Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 491b30b806 chore(desktop): release v0.0.17 2026-08-25 01:49:58 -07:00
Saoud Rizwan 8b046d04f9 feat(desktop): Customize hub, sidebar overhaul, and settings polish (#13538)
* feat(desktop): merge customization pages into a Customize hub with inline marketplace

Replaces the Plugins page and the dedicated Marketplace page with a single
Customize hub. Tabs: Skills, MCP, Plugins, Rules, Hooks, Tools, each with
live counts. Tabs backed by a marketplace catalog render the installed
items followed by an inline browsable Browse section (CLI-hub style), so
installing from the catalog immediately reflects in Installed above.

- Installed cards restyled to mirror the browse-card anatomy: bg-card p-4
  containers, absolute top-right xs Uninstall matching Install, truncating
  semibold titles, primary-tinted icons, real Badge components instead of
  ad-hoc bordered spans, un-indented line-clamped descriptions
- Rules/Hooks/Tools rows brought into the same card language; redundant
  intro paragraphs (duplicating the page description) removed; Tools group
  headers match the Installed header style with counts
- Marketplace section header renamed to Browse; duplicate 'N results' row
  removed (the header count is the single source)
- MCP embedded view now shows the full marketplace instead of
  installed-only

* feat(desktop): overhaul sidebar sessions and navigation

Sessions list:
- Sort toggle removed; sessions are always grouped by project, with pinned
  sessions leading each group (both subsets ordered by recency). The
  Pinned/Scheduled/Tasks category sections and their time-mode paging
  machinery (page-fill effect included) are deleted
- Scheduled sessions get an inline clock icon next to the pin position;
  pin + clock render together when both apply, and the running/unread
  status dot now coexists with them
- One font size (text-sm) across the list: titles, timestamps, project
  headers, show-more buttons, empty states. sidebarText needed !text-sm
  because the default button size's text-base wins the twMerge conflict
- Gradient fade under the Sessions header once the list scrolls, so rows
  fade out instead of hard-clipping
- The session-detail hover card is controlled from the sidebar and closes
  on scroll (Radix receives no pointer events while scrolling, so it used
  to float over moving content)
- Sidebar min resize width raised 224->260px; the per-project show-more
  label truncates so its nowrap text can't force rows to overflow and clip
  timestamps at narrow widths

Navigation:
- Customize replaces the Plugins/Marketplace/Hooks/Rules/Tools sidebar
  entries; Schedules and Customize are hidden from the expanded settings
  nav (their top rows cover them) but stay reachable when collapsed
- The settings gear always opens General instead of resuming the last
  section; the Account no-op hover special case is gone
- The New row highlights (aria-current) while the fresh not-yet-started
  task page is showing and hands off to the session row once the task
  starts; hitting New also focuses the prompt input via a window-event
  signal (lib/prompt-input-focus.ts) since the sidebar and composer sit in
  distant subtrees
- Fixed the xs button size collapsing any icon-bearing button to 12x12
  (leftover has-[>svg]:size-3 from when xs was a micro button) — this was
  why Uninstall buttons rendered broken next to Install

* feat(desktop): polish settings pages and chat composer

Models page:
- The provider detail panel is always open: no X button, no empty
  no-selection state. It defaults to the first connected provider (falling
  back to the first in the catalog), which also removes the layout shift
  that happened when the page swapped between full-width and panel
  variants on selection
- Fixed the list pane becoming unscrollable while the panel was open:
  grid items default to min-size auto, so the pane grew past its track
  inside the overflow-hidden grid and its ScrollArea had nothing to
  scroll; wrapped it in a min-h-0 min-w-0 cell
- Add Provider opens a Dialog instead of swapping the page
  (AddProviderContent gained a dialog variant that renders only the form)
- Embedded inputs (provider search, model search, detail fields) share one
  EMBEDDED_INPUT_CLASS stripping the Input component's own border/dark bg
  tint/shadow/ring, which rendered as a mismatched inner box; the model
  search box uses the same h-9/px-3 frame as the provider search
- Model list flows with the page instead of a max-h capped inner scroller

Other pages:
- Account uses the shared PageFrame/PageHeader: left-aligned, text-3xl
  title, Sign Out in the header actions slot
- Desktop notifications is one General section: header row plus the
  Event/Notify/Sound matrix nested in a card, so its rows no longer read
  as top-level peers of Dark mode; 'Available in the desktop app' label
  removed
- Schedule page retitled from Schedules with a real description; Customize
  description rewritten

Chat composer:
- The voice dictation button only renders once a voice model is
  configured (Settings -> Voice); the unconfigured deep-link state is
  gone (prop type kept for an easy restore)
2026-08-25 01:43:58 -07:00
Saoud RizwanandSaoud Rizwan 8a6c6f8afe Redesign desktop Model Providers page and split voice input into its own settings page (#13531)
* Redesign desktop Model Providers page and split voice input into its own settings page

- Group providers into Connected / Popular / All with auth-kind hints and
  connection status instead of per-row enable toggles
- Show browser sign-in (not an API key field) for OAuth providers, with a
  collapsed manual-key escape hatch where supported, plus explicit
  Connect / Disconnect / Sign out actions
- Move voice input to a dedicated Settings > Voice page that only offers
  connected transcription-capable providers, preselects a default model
  (streaming preferred), and stays disabled in the sidebar until a
  provider is connected

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

* Show native tooltip on the disabled Voice settings nav item

Disabled buttons drop pointer events, so the 'connect a model provider'
hint moves to a wrapping span for the browser tooltip to render.

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

* Drop letter avatars and gray provider ids from provider rows and voice chips

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

* Drop model counts from provider list rows

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

* Rename provider Connected status to Configured and drop the green styling

A settings entry is configuration, not a live connection; neutral gray
text avoids implying an active link, since the user still picks which
configured provider to use per chat.

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

* Resync provider catalog from disk when a settings save fails

Connect/disconnect/credential edits update the list optimistically; a
failed save now reloads the catalog instead of leaving the optimistic
state (and the view's module cache) claiming a configuration that was
never persisted.

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

* Rename oauthProvider test fixture to dodge CodeQL name heuristic

CodeQL's clear-text-storage query flags any identifier matching 'oauth'
as a credential source and traced the fixture's provider id into the
favorite-models localStorage write, which stores only provider/model id
strings. Renaming the fixture removes the false-positive source.

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

* Guard catalog reloads against races and resync detail drafts on failed saves

Optimistic provider mutations now bump a generation that discards any
in-flight catalog response, so a failed-save recovery reload can't
overwrite a newer edit with an older disk snapshot. The recovery also
remounts the provider detail panel via a reset token so its local field
drafts reflect the reloaded on-disk state instead of unpersisted edits
or an optimistically cleared disconnect.

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

* Fix failed-save recovery ordering and retry superseded reloads

Remount the provider detail only after the authoritative catalog reload
lands, so its drafts re-seed from disk state rather than the optimistic
values that failed to persist. When a concurrent edit supersedes the
recovery's in-flight response, retry the reload (bounded) instead of
dropping it, since that edit performs no reload of its own.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 23:54:09 -07:00
Saoud RizwanandSaoud Rizwan 4f5f238407 Desktop app: organize sidebar sessions into Pinned, Scheduled, and Tasks sections (#13528)
* Add Pinned/Scheduled/Tasks categories to desktop app sidebar

Replace the Schedules and Favorites filter-menu options with visible
collapsible category sections in the session sidebar, and rename the
Favorite action to Pin across the sidebar and sessions view.

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

* Grow full history window when Tasks show-more outpaces loaded tasks

loadMoreSessions treats its argument as a limit on all sessions, but the
Tasks show-more count only tracks Task rows, so once pinned/scheduled
rows pushed the loaded total past the requested count the call no-oped
and clicks went dead. Grow the whole history window via
loadOlderSessions instead, and only when the loaded tasks cannot fill
the next page. Addresses Greptile review on #13528.

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

* Auto-fill the Tasks page instead of fetching once per show-more click

A single 50-session window growth can consist entirely of pinned or
scheduled sessions, leaving a show-more click with no visible Tasks
progress. Replace the one-shot fetch with a page-fill effect that keeps
growing the history window until the requested Tasks page fills or
history runs out. Addresses the follow-up Greptile review on #13528.

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

* Halt page-fill retries after a failed history fetch

A failed fetch leaves the task count and has-more state unchanged,
which are exactly the conditions the page-fill effect fires on, so one
failing request would retry and re-toast forever. Halt the effect after
a failure and let the next explicit show-more click retry. Addresses
the third Greptile review on #13528.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 22:51:00 -07:00
Saoud RizwanandSaoud Rizwan 8f69880ac4 Hide Channels and Agents sections from desktop app sidebar (#13527)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 21:54:59 -07:00
Saoud RizwanandSaoud Rizwan a0c341e93c desktop: sidebar navigation cleanup with New/Schedule/Customize rows and dialog-based search (#13533)
* desktop: clean up sidebar navigation chrome

- Give New Task its own full-width labeled row below the logo row
  instead of an ambiguous icon next to the agenda toggle
- Wire the New Task row to the home action so starting a new task
  clearly takes you home (the logo still works as a fallback)
- Swap back/forward chevrons for browser-style arrow icons and
  bump their size

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

* desktop: sidebar New/Schedule/Customize rows and always-visible search

- Stack New (plus icon), Schedule, and Customize as full-width labeled
  rows below the logo; whole row highlights on hover via sidebarItem
- New starts a fresh task (home), Schedule opens Settings > Schedules,
  Customize opens the Customizations sections (Plugins first)
- Show the session search bar permanently above the sessions list
  instead of hiding it behind a search icon toggle

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

* desktop: move session search into a dialog behind a logo-row icon

- Replace the inline sidebar search bar with a search icon in the
  logo row that opens a cmdk command dialog listing sessions
- Selecting a result opens that session and closes the dialog
- Remove the agenda/tasks toggle the icon replaces, along with the
  now-unreachable sidebar Agenda panel (the welcome screen still
  surfaces agenda tasks)

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

* desktop: load full session history when the search dialog opens

Addresses Greptile review on #13533: the dialog only searched the
currently loaded history batch, so older unloaded sessions could not
be found. Opening search now kicks off loadAllSessions() (the hook's
purpose-built global-search loader), and the empty state reads
'Searching older sessions...' while more history is streaming in.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 21:53:47 -07:00
Saoud RizwanandSaoud Rizwan f91af30401 Add Desktop App and Cloud Platform to bug report issue template (#13532)
* Add Desktop App and Cloud Platform to bug report surfaces

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

* Rename Surface Diagnostics field to Diagnostics

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 19:49:47 -07:00
Saoud RizwanandSaoud Rizwan 83b2588c9c Disable the agent todo tool and hide the Agenda UI in the desktop app (#13530)
* remove todo tool and Agenda UI, keep schedule-only tasks tool

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

* chore: biome formatting fixes

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

* restore agenda backend; disable todo kind behind a flag instead of deleting

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

* keep agenda automation pump idle while the todo tool is disabled

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

* remove todo tool and Agenda UI altogether (revert the disable-flag hybrid)

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

* restore all agenda code to main state

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

* disable agent todo tool and hide Agenda UI behind flags

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 19:35:51 -07:00
Saoud RizwanandSaoud Rizwan 6e09e81a79 Add suggested schedule templates to the desktop Schedules page (#13529)
* Add suggested schedule templates to desktop Schedules page

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

* Fix unreadable selected text in inputs caused by selection utility conflict

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

* Restyle Suggested section label as small gray uppercase

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

* Hide suggested schedule cards that match an existing schedule name

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 18:14:01 -07:00
Saoud RizwanandSaoud Rizwan dc43a57fd7 fix(tools): create new files with the platform-native line ending (#13521)
* fix(tools): use platform-native EOL for new files and preserve CRLF in apply_patch updates

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

* simplify to the minimal new-file EOL fix

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

* extract shared normalizeNewFileLineEndings helper

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:54:54 -07:00
Saoud RizwanandSaoud Rizwan 833cc891b5 chore(vscode): hide per-tool MCP auto-approve checkboxes behind a flag (#13522)
* chore(vscode): remove per-tool MCP auto-approve checkboxes from webview

MCP auto-approval is now governed solely by the global 'Use MCP servers'
toggle; the SDK approval path (shared with the CLI and desktop app) has no
per-tool granularity, so the per-tool and 'Auto-approve all tools'
checkboxes were no-ops that implied control that no longer exists. Remove
them from the MCP settings view and chat tool rows. The autoApprove arrays
in cline_mcp_settings.json and the toggleToolAutoApprove RPC are left
intact for the legacy extension.

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

* chore(vscode): hide per-tool MCP auto-approve checkboxes behind a flag

Keep the checkbox components, handlers, and RPC plumbing intact but gate
rendering behind SHOW_MCP_PER_TOOL_AUTO_APPROVE=false: the SDK approval
path (shared with the CLI and desktop app) is all-or-nothing via the
global 'Use MCP servers' toggle, so the per-tool checkboxes were no-ops.
Flip the flag back on if the SDK gains per-tool approval granularity.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:47:15 -07:00
Saoud RizwanandSaoud Rizwan a2fb1d15ca fix(core): prevent search_codebase from crashing the process on giant single-line files (#13525)
* fix(core): prevent search_codebase from crashing the process on giant single-line files

searchWithRipgrep buffered all of rg's --json stdout into one string. Each
JSON event embeds the full text of the matched line (--max-columns is
ignored in JSON mode), so searching a directory of serialized trace dumps
(single-line multi-hundred-MB JSON files) accumulated gigabytes of stdout
until string concatenation threw RangeError: Out of memory inside the
stream data handler. That throw is outside the tool's try/catch, so it
escalated to an uncaughtException and killed the CLI/hub daemon.

Parse rg's JSON events incrementally line by line, drop events larger
than 256KB, truncate matched/context lines to MAX_LINE_CHARS, and stop
reading once maxResults is reached. The fallback regex scan now skips
files larger than 10MB (reporting the skip count) and truncates its
context lines the same way.

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

* simplify search_codebase crash fix to a minimal diff

Replace the incremental JSON-event parser with three small guards: stop
buffering rg stdout past 10MB, drop the trailing partial event before
parsing, and slice fallback context lines to MAX_LINE_CHARS. Drops the
fallback file-size skip and skip-count reporting.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:45:59 -07:00
Saoud Rizwan 8e7a55498b chore(cli): release v3.0.58 2026-08-24 15:44:16 -07:00
Saoud Rizwan 0cfc901589 fix(hub): flush the /shutdown 202 before daemon teardown
The /shutdown handler queued teardown on a microtask, which runs before
the event loop's write phase, so the daemon could process.exit() before
the accepted 202 was handed to the socket. Unix masked it (uv_try_write
lands small loopback writes synchronously); Windows has no such fast
path and lost the race regularly — the recurring shutdown.e2e.test.ts
'socket hang up' failures on windows-latest. Start teardown from the
response's write callback instead, with an idempotent 1s fallback so a
client that vanishes mid-write cannot strand the daemon, and send
Connection: close so the client gets a FIN rather than an abort.

Since the flakiness this compensated for is fixed at the source, restore
maxWorkers: 2 for the Windows core suite (serializing it cost ~3 min of
CI per run), and raise the e2e daemon discovery hang guard 10s→30s —
it guards against hangs, not runner speed.
2026-08-24 15:43:44 -07:00
Saoud RizwanandSaoud Rizwan a5c3181b78 fix(vscode): don't steal last-used provider from ClinePass on credential refresh (#13520)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 15:18:04 -07:00
Saoud Rizwan 8cb60caf0c chore(sdk): release v0.0.79 2026-08-24 14:55:30 -07:00
Mikołaj Kondratek c03e452315 fix(sdk): carry root overrides into the Node smoke-test sandbox (#13517)
ci-node-smoke.ts installs the packed SDK tarballs with a plain npm
install in a fresh temp dir, where the repo root package.json overrides
do not apply. When @sap-cloud-sdk 4.9.0 shipped (2026-08-24) it broke
@sap-ai-sdk/ai-api 2.14.0 (via @jerome-benoit/sap-ai-provider in
@cline/llms) with ERR_PACKAGE_PATH_NOT_EXPORTED, failing the smoke step
on every PR even though the root already pins @sap-cloud-sdk/* to 4.6.0.

Copy the root overrides block into the generated sandbox package.json
so the smoke install resolves the same pinned versions as the repo and
future third-party releases cannot break it independently.
2026-08-24 23:49:52 +02:00
Saoud RizwanandSaoud Rizwan 83ff8fd2f5 fix(hub): cap hub-events db size so it can't fill the disk (#13516)
* fix(hub): cap hub-events db size so it can't fill the disk

Row/time retention alone didn't bound disk usage: envelopes carrying
full session snapshots reach hundreds of KB each, so retained rows
could total tens of GB, sweeps only ran hourly, and DELETE never
shrinks a SQLite file. Enforce a 64 MiB size budget in prune() (oldest
rows first, VACUUM to return the space), and also prune after every
16 MiB appended so bursts can't outrun the hourly timer.

Fixes #13505

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

* fix(hub): tolerate VACUUM failure on a full disk

VACUUM needs scratch space and can fail in exactly the state a
ballooned event log causes. The byte-budget deletes already bound live
data, so swallow the error and let the next sweep retry the reclaim
instead of aborting startup pruning and disabling the durable log.

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

* fix(hub): count the size budget in UTF-8 bytes, not characters

envelopeJson.length (UTF-16 units) and SQLite LENGTH() (characters)
undercount multibyte text by up to 3x, which could leave a CJK-heavy
log settled above budget and re-running VACUUM every sweep. Use
Buffer.byteLength and LENGTH(CAST(... AS BLOB)) instead.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 14:47:26 -07:00
Mikołaj Kondratek 397a6a3341 fix(vscode): resolve hook workspace identity from the window, not shared global state (#13352)
* fix(vscode): resolve hook workspace identity from the window, not shared global state

Hook discovery, hook cwd selection, and the workspaceRoots metadata passed
to hook scripts all read the workspaceRoots/primaryRootIndex global state
keys. Global state lives in ~/.cline and is shared by every Cline instance
(all VS Code windows, the CLI, the JetBrains plugin), and nothing writes
these keys anymore, so hooks resolved against whatever project some other
or older instance last recorded. With a second window open on another
project, a workspace's .clinerules/hooks scripts were never discovered.

Resolve workspace roots via a single guarded helper backed by
HostProvider.workspace.getWorkspacePaths() (in-process, window-scoped,
same as refreshHooks): blank paths are filtered, a host-bridge failure
degrades to no workspace roots instead of silently disabling global hooks
or skipping blocking PreToolUse guards, and one resolution is threaded
through hooks-dir discovery, cache misses, cwd selection, and hook input
metadata so they can't disagree (previously up to four host lookups per
hook execution — real gRPC round trips in the standalone host). Roots and
hooks dirs are matched on whole path segments with the longest root
winning, so prefix-sharing or nested workspace roots resolve to the right
project. The adapter creates the runner once per event and skips no-op
runners, making creation the single resolution point; the separate
hasHook/getHookInfo checks are removed. The dead workspaceRoots and
primaryRootIndex state keys are dropped, and the four hand-rolled
HostProvider.workspace test stubs are consolidated into one shared
helper.

* test(vscode): add e2e coverage for workspace-scoped hook discovery

Boots real VS Code with the packaged extension against the workspace
fixture, sends a prompt, and asserts the fixture's UserPromptSubmit hook
was discovered from the open window's workspace, executed with that
workspace root as its cwd, and received the same root in its
workspaceRoots input — the end-to-end contract the hook workspace
identity fix establishes.

* test(vscode): isolate the e2e hook fixture from the shared workspace

The UserPromptSubmit fixture hook lived in the shared e2e workspace, so
every prompt-sending spec executed it (hooksEnabled defaults to true) —
and its cold PowerShell spawn on Windows pushed chat.test.ts past the
5s expect timeout. hooks.test.ts now overrides workspaceDir to a
dedicated workspace-hooks fixture, so only the hooks spec pays the hook
spawn.
2026-08-24 23:28:11 +02:00
Saoud Rizwan c9b75155ea fix(cli): remove the $4.99 ClinePass promo copy (#13514)
The $4.99 first-month promo is ending, so the CLI's first-launch "Try ClinePass" dialog should no longer advertise it. Also drops the leftover CLI_PROMO_CODE plumbing, which has been an empty string since the promo-code flow was removed.
2026-08-24 13:09:42 -07:00
Saoud Rizwan 09ee902639 chore(vscode): release v4.1.15 2026-08-23 12:33:23 -07:00
Saoud RizwanandSaoud Rizwan 2b7b01328a fix(vscode): auto-approve all MCP tool calls when the MCP toggle is on (#13498)
* fix(vscode): honor MCP auto-approve settings for SDK tool calls

The SDK extension required both the global 'Use MCP servers' auto-approve
toggle AND each tool's per-tool autoApprove flag before silently approving
an MCP call, while the legacy extension treated them as either/or. Restore
the legacy OR semantics so toggling MCP auto-approve works again.

Also key toolPolicies by the registered SDK tool name (via
defaultMcpToolNameTransform, now exported from @cline/core) instead of raw
server__tool. Servers whose names contain sanitized characters (e.g.
marketplace names like github.com/user/repo) or exceed 64 chars produced
policy keys that never matched the registered tool, so those MCP tools ran
without any approval gate; the live auto-approve lookup now re-applies the
transform instead of string-splitting the name.

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

* Revert "fix(vscode): honor MCP auto-approve settings for SDK tool calls"

This reverts commit 86c568fbba.

* fix(vscode): auto-approve all MCP tool calls when the MCP toggle is on

The SDK extension only auto-approved an MCP call when the global 'Use MCP
servers' auto-approve toggle AND that tool's per-tool autoApprove flag were
both set, so toggling MCP auto-approve appeared to do nothing and users had
to opt in each tool individually. The toggle alone now governs all MCP
tools; the per-tool flag is no longer consulted.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-23 12:12:32 -07:00
Saoud Rizwan be8b984d10 chore(vscode): release v4.1.14 2026-08-23 02:41:04 -07:00
Saoud Rizwan c870116d1d fix(telemetry): emit task.completed from every session teardown path (#13489)
The task.completed fallback lived only inside shutdownSession, but
stopSession/dispose route interactive sessions with a terminal reported
status through releaseSessionRuntime, which never emitted. Truthful
session-status reporting (shipped in 4.1.11) re-routed a large share of
interactive stops onto that branch and silently dropped the event.

Route the emission through a single choke point,
emitTaskCompletedOnTeardown, called from both shutdownSession and
releaseSessionRuntime. The completion criterion no longer reads
session.status: interactive sessions use the recorded final-turn
outcome (lastInteractiveTurnFinishReason), non-interactive sessions
keep the existing input.status === "completed" logic. A new
taskCompletedEmitted flag (also set by the submit_and_exit observer)
enforces exactly one task.completed per session. failSession now
records the errored final turn so a stale "completed" from an earlier
turn can never leak into the teardown emission. Telemetry only; no
user-facing behavior changes.
2026-08-23 02:34:26 -07:00
Saoud Rizwan 4f836ae7d0 test(sdk): give windows-sensitive suites realistic timeouts
Four consecutive SDK publish runs failed on windows-latest, each on a
different test, all of them plain timeouts: two @cline/shared SQLite
tests at the 5s vitest default, core's bash executor at 10s, and the hub
singleton endpoint test at 10s. The 2-core Windows runner spawns forks
and takes SQLite locks slowly enough to blow those budgets under load.

These timeouts guard against hangs; they are not timing assertions (the
one suite that does assert elapsed time, shutdown.e2e, was fixed by
removing file-level parallelism instead). Raise core to 20s and give
@cline/shared an explicit 15s in place of the inherited 5s default.
2026-08-22 16:39:19 -07:00
Saoud Rizwan 6cb653a362 chore(desktop): release v0.0.16 2026-08-22 16:34:23 -07:00
Saoud Rizwan 5077fe8697 fix(core): run hub e2e files serially so daemon timing budgets survive CI contention
singleton.e2e.test.ts (added in #13468) spawns real daemons and runs for
~15s. Vitest's default file parallelism let it run alongside
shutdown.e2e.test.ts, whose assertions are wall-clock bound: discovery
within 10s, exit within 5s, and a 2s shutdown watchdog. On the 2-core
windows-latest runner that contention alone broke those budgets, failing
the shutdown test two different ways across runs — once never observing
discovery, once with the daemon forced to exit before its HTTP 202
flushed (socket hang up). The test passed on Windows before #13468 and
has failed every SDK publish run since.
2026-08-22 15:18:04 -07:00
Saoud Rizwan 2266fe8cf4 chore(cli): release v3.0.57 2026-08-22 13:25:40 -07:00
Saoud Rizwan 21cb8d2525 chore(sdk): release v0.0.78 2026-08-22 13:03:18 -07:00
Saoud Rizwan 68ad354b52 chore(vscode): prepare 4.1.13 release 2026-08-22 12:50:54 -07:00
Saoud RizwanandSaoud Rizwan e098a8ed0d fix(core): stop stored capability lists from silently revoking tool calling for custom models (#13476)
* fix(core): seed tools capability when custom model capabilities are synthesized from boolean flags

For a models.json entry with no explicit capabilities list, toStoredModelInfo
synthesized a capability array purely from boolean convenience flags (e.g.
supportsReasoning: true -> ["reasoning"]). modelSupportsToolCalling fails open
only for a missing or empty list, so the synthesized non-empty list read as an
authoritative denial and silently stripped every tool definition from requests
to custom OpenAI-compatible models (#13463).

Seed "tools" whenever the list was not explicitly authored and the boolean
projections made it non-empty, preserving the fail-open contract. Explicitly
authored capability lists remain authoritative and can still disable tools.

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

* test(core): cover stale catalog capability overrides

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

* fix: treat stored capability lists as non-authoritative for tool calling

The hasExplicitCapabilities guard still let two producers of tool-less
lists through:

- The VS Code legacy-override migration (legacyModelInfoToOverrides)
  persists explicit partial lists like ["prompt-cache"] into models.json
  for custom OpenAI-compatible models, which then read as an authoritative
  "cannot call tools" and drop every tool - same symptom as #13463.
- Any hand- or UI-authored partial list on a non-catalog model.

Stored entries and user-authored provider metadata have no way to declare
"cannot call tools" (there is no supportsTools field, and every writer
that authors a full list includes "tools"), so seed "tools" into any
non-empty list for a language model. Only generated catalog capabilities
remain authoritative - a genuine no-tools catalog model stays that way -
and non-language models (e.g. image generation) never gain a tools claim.

Also make legacyModelInfoToOverrides write "tools" into the arrays it
fabricates, matching the providers.json migration, so models.json stops
being poisoned for older readers.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-22 12:42:09 -07:00
1de61b178a feat(hub): add drain and upgrade commands with replay support (#13468)
* feat(hub): add drain and upgrade commands with replay support

* handles disconnection

* feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport

Completes the wiring the previous commits' primitives needed:
HubServerTransport gains isDraining(), hub.drain/hub.status/profile.get
command handling, and replayEventsAfter() (backed by the durable event
log), plus the sequence/sinceSequence wire types they depend on in
shared/hub.ts. run-queue-handlers.ts reads the active bot profile's
plugin roots when executing durable runs.

Also adds hub/profiles/: profile.json (identity/rules/plugins) ->
system prompt composition, --profile / CLINE_HUB_BOT_PROFILE
resolution, and the bundled cline-dad profile with its
cline_hub_support read-only diagnostics tool.

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

* Revert "feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport"

This reverts commit 6696d5d202.

* fix(hub): dedupe replayed events by eventId, not just sequence

HubEventLogStore.append() returns a new envelope stamped with a
sequence rather than mutating the input, so a pending approval
re-issued sequence-less by subscribe() (it predates any durable-log
append) and its later sequence-stamped copy from the durable log are
two different objects carrying the same eventId. The replay-then-live
buffer in browser-websocket.ts only deduped by sequence, so the
sequence-less copy's guard never tripped and it was delivered a second
time when the buffer flushed after replay.

Track delivered eventIds alongside the sequence cursor; eventId
survives the append/stamp round-trip unchanged, so this dedupes the
exact-same logical event regardless of which copy arrives first.

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

* fix(hub): wire drain, durable event log, and run queue into the live transport

CI on this branch failed bun run build:sdk: browser-websocket.ts,
client/index.ts, and hub-websocket-server.ts (already on this branch)
reference sequence/sinceSequence, HubServerTransport.isDraining(), and
the "hub.drain" command — but the commit that reverted bot profiles
out of this branch also reverted this wiring, since it shared a commit
with the profiles work. That wiring is a hub concern, not a
bot-profiles one; split it back out.

- shared/hub.ts: sequence/sinceSequence types, run.enqueue/run.list/
  hub.drain/hub.status/stream.replay capability, command, and event
  names. profile.get intentionally excluded — stays bot-profiles-only.
- context.ts: isDraining() on HubTransportContext. botProfile field
  intentionally excluded.
- hub-server-transport.ts: eventLog/runQueue fields and start/stop
  lifecycle, publish() appends to the durable log, handleCommand cases
  for run.enqueue/run.list/hub.drain/hub.status, drain-refusal check,
  replayEventsAfter()/lastEventSequence(). startBotProfile()/
  startHubSupportTool() and the profile.get case intentionally
  excluded.
- run-queue-handlers.ts: added without handleProfileGet (needs
  ctx.botProfile, which doesn't exist here).
- hub-upgrades.test.ts: added without its two bot-profile-injection
  tests (they need a resolved bot profile to assert against).

Verified bun run build:sdk exits 0 (the exact CI command) and
bunx vitest run src/hub passes (311/312; the one failure is the
same pre-existing environment-timing flake already present before
this change).

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

* fix(hub): export instance-lock, event-log, and run-queue from the hub barrel

These landed as internal modules only; hub-server-transport.ts and
hub-websocket-server.ts import them by direct path, but nothing
re-exported them from the public @cline/core/hub surface the way
sibling discovery/server modules already are.

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

* fix(hub): wire the instance lock into the daemon entry point

The singleton lock (discovery/instance-lock.ts) and its consumption in
startHubWebSocketServer/ensureHubWebSocketServer were already on this
branch, but the daemon entry point's own half was not: retrying a bind
when a retiring predecessor still holds the lock, and exiting with a
distinct code (3) instead of the generic fatal path when a live Hub
already owns the data directory. Without this, a daemon racing a
retiring predecessor could fail outright instead of waiting the lock
out, and losing the singleton race looked identical to a crash.

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

* fix(hub): address drain/upgrade review findings (#13478)

- cline hub upgrade: check idleness at least once (--wait 0 works), reject
  non-numeric --wait, and un-drain on every abort path so an aborted
  upgrade can never leave the hub refusing new work
- add cline hub drain --off and the off query param to requestHubDrain so
  POST /drain?off is reachable from shipped code
- HubEventLogStore/HubRunQueue: WAL journal mode + busy_timeout, and stamp
  sequences from lastInsertRowid instead of SELECT MAX(sequence)
- HubInstanceLock.acquire: degrade to an unheld lock when SQLite is
  unavailable instead of refusing hub startup; only BUSY/LOCKED still
  raises HubLockHeldError
- ensureHubWebSocketServer: retire an unusable discovered hub through the
  shared retireDiscoveredHub (busy hubs are attached to, drain precedes
  shutdown, discovery cleared only when the hub actually retired)
- replay adapter: advance the cursor past eventId-deduped events, cap
  replay pages, stop when the cursor stalls, and drop the dedupe set after
  the buffered flush so it cannot grow for the socket lifetime

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(hub): derive the singleton e2e challenger cwd portably

The challenger's working directory was derived by round-tripping the
discovery path through a file: URL and stripping the last pathname
segment. On Windows that yields a POSIX-style '/C:/...' path, which is
not a valid spawn cwd, so the spawn fails ENOENT before the singleton
lock is ever contested and the Windows SDK test job goes red.

The data dir is simply the discovery file's parent: use dirname().

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 21:49:21 -07:00
Saoud Rizwan e7ed29109b ci(vscode): make combined nightly manual-dispatch only
The PublishNightly environment gained required reviewers, so each cron
run parked on approval, held the workflow's concurrency group, and
silently cancelled every scheduled run queued behind it. 20 consecutive
scheduled nightlies died this way between 2026-07-31 and 2026-08-21;
the only nightlies that shipped in that window were manual dispatches.

Drop the cron rather than leave a trigger that cannot succeed unattended.
2026-08-21 17:01:56 -07:00
BeeandCursor Agent 9316de6bb5 fix: propagate Langfuse session telemetry (#13473)
* fix telemetry session propagation

* feat telemetry client version metadata

* fix(core): address Langfuse review feedback — hub client identity + delegated agent session grouping (#13475)

* fix(core): rebuild hub session client identity from request headers

Hub-backed sessions do not transport extensionContext (it is local-only),
so the daemon's runtime built traces without the clientName/clientVersion
metadata even though the hub client bakes X-CLIENT-TYPE / X-CLIENT-VERSION
into the session's provider headers. Reconstruct extensionContext.client
from those headers during local runtime bootstrap so hub-backed Langfuse
traces carry the same client identity as local runtimes, and the daemon's
header re-resolution stops clobbering the original X-CLIENT-TYPE.

* fix(core): propagate parent distinctId/sessionId to delegated agents

Delegated agents (spawned sub-agents, configured agents, teammates) were
built without distinctId and sessionId, so their Langfuse traces had no
userId or sessionId and did not group with the parent user or session.
Thread the host-resolved distinctId through RuntimeBuilderInput and the
root sessionId through the delegated-agent config provider, and copy both
onto the delegated AgentConfig.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-21 16:34:21 -07:00
Tomás Barreiro fb58e340a2 Add feature flags to the desktop app (#13289)
* Add feature flags to the app

* React to account updates

* Address comments

* use a per-app file
2026-08-22 00:25:30 +02:00
Saoud Rizwan db6d18a98a chore(vscode): prepare 4.1.12 release 2026-08-21 13:53:56 -07:00
2ea460fa46 Treat an empty preserved capability list as unspecified when seeding tools (#13465)
* Treat an empty preserved capability list as unspecified when seeding tools

toSdkModelInfo guarded the tools seeding with a strict
preservedCapabilities === undefined check, but modelHasCapability —
the runtime's own reader — treats undefined AND length === 0 as
"unspecified". A custom OpenAI-Compatible model whose stored
capabilities field is a defined-but-empty array (a config carried over
from before the field existed, or one round-tripped through a boundary
that defaults it to []) skipped the seeding; the first boolean
projection to run afterwards (e.g. supportsReasoning) then populated
the array, the runtime gate read the non-empty, tool-less list as
authoritative, and every tool definition was silently dropped from the
session (#13463).

The guard now covers the empty array too, matching the reader's
unspecified semantics.

* test: satisfy the store's isModelInfo gate so the empty-capabilities case actually reaches knownModels

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

---------

Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 13:42:01 -07:00
Saoud RizwanandSaoud Rizwan 7d366ce7d4 fix(vscode): remote config MCP settings (#13466)
* fix(vscode): enforce enterprise MCP controls on the Customize marketplace

The unified Customize marketplace replaced the old MCP marketplace
without carrying over enterprise remote-config enforcement: the catalog
RPC returned every MCP entry and installs were never policy-checked,
so orgs with mcpMarketplaceEnabled=false or an allowedMCPServers
allowlist saw (and could install) all marketplace MCP servers.

- Filter MCP entries out of getMarketplaceCatalog when the marketplace
  is disabled, and restrict entries to the allowlist when configured
  (matching entry id, display name, installed server name, or source
  repo URL, mirroring legacy GitHub-URL allowlist ids)
- Reject installMarketplaceEntry requests that violate the policy
- Map the published catalog's repo/homepage fields onto
  sourceUrl/homepageUrl so URL-based allowlists can match
- Update the enterprise MCP server controls docs

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

* refactor: simplify MCP marketplace policy enforcement

Fold the policy check into marketplace-helpers, drop the dedicated
test suite, and trim the docs edit to the strictly necessary line.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 12:40:01 -07:00
170 changed files with 14005 additions and 4610 deletions
+7 -3
View File
@@ -15,6 +15,8 @@ body:
- VSCode Extension
- JetBrains Plugin
- CLI
- Desktop App
- Cloud Platform
default: 0
validations:
required: true
@@ -62,13 +64,15 @@ body:
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
label: Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
- Desktop App: paste the app version from the Settings view.
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
validations:
required: false
- type: textarea
@@ -14,9 +14,12 @@ name: ext-vscode-publish-nightly
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
# Manual dispatch only. The nightly cron was removed deliberately: the
# PublishNightly environment gained required reviewers, and an unattended
# cron run would just sit `waiting` on that approval, hold this workflow's
# concurrency group, and silently cancel every later scheduled run behind it
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
workflow_dispatch:
inputs:
legacy-ref:
@@ -74,8 +77,9 @@ jobs:
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
# NOTE: the || fallback is retained so this stays correct if a
# non-dispatch trigger is ever added back (inputs are empty strings
# on e.g. `schedule` events, where the declared default does not apply).
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## [4.1.15]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
## [4.1.14]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
### Fixed
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
## [4.1.13]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
## [4.1.12]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
## [4.1.11]
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
+15
View File
@@ -1,5 +1,20 @@
# Cline CLI Changelog
## 3.0.58
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
## 3.0.57
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
## 3.0.56
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.56",
"version": "3.0.58",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+147
View File
@@ -3,16 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockLocalHubHasNoActiveSessions,
mockProbeHubServer,
mockReadHubDiscovery,
mockRequestHubDrain,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockLocalHubHasNoActiveSessions: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockRequestHubDrain: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -27,8 +31,10 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
requestHubDrain: mockRequestHubDrain,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
@@ -95,6 +101,147 @@ describe("createHubCommand", () => {
});
});
function createCommand() {
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
);
return {
cmd,
output,
errors,
exitCode: () => exitCode,
};
}
it("sends an un-drain request with drain --off", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain", "--off"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain --off",
{ off: true },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: false,
url: "ws://127.0.0.1:25463/hub",
});
});
it("drains without the off flag by default", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain",
{ off: false },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(true);
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(errors).toEqual([]);
expect(exitCode()).toBe(0);
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
// The drain was never lifted manually: the drained hub was replaced.
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toEqual({
upgraded: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
const { cmd, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(1);
expect(errors[0]).toContain("still serving sessions");
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub upgrade aborted",
{ off: true },
);
});
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
const { cmd } = createCommand();
cmd.configureOutput({ writeErr: () => {} });
for (const sub of cmd.commands) {
sub.configureOutput({ writeErr: () => {} });
}
await expect(
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
).rejects.toThrow("--wait requires a non-negative number of seconds.");
expect(mockRequestHubDrain).not.toHaveBeenCalled();
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
+123 -1
View File
@@ -1,14 +1,16 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
localHubHasNoActiveSessions,
probeHubServer,
readHubDiscovery,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { Command, InvalidArgumentError } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
@@ -54,6 +56,16 @@ function resolveCliHubOwnerContext() {
: resolveSharedHubOwnerContext();
}
function parseWaitSeconds(value: string): number {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
throw new InvalidArgumentError(
"--wait requires a non-negative number of seconds.",
);
}
return parsed;
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -150,5 +162,115 @@ export function createHubCommand(
}),
);
hub
.command("drain")
.description("Refuse new mutating work while accepted runs finish")
.option("--reason <text>", "Why the hub is draining")
.option("--off", "Lift the drain and accept new mutating work again")
.action(
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
io.writeErr("No hub is running.");
fail();
return;
}
const draining = cmdOptions.off !== true;
const ok = await requestHubDrain(
discovery.url,
discovery.authToken,
cmdOptions.reason ??
(draining ? "cline hub drain" : "cline hub drain --off"),
{ off: !draining },
);
if (!ok) {
io.writeErr(
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
);
fail();
return;
}
io.writeln(JSON.stringify({ draining, url: discovery.url }));
}),
);
hub
.command("upgrade")
.description(
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
)
.option(
"--wait <seconds>",
"How long to wait for the hub to go idle",
parseWaitSeconds,
120,
)
.action(
action(async (cmdOptions: { wait: number }) => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
const drained = await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade",
).catch(() => false);
// An aborted upgrade must hand the hub back: leaving it
// draining refuses all new mutating work until a restart.
const undrain = async (): Promise<void> => {
if (!drained) {
return;
}
await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade aborted",
{ off: true },
).catch(() => false);
};
try {
const deadline = Date.now() + cmdOptions.wait * 1_000;
let idle = false;
// Check at least once so --wait 0 still observes an idle hub.
for (;;) {
idle = await localHubHasNoActiveSessions(
discovery.url,
discovery.authToken,
).catch(() => true);
if (idle || Date.now() >= deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
if (!idle) {
await undrain();
io.writeErr(
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
);
fail();
return;
}
await stopHubServer(opts.cwd);
} catch (error) {
await undrain();
throw error;
}
}
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(JSON.stringify({ upgraded: true, url }));
}),
);
return hub;
}
@@ -71,7 +71,6 @@ export function MigrationNoticeContent(
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
<text selectable>Try it now with a limited-time promo for $4.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
@@ -3,7 +3,6 @@ import {
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
import {
type DialogDismissKey,
isAnyKeyDismiss,
@@ -77,8 +76,5 @@ export function buildClinePassSubscriptionPageUrl(
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
if (CLI_PROMO_CODE) {
url.searchParams.set("code", CLI_PROMO_CODE);
}
return url.toString();
}
+3 -12
View File
@@ -18,20 +18,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "";
export function getCliSubscriptionUrl(): string {
if (!CLI_PROMO_CODE) {
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString();
}
return `${new URL(
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
).toString();
}
export function getCliNotSubscribedMessage(): string {
+27
View File
@@ -1,5 +1,32 @@
# Cline Desktop Changelog
## 0.0.17
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
- Added suggested schedule templates to the Schedule page
- Add Provider opens a dialog instead of swapping out the page
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
- Fixed the provider list being unscrollable while a provider detail panel was open
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
- Fixed Uninstall buttons collapsing to a broken square next to Install
- Fixed unreadable selected text inside input fields
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing the app on files containing a single enormous line
- The hub's event log can no longer grow until it fills your disk
## 0.0.16
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
- The app now honors server-side feature flags, refreshing them when your account changes
## 0.0.15
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.15",
"version": "0.0.17",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -33,10 +33,10 @@
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@pierre/diffs": "^1.3.0",
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^3.9.1",
"@pierre/diffs": "^1.3.0",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
@@ -80,6 +80,7 @@
"next": "16.2.11",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -5,6 +5,7 @@ import type { SidecarContext } from "./types";
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
@@ -50,6 +52,7 @@ beforeEach(() => {
clineAccountServiceCtorMock.mockReset();
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
saveProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
@@ -140,3 +143,162 @@ describe("cline_account command auth states", () => {
});
});
});
/**
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
* cover the mid-session transitions that would otherwise keep evaluating flags
* against a stale account (or the device).
*/
describe("cline_account keeps feature-flag identity in sync", () => {
async function currentFlagsUserId(): Promise<string | undefined> {
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
return getDesktopFeatureFlagsContext().userId ?? undefined;
}
async function runOperation(ctx: SidecarContext, operation: string) {
const { handleCommand } = await import("./commands");
return handleCommand(ctx, "cline_account", {
action: "clineAccount",
operation,
});
}
beforeEach(async () => {
const { resetDesktopFeatureFlagsForTesting } = await import(
"./feature-flags"
);
resetDesktopFeatureFlagsForTesting();
});
it("adopts the account identity on login", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({
id: "acct-1",
email: "dev@example.com",
});
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("leaves the signed-in identity intact across an organization switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
executeClineAccountActionMock.mockResolvedValue(undefined);
getProviderSettingsMock.mockReturnValue({
auth: { accountId: "stale-acct" },
});
await runOperation(ctx, "switchAccount");
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("adopts the identity from the refetch that follows a switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
executeClineAccountActionMock.mockResolvedValue(undefined);
await runOperation(ctx, "switchAccount");
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-2");
});
it("clears the account identity on logout", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// Signed out: no token resolves.
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBeUndefined();
});
it("clears the identity when sign-out blanks the cline auth settings", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// What the Sign Out button actually sends: a settings write that blanks
// the auth block. No account command is involved.
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
saveProviderSettingsMock.mockReturnValue({
providerId: "cline",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline",
api_key: "",
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
});
expect(await currentFlagsUserId()).toBeUndefined();
});
it("ignores settings writes for other providers", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
saveProviderSettingsMock.mockReturnValue({
providerId: "anthropic",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "anthropic",
api_key: "sk-test",
});
// Saving an unrelated provider must not disturb the Cline identity.
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("falls back to the device distinct ID after logout", async () => {
const { ctx } = createContext();
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
const deviceId = getDesktopFeatureFlagsContext().distinctId;
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
// Not left on the previous account's ID.
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
});
});
+64 -3
View File
@@ -70,6 +70,10 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
identifyDesktopFeatureFlagsAccount,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -294,6 +298,33 @@ function removePathIfExists(
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
function syncFeatureFlagsAccountFromResult(
ctx: SidecarContext,
operation: string,
result: unknown,
): void {
if (operation === "fetchMe") {
const user = result as { id?: string; email?: string } | undefined;
if (user?.id) {
void identifyDesktopFeatureFlagsAccount(
{ id: user.id, email: user.email },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
return;
}
}
function syncFeatureFlagsAccountFromSettings(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): void {
void identifyDesktopFeatureFlagsAccount(
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
async function resolveFreshClineAuthToken(
ctx: SidecarContext,
manager: ProviderSettingsManager,
@@ -1403,7 +1434,7 @@ export async function handleCommand(
const result = await backend.updateSession({ sessionId, metadata: merged });
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a favorite would
// updated_at, which clients sort and label rows by, so a pin would
// otherwise make an old session look like it just ran.
if (existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
@@ -1548,6 +1579,14 @@ export async function handleCommand(
// would be captured as error telemetry and shown raw to the user.
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
// Backstop for credentials that go away without a settings write —
// an expired or server-revoked token. Explicit sign-out is handled
// at its source in `save_provider_settings`; this catches the rest
// so a stale account never keeps serving its rollout cohort.
void identifyDesktopFeatureFlagsAccount(
{},
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
@@ -1556,10 +1595,12 @@ export async function handleCommand(
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
const result = await executeClineAccountAction(
args as ClineAccountActionRequest,
accountService,
);
syncFeatureFlagsAccountFromResult(ctx, operation, result);
return result;
}
// ── Provider management ────────────────────────────────────────────
@@ -1718,13 +1759,21 @@ export async function handleCommand(
}
if (command === "save_provider_settings") {
const manager = new ProviderSettingsManager();
return saveLocalProviderSettings(manager, {
const saved = saveLocalProviderSettings(manager, {
...readProviderSettingsUpdate(args),
providerId: String(args?.provider ?? ""),
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
// Sign-out is a `save_provider_settings` that blanks the cline auth block
// (see signOut in webview settings/account-view.tsx), so this is the
// authoritative signal — it fires the moment credentials are cleared
// rather than waiting for the next account fetch.
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncFeatureFlagsAccountFromSettings(ctx, manager);
}
return saved;
}
if (command === "add_provider") {
const manager = new ProviderSettingsManager();
@@ -1827,6 +1876,18 @@ export async function handleCommand(
return readGlobalSettings();
}
// ── Feature flags ──────────────────────────────────────────────────
// Flags are evaluated here, not in the webview: the sidecar already has
// the PostHog key inlined at build time and evaluates against the same
// distinct ID it reports telemetry with. The client just reads the
// resolved values.
if (command === "get_feature_flags") {
return await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
});
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
@@ -26,6 +26,10 @@ import {
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -627,6 +631,10 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
// Shuts down the PostHog client the feature flags service owns, flushing
// any pending $feature_flag_called events.
cleanup.push(disposeDesktopFeatureFlagsService());
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -1021,6 +1029,10 @@ export async function initializeSessionManager(
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
featureFlags: getDesktopFeatureFlagsService({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
hub: {
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildClinePostHogClient: vi.fn(() => ({ kind: "posthog-client" })),
PostHogFeatureFlagsProvider: vi.fn(function PostHogFeatureFlagsProvider(
this: Record<string, unknown>,
options: unknown,
) {
this.kind = "posthog";
this.options = options;
}),
NoOpFeatureFlagsProvider: vi.fn(function NoOpFeatureFlagsProvider(
this: Record<string, unknown>,
) {
this.kind = "noop";
}),
resolveCoreDistinctId: vi.fn(() => "machine-distinct-id"),
poll: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
setContext: vi.fn(),
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
// Two known flags keep the snapshot assertions meaningful even as the
// real registry changes.
FEATURE_FLAGS: ["ext-cline-pass", "ext-demo-flag"],
NoOpFeatureFlagsProvider: mocks.NoOpFeatureFlagsProvider,
resolveCoreDistinctId: mocks.resolveCoreDistinctId,
FeatureFlagsService: class {
options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
}
poll = mocks.poll;
dispose = mocks.dispose;
setContext = mocks.setContext;
getFlagPayload = mocks.getFlagPayload;
},
};
});
vi.mock("@cline/core/services/feature-flags/posthog", () => ({
buildClinePostHogClient: mocks.buildClinePostHogClient,
PostHogFeatureFlagsProvider: mocks.PostHogFeatureFlagsProvider,
}));
import {
buildFeatureFlagsSnapshot,
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsContext,
getDesktopFeatureFlagsService,
refreshDesktopFeatureFlags,
resetDesktopFeatureFlagsForTesting,
setDesktopFeatureFlagsAccountContext,
} from "./feature-flags";
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const originalIsTest = process.env.IS_TEST;
beforeEach(() => {
vi.clearAllMocks();
resetDesktopFeatureFlagsForTesting();
delete process.env.IS_TEST;
delete process.env.E2E_TEST;
});
afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.TELEMETRY_SERVICE_API_KEY;
} else {
process.env.TELEMETRY_SERVICE_API_KEY = originalApiKey;
}
if (originalIsTest === undefined) {
delete process.env.IS_TEST;
} else {
process.env.IS_TEST = originalIsTest;
}
});
describe("getDesktopFeatureFlagsService", () => {
it("uses PostHog when the build-time key is inlined", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.buildClinePostHogClient).toHaveBeenCalledWith("phc_key");
expect(mocks.NoOpFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("falls back to the no-op provider when no key was inlined", () => {
delete process.env.TELEMETRY_SERVICE_API_KEY;
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("never calls PostHog under IS_TEST even with a key present", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
process.env.IS_TEST = "true";
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("returns one shared instance so the core and the webview agree", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
expect(getDesktopFeatureFlagsService()).toBe(
getDesktopFeatureFlagsService(),
);
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
});
});
describe("feature flags context", () => {
it("defaults to the machine distinct ID under the cline-code client name", () => {
const context = getDesktopFeatureFlagsContext();
expect(context.clientName).toBe("cline-code");
expect(context.distinctId).toBe("machine-distinct-id");
});
it("switches to the account ID once signed in, and pushes it to the service", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
setDesktopFeatureFlagsAccountContext({
id: "acct-1",
email: "dev@example.com",
});
const context = getDesktopFeatureFlagsContext();
expect(context.distinctId).toBe("acct-1");
expect(context.userId).toBe("acct-1");
expect(mocks.setContext).toHaveBeenCalledTimes(1);
});
it("keeps the device identity when the account ID is blank", () => {
setDesktopFeatureFlagsAccountContext({ id: " " });
expect(getDesktopFeatureFlagsContext().distinctId).toBe(
"machine-distinct-id",
);
});
it("clears the account identity on sign-out and falls back to the device", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(getDesktopFeatureFlagsContext().userId).toBe("acct-1");
expect(setDesktopFeatureFlagsAccountContext({})).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBeUndefined();
// Must not be left on the signed-out account's ID.
expect(context.distinctId).toBe("machine-distinct-id");
});
it("reports no change when the same account is re-confirmed", () => {
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(true);
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(false);
});
it("reports no change when signed out twice", () => {
expect(setDesktopFeatureFlagsAccountContext({})).toBe(false);
});
it("re-points at the new account when switching accounts", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-2" })).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBe("acct-2");
expect(context.distinctId).toBe("acct-2");
});
});
describe("buildFeatureFlagsSnapshot", () => {
it("resolves every known flag so the client needs no defaults", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? true : undefined,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags).toEqual({
"ext-cline-pass": true,
// Unreturned flags resolve to false rather than being absent.
"ext-demo-flag": false,
});
});
it("passes non-boolean payloads through untouched", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? { variant: "b", limit: 3 } : false,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags["ext-cline-pass"]).toEqual({
variant: "b",
limit: 3,
});
});
});
describe("refreshDesktopFeatureFlags", () => {
it("polls before returning the snapshot", async () => {
mocks.getFlagPayload.mockReturnValue(true);
const snapshot = await refreshDesktopFeatureFlags();
expect(mocks.poll).toHaveBeenCalledTimes(1);
expect(snapshot.flags["ext-cline-pass"]).toBe(true);
});
it("still returns cached values when the poll fails", async () => {
mocks.poll.mockRejectedValueOnce(new Error("offline"));
mocks.getFlagPayload.mockReturnValue(false);
const logger = { error: vi.fn(), log: vi.fn(), debug: vi.fn() };
const snapshot = await refreshDesktopFeatureFlags({ logger });
expect(snapshot.flags["ext-cline-pass"]).toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});
describe("disposeDesktopFeatureFlagsService", () => {
it("disposes the live service and clears it", async () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
await disposeDesktopFeatureFlagsService();
expect(mocks.dispose).toHaveBeenCalledTimes(1);
// A later call builds a fresh service rather than reusing a disposed one.
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(2);
});
it("is a no-op when nothing was created", async () => {
await expect(disposeDesktopFeatureFlagsService()).resolves.toBeUndefined();
expect(mocks.dispose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,171 @@
import { join } from "node:path";
import {
type BasicLogger,
FEATURE_FLAGS,
type FeatureFlagPayload,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
let desktopFeatureFlagsContext: FeatureFlagsContext = {
clientName: "cline-code",
};
let desktopFeatureFlagsService: FeatureFlagsService | undefined;
function resolveDesktopFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.cline-code.json");
}
function ensureDesktopDistinctId(): string {
const distinctId = desktopFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
desktopFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getDesktopFeatureFlagsContext(): FeatureFlagsContext {
ensureDesktopDistinctId();
return { ...desktopFeatureFlagsContext };
}
export function getDesktopFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!desktopFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
desktopFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getDesktopFeatureFlagsContext(),
cacheFilePath: resolveDesktopFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
}
return desktopFeatureFlagsService;
}
export async function disposeDesktopFeatureFlagsService(): Promise<void> {
if (!desktopFeatureFlagsService) {
return;
}
const current = desktopFeatureFlagsService;
desktopFeatureFlagsService = undefined;
await current.dispose();
}
export function setDesktopFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): boolean {
const accountId = account.id?.trim();
const previousUserId = desktopFeatureFlagsContext.userId ?? undefined;
if (previousUserId === (accountId || undefined)) {
return false;
}
if (accountId) {
desktopFeatureFlagsContext = {
...desktopFeatureFlagsContext,
distinctId: accountId,
userId: accountId,
};
} else {
// Drop both identifiers; ensureDesktopDistinctId re-resolves the device
// ID on the next read rather than leaving the old account's ID behind.
const {
distinctId: _distinctId,
userId: _userId,
...rest
} = desktopFeatureFlagsContext;
desktopFeatureFlagsContext = rest;
}
desktopFeatureFlagsService?.setContext(getDesktopFeatureFlagsContext());
return true;
}
export type FeatureFlagsSnapshot = {
flags: Record<string, FeatureFlagPayload>;
};
export function buildFeatureFlagsSnapshot(
service: FeatureFlagsService,
): FeatureFlagsSnapshot {
const flags: Record<string, FeatureFlagPayload> = {};
for (const flag of FEATURE_FLAGS) {
flags[flag] = service.getFlagPayload(flag) ?? false;
}
return { flags };
}
/**
* Refresh flags from PostHog, then hand back the resolved snapshot.
*
* Polling is cheap to call repeatedly.
*/
export async function refreshDesktopFeatureFlags(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): Promise<FeatureFlagsSnapshot> {
const service = getDesktopFeatureFlagsService(options);
try {
await service.poll();
} catch (error) {
options?.logger?.error?.("Error refreshing desktop feature flags", {
error,
});
}
return buildFeatureFlagsSnapshot(service);
}
export async function identifyDesktopFeatureFlagsAccount(
account: { id?: string; email?: string },
options?: { logger?: BasicLogger; telemetry?: ITelemetryService },
): Promise<void> {
if (
!setDesktopFeatureFlagsAccountContext(account) ||
!desktopFeatureFlagsService
) {
return;
}
try {
await desktopFeatureFlagsService.poll();
} catch (error) {
options?.logger?.error?.("Error polling desktop feature flags", { error });
}
}
export function resetDesktopFeatureFlagsForTesting(): void {
desktopFeatureFlagsService = undefined;
desktopFeatureFlagsContext = { clientName: "cline-code" };
}
@@ -9,6 +9,7 @@ import {
setSdkLogger,
} from "@cline/core";
import { version } from "../package.json";
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
import {
createDesktopLoggerAdapter,
type DesktopLoggerAdapter,
@@ -45,6 +46,7 @@ export function createDesktopObservability(): DesktopObservability {
id: auth.accountId,
provider: "cline",
});
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
}
captureExtensionActivated(telemetry);
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.15",
"version": "0.0.17",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
+12 -8
View File
@@ -65,6 +65,7 @@ import {
markOnboardingCompleted,
ONBOARDING_RESET_EVENT,
} from "@/lib/onboarding";
import { requestPromptInputFocus } from "@/lib/prompt-input-focus";
import { isProviderConnected } from "@/lib/provider-connection";
import {
fetchProviderCatalog,
@@ -220,6 +221,7 @@ export default function Home() {
const handleNewThread = useCallback(() => {
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
requestPromptInputFocus();
}, []);
const completeOnboarding = useCallback(() => {
@@ -289,6 +291,7 @@ export default function Home() {
return;
}
navigateWith({ view: "chat" });
requestPromptInputFocus();
}, [activeThread, handleNewThread, navigateWith]);
const handleViewChange = useCallback(
(nextView: DesktopAppView) => {
@@ -296,6 +299,13 @@ export default function Home() {
},
[navigateWith],
);
// The sidebar's New row reads as selected while the fresh, not-yet-started
// task page is showing; once the task starts the session row takes over.
const newTaskActive =
view === "chat" &&
activeThread !== undefined &&
!activeThread.hasStarted &&
!activeThread.historySession;
const handleSettingsSectionChange = useCallback(
(section: SettingsSection) => {
navigateWith({ settingsSection: section, view: "settings" });
@@ -428,21 +438,15 @@ export default function Home() {
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
newTaskActive={newTaskActive}
onHome={handleHome}
onNavigateBack={handleNavigateBack}
onNavigateForward={handleNavigateForward}
onNewThread={handleNewThread}
onOpenSessionById={handleOpenSessionById}
onSettingsSectionChange={handleSettingsSectionChange}
sessionHistory={sessionHistory}
setView={handleViewChange}
settingsSection={settingsSection}
view={view}
workspaceRoot={
activeThread?.historySession?.workspaceRoot ||
activeThread?.historySession?.cwd ||
historyWorkspacePaths[0]
}
canNavigateBack={navigation.back.length > 0}
canNavigateForward={navigation.forward.length > 0}
/>
@@ -481,7 +485,7 @@ export default function Home() {
}
parentSession={activeParentSession}
onOpenVoiceInputSettings={() =>
handleSettingsSectionChange("Models")
handleSettingsSectionChange("Voice")
}
onThreadStarted={handleThreadStarted}
/>
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
// Covers the shipped state of the Agenda feature: the sidebar has no Agenda
// UI at all, and with AGENDA_UI_ENABLED false (the real flag value) the
// welcome quick actions stay hidden and no agenda commands are issued. The
// feature-flag mock in welcome-chat.test.tsx forces the flag on to keep
// exercising the dormant welcome-screen UI.
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 { SidebarProvider } from "@/components/ui/sidebar";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { UseSessionHistoryResult } from "@/hooks/use-session-history";
const desktopMocks = vi.hoisted(() => ({
invoke: vi.fn(),
listAgendaTasks: vi.fn(),
getAgendaAutomationPolicy: vi.fn(),
subscribe: vi.fn(() => () => undefined),
subscribeTransportState: vi.fn(() => () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
desktopMocks.invoke.mockRejectedValue(new Error("not available in test"));
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function makeSessionHistory(): UseSessionHistoryResult {
return {
deleteThread: vi.fn(),
forkThread: vi.fn(),
hasLoadedHistory: true,
isLoadingMore: false,
loadAllSessions: vi.fn(async () => true),
loadOlderSessions: vi.fn(),
loadMoreSessions: vi.fn(),
mayHaveMoreSessions: false,
openThread: vi.fn(),
pendingAction: null,
renameThread: vi.fn(),
threads: [],
unreadSessionIds: new Set<string>(),
} as unknown as UseSessionHistoryResult;
}
describe("Agenda UI hidden by default", () => {
it("renders the sidebar without any Agenda UI and issues no agenda commands", async () => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory()}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
await Promise.resolve();
});
expect(container.querySelector('[aria-label="Show Agenda"]')).toBeNull();
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
expect(
container.querySelector('[aria-label="Search sessions"]'),
).not.toBeNull();
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
expect(desktopMocks.getAgendaAutomationPolicy).not.toHaveBeenCalled();
});
it("renders the welcome screen without agenda quick actions or agenda fetches", async () => {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
listWorkspaces: vi.fn(async () => ["/projects/project-1"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<WelcomeScreen
active
body={null}
composer={null}
gitBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onSwitchGitBranch={vi.fn(async () => true)}
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import type { AgendaTaskRecord } from "@cline/shared";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -18,13 +17,6 @@ import type {
const desktopMocks = vi.hoisted(() => ({
invoke: vi.fn(),
createAgendaTask: vi.fn(),
listAgendaTasks: vi.fn(),
approveAgendaTask: vi.fn(),
cancelAgendaTask: vi.fn(),
runAgendaTask: vi.fn(),
getAgendaAutomationPolicy: vi.fn(),
setAgendaAutomationPolicy: vi.fn(),
subscribe: vi.fn(() => () => undefined),
subscribeTransportState: vi.fn(() => () => undefined),
}));
@@ -55,13 +47,15 @@ function makeSessionHistory(
loadOlderSessions?: ReturnType<typeof vi.fn>;
mayHaveMoreSessions?: boolean;
hasLoadedHistory?: boolean;
isLoadingMore?: boolean;
} = {},
): UseSessionHistoryResult {
return {
deleteThread: vi.fn(),
forkThread: vi.fn(),
hasLoadedHistory: options.hasLoadedHistory ?? true,
isLoadingMore: false,
isLoadingMore: options.isLoadingMore ?? false,
loadAllSessions: vi.fn(async () => true),
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
loadMoreSessions,
mayHaveMoreSessions: options.mayHaveMoreSessions ?? false,
@@ -122,6 +116,14 @@ function sessionIsVisible(title: string): boolean {
);
}
function sessionRow(title: string): HTMLButtonElement {
const row = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.querySelector("span")?.textContent === title,
);
expect(row).toBeDefined();
return row as HTMLButtonElement;
}
const signedInUser = {
id: "user-1",
email: "beatrix@cline.bot",
@@ -145,23 +147,6 @@ beforeEach(() => {
window.localStorage.clear();
invoke.mockReset();
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
desktopMocks.createAgendaTask.mockReset();
desktopMocks.listAgendaTasks.mockReset();
desktopMocks.listAgendaTasks.mockResolvedValue([]);
desktopMocks.approveAgendaTask.mockReset();
desktopMocks.cancelAgendaTask.mockReset();
desktopMocks.runAgendaTask.mockReset();
desktopMocks.getAgendaAutomationPolicy.mockReset();
desktopMocks.getAgendaAutomationPolicy.mockResolvedValue({
scopeKey: "global",
mode: "manual",
applyToAgentCreated: true,
maxConcurrentRuns: 1,
maxChainDepth: 3,
maxStartsPerHour: 20,
updatedAt: "2026-08-13T00:00:00.000Z",
});
desktopMocks.setAgendaAutomationPolicy.mockReset();
desktopMocks.subscribe.mockReset();
desktopMocks.subscribe.mockImplementation(() => () => undefined);
desktopMocks.subscribeTransportState.mockReset();
@@ -179,6 +164,17 @@ beforeEach(() => {
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
HTMLElement.prototype.scrollIntoView = vi.fn();
// cmdk (the search dialog) observes its list size; jsdom has no
// ResizeObserver implementation.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -191,258 +187,18 @@ afterEach(async () => {
});
describe("AgentSidebar session organization", () => {
it("shows an unread dot when a new Todo item arrives and clears it on open", async () => {
const eventHandlers = new Map<string, () => void>();
desktopMocks.subscribe.mockImplementation(
(eventName: string, handler: () => void) => {
eventHandlers.set(eventName, handler);
return () => eventHandlers.delete(eventName);
},
);
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
await vi.waitFor(() =>
expect(desktopMocks.listAgendaTasks).toHaveBeenCalled(),
);
expect(
container.querySelector('[data-testid="new-todo-indicator"]'),
).toBeNull();
desktopMocks.listAgendaTasks.mockResolvedValue([makeAgendaTask()]);
await act(async () => {
eventHandlers.get("task.created")?.();
});
await vi.waitFor(() =>
expect(
container.querySelector('[data-testid="new-todo-indicator"]'),
).not.toBeNull(),
);
await click(
container.querySelector('[aria-label="Show Agenda"]') as Element,
);
expect(
container.querySelector('[data-testid="new-todo-indicator"]'),
).toBeNull();
});
it("shows pending Agenda work and requires approval before run", async () => {
const task = makeAgendaTask();
desktopMocks.listAgendaTasks.mockResolvedValue([task]);
desktopMocks.approveAgendaTask.mockResolvedValue({
...task,
status: "approved",
revision: 2,
});
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
workspaceRoot="/projects/current"
/>
</SidebarProvider>,
);
await Promise.resolve();
});
await click(
container.querySelector('[aria-label="Show Agenda"]') as Element,
);
expect(container.textContent).toContain("Review PR checks");
expect(container.textContent).toContain("cline");
expect(container.textContent).not.toContain("P1 · pending approval");
expect(desktopMocks.listAgendaTasks).toHaveBeenCalledWith({
statuses: ["pending_approval", "approved", "in_progress", "failed"],
workspaceRoot: "/projects/current",
limit: 200,
});
const approve = container.querySelector(
'[aria-label="Approve Review PR checks"]',
);
expect(approve).not.toBeNull();
expect(approve?.className).toContain("text-emerald-500!");
expect(
container.querySelector('[aria-label="Cancel Review PR checks"]')
?.className,
).toContain("text-destructive!");
expect(approve?.closest(".group")?.className).toContain("max-w-full");
expect(
buttonWithText("Review PR checks").querySelector(".truncate"),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Run Review PR checks"]'),
).toBeNull();
await click(buttonWithText("Review PR checks"));
expect(desktopMocks.approveAgendaTask).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Review CI and report failures.",
);
expect(buttonWithText("Reject", document)).toBeDefined();
await click(buttonWithText("Approve", document));
expect(desktopMocks.approveAgendaTask).toHaveBeenCalledWith({
taskId: "task-1",
expectedRevision: 1,
});
});
it("uses each displayed Agenda revision when running or cancelling", async () => {
const runnable = makeAgendaTask({
taskId: "task-run",
title: "Run task",
status: "approved",
revision: 4,
});
const cancellable = makeAgendaTask({
taskId: "task-cancel",
title: "Cancel task",
status: "approved",
revision: 9,
});
desktopMocks.listAgendaTasks.mockResolvedValue([runnable, cancellable]);
desktopMocks.runAgendaTask.mockResolvedValue({
task: { ...runnable, status: "in_progress" },
});
desktopMocks.cancelAgendaTask.mockResolvedValue({
...cancellable,
status: "cancelled",
});
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
await Promise.resolve();
});
await click(
container.querySelector('[aria-label="Show Agenda"]') as Element,
);
await click(
container.querySelector('[aria-label="Run Run task"]') as Element,
);
expect(desktopMocks.runAgendaTask).toHaveBeenCalledWith({
taskId: "task-run",
expectedRevision: 4,
});
await click(
container.querySelector('[aria-label="Cancel Cancel task"]') as Element,
);
expect(desktopMocks.cancelAgendaTask).toHaveBeenCalledWith({
taskId: "task-cancel",
expectedRevision: 9,
});
});
it("creates a workspace task with the selected priority, expiry, and model", async () => {
const created = makeAgendaTask({
taskId: "task-created",
title: "Investigate the regression",
});
desktopMocks.createAgendaTask.mockResolvedValue(created);
window.localStorage.setItem(
"cline.code.model-selection.v1",
JSON.stringify({
lastProvider: "openrouter",
lastModelByProvider: { openrouter: "anthropic/claude-sonnet-4.6" },
}),
);
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
workspaceRoot="/projects/current"
/>
</SidebarProvider>,
);
await Promise.resolve();
});
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
await click(
container.querySelector('[aria-label="Show Agenda"]') as Element,
);
await click(
container.querySelector('[aria-label="Create Todo item"]') as Element,
);
const title =
document.querySelector<HTMLInputElement>("#agenda-task-title");
const instructions = document.querySelector<HTMLTextAreaElement>(
"#agenda-task-instructions",
);
expect(title).not.toBeNull();
expect(instructions).not.toBeNull();
await changeField(title as HTMLInputElement, "Investigate the regression");
await changeField(
instructions as HTMLTextAreaElement,
"Inspect the failing build and implement a fix.",
);
await click(buttonWithText("Add to Agenda", document));
await vi.waitFor(() =>
expect(desktopMocks.createAgendaTask).toHaveBeenCalledOnce(),
);
const input = desktopMocks.createAgendaTask.mock.calls[0]?.[0];
expect(input).toMatchObject({
type: "todo",
title: "Investigate the regression",
instructions: "Inspect the failing build and implement a fix.",
scope: "workspace",
workspaceRoot: "/projects/current",
priority: 3,
modelSelection: {
providerId: "openrouter",
modelId: "anthropic/claude-sonnet-4.6",
},
automationEligible: true,
});
expect(Date.parse(input.expiresAt)).toBeGreaterThan(Date.now());
});
it("filters scheduled sessions without changing their titles", async () => {
it("marks scheduled sessions with a clock icon in the row", async () => {
const scheduled = {
...makeThread("scheduled", 1),
...makeThread("alpha", 1),
source: "core",
isScheduled: true,
};
const regular = { ...makeThread("regular", 1), source: "core" };
const pinnedScheduled = {
...makeThread("alpha", 2),
isScheduled: true,
pinned: true,
};
const regular = { ...makeThread("alpha", 3), source: "core" };
await act(async () => {
root.render(
@@ -450,9 +206,11 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([scheduled, regular], vi.fn())}
sessionHistory={makeSessionHistory(
[scheduled, pinnedScheduled, regular],
vi.fn(),
)}
setView={vi.fn()}
settingsSection="General"
view="chat"
@@ -461,24 +219,127 @@ describe("AgentSidebar session organization", () => {
);
});
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(container.textContent).not.toContain("(schedule)");
// The clock marks scheduled rows inline; a pinned scheduled session
// shows both indicators at once.
expect(
sessionRow("alpha session 1").querySelector('[aria-label="Scheduled"]'),
).not.toBeNull();
expect(
sessionRow("alpha session 1").querySelector('[aria-label="Pinned"]'),
).toBeNull();
expect(
sessionRow("alpha session 2").querySelector('[aria-label="Scheduled"]'),
).not.toBeNull();
expect(
sessionRow("alpha session 2").querySelector('[aria-label="Pinned"]'),
).not.toBeNull();
expect(
sessionRow("alpha session 3").querySelector('[aria-label="Scheduled"]'),
).toBeNull();
await click(
container.querySelector('[aria-label="Filter sessions"]') as Element,
);
expect(document.body.textContent).not.toContain("Recent");
const schedulesOption = await vi.waitFor(() => {
const option = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].find((candidate) => candidate.textContent?.includes("Schedules"));
expect(option).toBeDefined();
return option as HTMLElement;
// The old Pinned/Scheduled/Tasks category sections are gone.
expect(container.textContent).not.toContain("Scheduled");
expect(container.textContent).not.toContain("Tasks");
});
it("pins sessions to the top of their project group", async () => {
const pinned = { ...makeThread("alpha", 3), pinned: true };
const threads = [makeThread("alpha", 1), makeThread("alpha", 2), pinned];
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(threads, vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
await click(schedulesOption);
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(sessionIsVisible("regular session 1")).toBe(false);
// The pinned session leads its project group despite being the oldest
// entry in history order, and carries the pin icon inline.
const pinnedRow = sessionRow("alpha session 3");
expect(pinnedRow.querySelector('[aria-label="Pinned"]')).not.toBeNull();
for (const title of ["alpha session 1", "alpha session 2"]) {
expect(
pinnedRow.compareDocumentPosition(sessionRow(title)) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
expect(container.textContent).not.toContain("Pinned");
});
it("loads older history only on explicit Show more clicks", async () => {
const tasks = Array.from({ length: 5 }, (_, index) =>
makeThread("plain", index + 1),
);
const loadOlderSessions = vi.fn(async () => false);
const renderSidebar = async (isLoadingMore: boolean) => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(tasks, vi.fn(), {
isLoadingMore,
loadOlderSessions,
mayHaveMoreSessions: true,
})}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
};
await renderSidebar(false);
await click(buttonWithText("Show more"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
// A settled fetch never triggers an automatic follow-up request; only
// another explicit click asks for more history.
await renderSidebar(true);
await renderSidebar(false);
expect(loadOlderSessions).toHaveBeenCalledOnce();
await click(buttonWithText("Show more"));
expect(loadOlderSessions).toHaveBeenCalledTimes(2);
});
it("keeps a flat session list when nothing is pinned or scheduled", async () => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(
[makeThread("plain", 1)],
vi.fn(),
)}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
expect(sessionIsVisible("plain session 1")).toBe(true);
expect(container.textContent).not.toContain("Pinned");
expect(container.textContent).not.toContain("Scheduled");
expect(container.textContent).not.toContain("Tasks");
});
it("defaults to all sources and filters by the selected client source", async () => {
@@ -527,7 +388,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn(), {
hasLoadedHistory: false,
@@ -553,7 +413,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn(), {
hasLoadedHistory: true,
@@ -604,7 +463,7 @@ describe("AgentSidebar session organization", () => {
);
});
it("defaults to time and keeps project expansion scoped to one project", async () => {
it("always groups sessions by project and scopes expansion to one project", async () => {
const threads = [
...Array.from({ length: 12 }, (_, index) =>
makeThread("alpha", index + 1),
@@ -626,7 +485,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
@@ -637,44 +495,27 @@ describe("AgentSidebar session organization", () => {
);
});
expect(
container.querySelector('[aria-label="Sort sessions: Time"]'),
).not.toBeNull();
expect(sessionIsVisible("alpha session 10")).toBe(true);
expect(sessionIsVisible("alpha session 11")).toBe(false);
expect(sessionIsVisible("beta session 1")).toBe(false);
await click(buttonWithText("Show more"));
expect(sessionIsVisible("alpha session 11")).toBe(true);
expect(loadMoreSessions).toHaveBeenCalledWith(20);
await click(
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
);
const projectOption = await vi.waitFor(() => {
const option = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].find((candidate) => candidate.textContent?.includes("Sort by project"));
expect(option).toBeDefined();
return option as HTMLElement;
});
await click(projectOption);
await vi.waitFor(() => {
expect(
container.querySelector('[aria-label="Sort sessions: Project"]'),
).not.toBeNull();
});
// The sort toggle is gone: grouping is always by project.
expect(container.querySelector('[aria-label^="Sort sessions"]')).toBeNull();
expect(container.textContent).toContain("alpha");
expect(container.textContent).toContain("beta");
expect(sessionIsVisible("alpha session 10")).toBe(true);
expect(sessionIsVisible("alpha session 11")).toBe(false);
expect(sessionIsVisible("beta session 10")).toBe(true);
expect(sessionIsVisible("beta session 11")).toBe(false);
// Expanding one project leaves the others' pagination untouched.
await click(buttonWithText("Show more in alpha"));
expect(sessionIsVisible("alpha session 11")).toBe(true);
expect(sessionIsVisible("beta session 11")).toBe(false);
expect(loadMoreSessions).not.toHaveBeenCalled();
await click(buttonWithText("Load older projects"));
// The trailing Show more button grows the loaded history window.
const globalShowMore = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((button) => button.textContent?.trim() === "Show more");
expect(globalShowMore).toBeDefined();
await click(globalShowMore as HTMLButtonElement);
expect(loadOlderSessions).toHaveBeenCalledOnce();
});
@@ -688,7 +529,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -734,7 +574,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={setView}
@@ -757,8 +596,9 @@ describe("AgentSidebar session organization", () => {
expect(setView).not.toHaveBeenCalled();
});
it("suppresses the settings gear hover state while the Account screen is open", async () => {
it("opens the General settings section from the gear in any section", async () => {
invoke.mockResolvedValue(signedInUser);
const onSettingsSectionChange = vi.fn();
const renderSidebar = async (settingsSection: "Account" | "General") => {
await act(async () => {
@@ -768,8 +608,7 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection={settingsSection}
@@ -786,13 +625,19 @@ describe("AgentSidebar session organization", () => {
});
};
// The Account screen leaves the gear un-highlighted, but clicking it
// still navigates to General rather than acting as a no-op.
// (split on spaces: the variant's hover:bg-surface-hover would match a
// plain substring check)
const gearOnAccount = await renderSidebar("Account");
expect(gearOnAccount.className).toContain("hover:bg-transparent");
expect(gearOnAccount.className).not.toContain("bg-surface-hover");
expect(gearOnAccount.className.split(" ")).not.toContain(
"bg-surface-hover",
);
await click(gearOnAccount);
expect(onSettingsSectionChange).toHaveBeenCalledWith("General");
const gearOnGeneral = await renderSidebar("General");
expect(gearOnGeneral.className).not.toContain("hover:bg-transparent");
expect(gearOnGeneral.className).toContain("bg-surface-hover");
expect(gearOnGeneral.className.split(" ")).toContain("bg-surface-hover");
});
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
@@ -818,7 +663,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={onHome}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -867,7 +711,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -906,7 +749,6 @@ describe("AgentSidebar session organization", () => {
onHome={vi.fn()}
onNavigateBack={onNavigateBack}
onNavigateForward={onNavigateForward}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -930,17 +772,17 @@ describe("AgentSidebar session organization", () => {
expect(onNavigateForward).toHaveBeenCalledOnce();
});
it("places the logo and icon-only new-session action below the title bar", async () => {
const onNewThread = vi.fn();
it("stacks New, Schedule, and Customize as full-width rows below the logo", async () => {
const onHome = vi.fn();
const onSettingsSectionChange = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={onNewThread}
onSettingsSectionChange={vi.fn()}
onHome={onHome}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
@@ -952,14 +794,126 @@ describe("AgentSidebar session organization", () => {
});
const logo = container.querySelector('[aria-label="Cline home"]');
const showAgenda = container.querySelector('[aria-label="Show Agenda"]');
const newSession = container.querySelector('[aria-label="New Session"]');
const actionsNav = container.querySelector(
'[aria-label="Sidebar actions"]',
);
expect(logo).not.toBeNull();
expect(showAgenda).not.toBeNull();
expect(newSession).not.toBeNull();
expect(newSession?.textContent).toBe("");
await click(newSession as Element);
expect(onNewThread).toHaveBeenCalledOnce();
expect(actionsNav).not.toBeNull();
// The rows read as labeled full-width entries stacked outside the
// logo row, not ambiguous icons in the header cluster.
const rows = [
...(actionsNav?.querySelectorAll<HTMLButtonElement>("button") ?? []),
];
expect(rows.map((row) => row.textContent)).toEqual([
"New",
"Schedule",
"Customize",
]);
for (const row of rows) {
expect(row.className).toContain("w-full");
}
expect(actionsNav?.contains(logo as Element)).toBe(false);
await click(buttonWithText("New", actionsNav as ParentNode));
expect(onHome).toHaveBeenCalledOnce();
await click(buttonWithText("Schedule", actionsNav as ParentNode));
expect(onSettingsSectionChange).toHaveBeenCalledWith("Schedules");
await click(buttonWithText("Customize", actionsNav as ParentNode));
expect(onSettingsSectionChange).toHaveBeenCalledWith("Customize");
});
it("highlights the New row only while the new-task page is active", async () => {
const renderSidebar = async (newTaskActive: boolean) => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
newTaskActive={newTaskActive}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
return buttonWithText(
"New",
container.querySelector('[aria-label="Sidebar actions"]') as ParentNode,
);
};
const activeRow = await renderSidebar(true);
expect(activeRow.className.split(" ")).toContain("bg-surface-hover");
expect(activeRow.getAttribute("aria-current")).toBe("page");
const inactiveRow = await renderSidebar(false);
expect(inactiveRow.className.split(" ")).not.toContain("bg-surface-hover");
expect(inactiveRow.getAttribute("aria-current")).toBeNull();
});
it("opens session search in a dialog from the logo-row icon", async () => {
const sessionHistory = makeSessionHistory(
[makeThread("alpha", 1), makeThread("beta", 1)],
vi.fn(),
);
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
// Search lives behind the logo-row icon, not an inline sidebar input.
expect(container.querySelector("input")).toBeNull();
const searchButton = container.querySelector(
'button[aria-label="Search sessions"]',
);
expect(searchButton).not.toBeNull();
await click(searchButton as Element);
// Opening search pulls the full history so unloaded sessions match too.
expect(sessionHistory.loadAllSessions).toHaveBeenCalledOnce();
const searchInput = await vi.waitFor(() => {
const input = document.querySelector<HTMLInputElement>(
'[data-slot="command-input"]',
);
expect(input).not.toBeNull();
return input as HTMLInputElement;
});
expect(searchInput.placeholder).toBe("Search sessions...");
await changeField(searchInput, "alpha");
const match = await vi.waitFor(() => {
const items = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
];
expect(items).toHaveLength(1);
return items[0] as HTMLElement;
});
expect(match.textContent).toContain("alpha session 1");
await click(match);
expect(sessionHistory.openThread).toHaveBeenCalledWith("alpha-1");
await vi.waitFor(() =>
expect(document.querySelector('[data-slot="command-input"]')).toBeNull(),
);
});
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
@@ -970,7 +924,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -983,7 +936,9 @@ describe("AgentSidebar session organization", () => {
});
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
expect(
container.querySelector('[aria-label="Sidebar actions"]'),
).toBeNull();
expect(
container.querySelector('[aria-label="Expand sidebar"]')?.className,
).toContain("mt-auto");
@@ -997,7 +952,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -1046,7 +1000,6 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
@@ -1069,29 +1022,3 @@ describe("AgentSidebar session organization", () => {
).toContain("Settings");
});
});
function makeAgendaTask(
overrides: Partial<AgendaTaskRecord> = {},
): AgendaTaskRecord {
return {
taskId: "task-1",
type: "follow-up",
status: "pending_approval",
title: "Review PR checks",
description: "Confirm that CI passed.",
instructions: "Review CI and report failures.",
scope: "workspace",
workspaceRoot: "/projects/cline",
resourcePaths: [],
priority: 1,
availableAt: "2026-08-13T00:00:00.000Z",
expiresAt: "2099-08-20T00:00:00.000Z",
automationEligible: true,
revision: 1,
createdBy: { kind: "agent" },
updatedBy: { kind: "agent" },
createdAt: "2026-08-13T00:00:00.000Z",
updatedAt: "2026-08-13T00:00:00.000Z",
...overrides,
};
}
File diff suppressed because it is too large Load Diff
@@ -24,13 +24,15 @@ const buttonVariants = cva(
sidebarItem:
"!h-auto w-full justify-start text-left gap-2 rounded-md !px-2 py-2 !text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground",
sidebarText:
"!h-auto justify-start gap-1 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-sidebar-foreground",
"!h-auto justify-start gap-1 px-3 py-1.5 !text-sm font-normal text-muted-foreground hover:text-sidebar-foreground",
text: "bg-transparent text-sm font-medium text-muted-foreground hover:text-foreground",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:pl-2 has-[>svg]:pr-2.5 text-base",
sm: "h-8 gap-1.5 px-2.5 py-1.5 has-[>svg]:pl-2.5 has-[>svg]:pr-3 text-sm",
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:size-3 text-xs",
// (has-[>svg]:size-3 was a leftover from when xs was a 12px micro
// button; it collapsed any xs button containing an icon.)
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:px-2 text-xs",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 text-lg",
icon: "size-5",
"icon-sm": "size-3 p-1",
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
@@ -33,7 +33,9 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
const SIDEBAR_MIN_WIDTH = 224;
// Floor keeps session rows legible: below this the thread titles are fully
// truncated away and row metadata (timestamps, pins) starts clipping.
const SIDEBAR_MIN_WIDTH = 260;
const SIDEBAR_MAX_WIDTH = 560;
function clampSidebarWidth(value: number): number {
@@ -903,14 +903,11 @@ describe("ChatInputBar", () => {
expect(promptInput?.parentElement?.className).toContain("items-start");
expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true);
expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true);
expect(promptInput?.parentElement?.contains(speechTrigger)).toBe(true);
// The mic button is hidden for now (SHOW_VOICE_INPUT_BUTTON).
expect(speechTrigger).toBeNull();
expect(sendTrigger?.parentElement?.className).toContain("self-end");
expect(rightControls?.contains(sendTrigger)).toBe(false);
expect(rightControls?.contains(speechTrigger ?? null)).toBe(false);
expect(speechTrigger?.parentElement?.nextElementSibling).toBe(sendTrigger);
expect(leftControls?.parentElement).toBe(rightControls?.parentElement);
await act(async () => speechTrigger?.click());
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
});
it("selects High from the supported model thinking menu", async () => {
@@ -40,6 +40,7 @@ import {
readModelSelectionStorageFromWindow,
writeModelSelectionStorageToWindow,
} from "@/lib/model-selection";
import { subscribeToPromptInputFocus } from "@/lib/prompt-input-focus";
import { normalizeProviderId } from "@/lib/provider-id";
import {
loadProviderModelCatalog,
@@ -50,6 +51,7 @@ import {
} from "@/lib/provider-model-catalog";
import type { ProviderModel } from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
@@ -344,7 +346,6 @@ function ChatInputBarImpl({
onSteerPromptInQueue,
onEditPromptInQueue,
onRemovePromptInQueue,
onOpenVoiceInputSettings,
summary,
}: ChatInputBarProps) {
const {
@@ -362,6 +363,15 @@ function ChatInputBarImpl({
const latestDraftVersionRef = useRef(promptDraft.version);
latestDraftVersionRef.current = promptDraft.version;
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
// The sidebar's "New" action asks the prompt input to grab focus through a
// window event (see lib/prompt-input-focus.ts).
useEffect(
() =>
subscribeToPromptInputFocus(() => {
promptInputRef.current?.focus();
}),
[],
);
const batchTranscriptSessionRef = useRef<{
start: number;
end: number;
@@ -1293,44 +1303,34 @@ function ChatInputBarImpl({
<CircleStop className="size-3" />
</button>
)}
<SpeechInput
allowUnavailableClick={!transcriptionTarget}
key={
transcriptionTarget
? `${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`
: "unconfigured"
}
onActiveChange={handleSpeechInputActiveChange}
onAudioRecorded={handleAudioRecorded}
onClick={(event) => {
if (!transcriptionTarget) {
event.preventDefault();
onOpenVoiceInputSettings?.();
{/* The mic button only appears once a voice model is
configured in Settings Voice; unconfigured users
don't get a dead control. */}
{transcriptionTarget ? (
<SpeechInput
key={`${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`}
onActiveChange={handleSpeechInputActiveChange}
onAudioRecorded={handleAudioRecorded}
onError={handleSpeechInputError}
onProcessingChange={setSpeechInputProcessing}
onStartStreaming={
transcriptionTarget.supportsStreaming
? handleStartStreamingTranscription
: undefined
}
}}
onError={handleSpeechInputError}
onProcessingChange={setSpeechInputProcessing}
onStartStreaming={
transcriptionTarget?.supportsStreaming
? handleStartStreamingTranscription
: undefined
}
onStreamingEnd={handleStreamingTranscriptionEnd}
onStreamingStart={handleStreamingTranscriptionStart}
onTranscriptionChange={
transcriptionTarget?.supportsStreaming
? undefined
: handleTranscriptionChange
}
recordingMode={
transcriptionTarget?.supportsStreaming ? "streaming" : "auto"
}
title={
transcriptionTarget
? `${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`
: "Configure voice input in Settings → Models"
}
/>
onStreamingEnd={handleStreamingTranscriptionEnd}
onStreamingStart={handleStreamingTranscriptionStart}
onTranscriptionChange={
transcriptionTarget.supportsStreaming
? undefined
: handleTranscriptionChange
}
recordingMode={
transcriptionTarget.supportsStreaming ? "streaming" : "auto"
}
title={`${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`}
/>
) : null}
{(!isBusy || canSend) && (
<button
aria-label="Send message"
@@ -21,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
subscribeTransportState: vi.fn(() => () => undefined),
},
}));
// The Agenda UI ships hidden for now; these tests force the flag on so they
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
// shipped (hidden) state.
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
let container: HTMLDivElement;
let root: Root;
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
import { useWorkspace } from "@/contexts/workspace-context";
import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import { SessionContent } from "./session-content";
import { WelcomeHero } from "./welcome-hero";
@@ -49,7 +50,7 @@ export function WelcomeScreen({
statuses: ["pending_approval", "approved", "in_progress", "failed"],
limit: 8,
},
active && workspaceRoot.trim().length > 0,
AGENDA_UI_ENABLED && active && workspaceRoot.trim().length > 0,
);
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
@@ -168,7 +169,7 @@ export function WelcomeScreen({
{active ? composer : <SessionContent>{composer}</SessionContent>}
</div>
{active ? (
{active && AGENDA_UI_ENABLED ? (
<>
<AgentQuickActions
actions={actions}
@@ -1007,22 +1007,17 @@ export function MarketplaceView({
/>
))}
</div>
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{catalogEntries.length}
</span>
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
{selectedTag ? (
<Button
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
{selectedTag ? (
<Button
className="shrink-0 self-start md:self-auto"
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
) : null;
@@ -1219,7 +1214,7 @@ export function MarketplaceView({
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
tagLabels={tagLabels}
title={variant === "directory" ? undefined : "Marketplace"}
title={variant === "directory" ? undefined : "Browse"}
/>
) : null}
</div>
@@ -152,19 +152,19 @@ describe("SessionsView table", () => {
expect(row?.parentElement?.className).not.toContain("min-h-14");
});
it("marks favorited sessions with a star", async () => {
it("marks pinned sessions with a pin icon", async () => {
const plain = renderView();
await plain.render();
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
expect(container.querySelector('[aria-label="Pinned"]')).toBeNull();
await act(async () => root.unmount());
root = createRoot(container);
const favorited = renderView({
const pinned = renderView({
threads: [{ ...thread, pinned: true }],
});
await favorited.render();
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
await pinned.render();
expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull();
});
it("opens a session on click but not while text is selected", async () => {
@@ -13,8 +13,8 @@ import {
Loader2,
MoreHorizontal,
Pencil,
Pin,
Search,
Star,
Trash2,
X,
} from "lucide-react";
@@ -131,7 +131,7 @@ function sessionFilterDetails(
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
thread.pinned ? "favorite:yes" : undefined,
thread.pinned ? "pinned:yes" : undefined,
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
@@ -572,8 +572,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
/>
<span className="truncate">{thread.title}</span>
{thread.pinned ? (
<Star
aria-label="Favorited"
<Pin
aria-label="Pinned"
className="size-3.5 shrink-0 fill-current text-muted-foreground"
/>
) : null}
@@ -626,13 +626,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
)
}
>
<Star
<Pin
className={cn(
"size-4",
thread.pinned && "fill-current",
)}
/>
{thread.pinned ? "Unfavorite" : "Favorite"}
{thread.pinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
@@ -24,12 +24,12 @@ import {
UserCircleIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAccount } from "@/contexts/account-context";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
const DASHBOARD_URL = "https://app.cline.bot/dashboard";
const USAGE_DASHBOARD_URL = "https://app.cline.bot/dashboard/usage";
@@ -554,12 +554,11 @@ export function AccountView() {
);
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-2xl font-semibold text-foreground">Account</h2>
{user && (
<PageFrame contentClassName="max-w-3xl">
<PageHeader
title="Account"
actions={
user ? (
<button
type="button"
disabled={accountActionPending !== null}
@@ -567,284 +566,284 @@ export function AccountView() {
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-surface-hover hover:text-foreground disabled:opacity-60"
>
{accountActionPending === "sign-out" ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
) : (
<LogOut className="h-4 w-4" />
<LogOut className="size-4" />
)}
{accountActionPending === "sign-out" ? "Signing Out" : "Sign Out"}
</button>
)}
</div>
) : undefined
}
/>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{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>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{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()}
{!overviewLoading && signedOut && renderSignedOut()}
{overviewError && renderError(overviewError, loadOverview)}
{!overviewLoading && !signedOut && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<button
type="button"
title="Open dashboard"
onClick={() => void openExternalUrl(DASHBOARD_URL)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</button>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{!overviewLoading && signedOut && renderSignedOut()}
{overviewError && renderError(overviewError, loadOverview)}
{!overviewLoading && !signedOut && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<button
type="button"
title="Open dashboard"
onClick={() => void openExternalUrl(DASHBOARD_URL)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</button>
</div>
</div>
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<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-surface-hover hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Credit
</button>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
)}
{/* Organizations */}
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<button
type="button"
onClick={() =>
void openExternalUrl(CREATE_ORGANIZATION_URL)
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-surface-hover hover:text-foreground "
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
Credit
</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 className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
</>
)}
</div>
)}
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading && !usageError && usageLoaded && (
<div className="overflow-hidden rounded-lg border border-border">
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
{usageTransactions.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
{/* Organizations */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
</h3>
</div>
)}
<div className="flex justify-center border-t border-border px-4 py-3">
<button
type="button"
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
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-surface-hover hover:text-foreground "
>
See More
<ExternalLink className="h-3.5 w-3.5" />
<Plus className="h-3.5 w-3.5" />
Create
</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>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading && !usageError && usageLoaded && (
<div className="overflow-hidden rounded-lg border border-border">
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
{usageTransactions.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
))}
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</ScrollArea>
)}
<div className="flex justify-center border-t border-border px-4 py-3">
<button
type="button"
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
>
See More
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageFrame>
);
}
@@ -53,10 +53,13 @@ interface NewProviderForm {
}
export function AddProviderContent({
variant = "page",
onBack,
onSave,
existingProviderIds,
}: {
/** "dialog" renders only the form body for use inside a Dialog. */
variant?: "page" | "dialog";
onBack: () => void;
onSave: (payload: AddProviderPayload) => Promise<void>;
existingProviderIds: string[];
@@ -180,6 +183,290 @@ export function AddProviderContent({
}
};
const content = (
<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>
<ModelIdInput models={form.models} onChange={updateModels} />
<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 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(
"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",
)}
>
{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>
<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 hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
</div>
) : null}
</div>
{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 hover:bg-surface-hover hover:text-foreground"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium",
canSave && !saving
? "bg-primary hover:bg-primary/90"
: "bg-muted cursor-not-allowed text-foreground",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
);
if (variant === "dialog") {
return content;
}
return (
<PageFrame contentClassName="max-w-4xl">
<PageHeader
@@ -192,289 +479,12 @@ export function AddProviderContent({
className="rounded-md p-1.5"
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
<ArrowLeft className="size-4" />
Providers
</Button>
}
/>
<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>
<ModelIdInput models={form.models} onChange={updateModels} />
<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 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(
"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",
)}
>
{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>
<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 hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
</div>
) : null}
</div>
{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 hover:bg-surface-hover hover:text-foreground"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium",
canSave && !saving
? "bg-primary hover:bg-primary/90"
: "bg-muted cursor-not-allowed text-foreground",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
{content}
</PageFrame>
);
}
@@ -1,6 +1,5 @@
"use client";
import { Store } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
@@ -10,25 +9,33 @@ import { CustomizationSectionView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
/**
* Unified Plugins hub: one page for everything installed (plugins, MCP
* servers, skills) with sub-tabs and live counts. The "Browse Marketplace"
* button navigates to the dedicated Marketplace settings page.
* Unified Customize hub: one page for everything that extends Cline
* skills, MCP servers, plugins, rules, hooks, and tools as sub-tabs with
* live counts. Tabs with a marketplace catalog (skills, MCP, plugins) show
* installed items followed by an inline browsable marketplace section, so
* there is no separate Marketplace page.
*/
type PluginsHubTab = "plugins" | "mcp" | "skills";
type CustomizeTab = "skills" | "mcp" | "plugins" | "rules" | "hooks" | "tools";
const HUB_TABS: { id: PluginsHubTab; label: string }[] = [
{ id: "plugins", label: "Plugins" },
{ id: "mcp", label: "MCP" },
const CUSTOMIZE_TABS: { id: CustomizeTab; label: string }[] = [
{ id: "skills", label: "Skills" },
{ id: "mcp", label: "MCP" },
{ id: "plugins", label: "Plugins" },
{ id: "rules", label: "Rules" },
{ id: "hooks", label: "Hooks" },
{ id: "tools", label: "Tools" },
];
type HubCounts = Partial<Record<PluginsHubTab, number>>;
type TabCounts = Partial<Record<CustomizeTab, number>>;
type HubInventoryResponse = {
plugins?: unknown[];
skills?: unknown[];
workflows?: unknown[];
rules?: unknown[];
hooks?: unknown[];
tools?: unknown[];
mcp?: { servers?: unknown[] };
};
@@ -36,13 +43,9 @@ function asCount(value: unknown): number {
return Array.isArray(value) ? value.length : 0;
}
export function PluginsHubView({
onOpenMarketplace,
}: {
onOpenMarketplace?: () => void;
}) {
const [tab, setTab] = useState<PluginsHubTab>("plugins");
const [counts, setCounts] = useState<HubCounts>({});
export function CustomizeView() {
const [tab, setTab] = useState<CustomizeTab>("skills");
const [counts, setCounts] = useState<TabCounts>({});
const refreshCounts = useCallback(async () => {
const inventory = await desktopClient
@@ -52,9 +55,12 @@ export function PluginsHubView({
return;
}
setCounts({
plugins: asCount(inventory.plugins),
skills: asCount(inventory.skills) + asCount(inventory.workflows),
mcp: asCount(inventory.mcp?.servers),
plugins: asCount(inventory.plugins),
rules: asCount(inventory.rules),
hooks: asCount(inventory.hooks),
tools: asCount(inventory.tools),
});
}, []);
@@ -72,22 +78,14 @@ export function PluginsHubView({
return (
<PageFrame>
<PageHeader
description="Manage installed plugins, MCP servers, and skills. Browse the marketplace to install more."
title="Plugins"
actions={
onOpenMarketplace ? (
<Button onClick={onOpenMarketplace} type="button" variant="outline">
<Store className="size-4" />
Browse Marketplace
</Button>
) : undefined
}
description="Extend what Cline can do and change how it works. Manage what's installed and browse the marketplace for more options."
title="Customize"
/>
<div className="mb-6 flex items-center gap-0 border-b border-border">
{HUB_TABS.map((hubTab) => {
const count = counts[hubTab.id];
const active = tab === hubTab.id;
{CUSTOMIZE_TABS.map((customizeTab) => {
const count = counts[customizeTab.id];
const active = tab === customizeTab.id;
return (
<Button
aria-current={active ? "page" : undefined}
@@ -97,12 +95,12 @@ export function PluginsHubView({
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
key={hubTab.id}
onClick={() => setTab(hubTab.id)}
key={customizeTab.id}
onClick={() => setTab(customizeTab.id)}
type="button"
variant="ghost"
>
{hubTab.label}
{customizeTab.label}
{typeof count === "number" ? (
<span
className={cn(
@@ -123,26 +121,44 @@ export function PluginsHubView({
})}
</div>
{tab === "plugins" ? (
{tab === "skills" ? (
<CustomizationSectionView
catalogPrimitive="plugin"
catalogPrimitive="skill"
chrome="embedded"
marketplaceVariant="installed"
marketplaceVariant="full"
onInventoryChanged={handleInventoryChanged}
section="Plugins"
section="Skills"
/>
) : tab === "mcp" ? (
<McpServersContent
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
/>
) : tab === "plugins" ? (
<CustomizationSectionView
catalogPrimitive="plugin"
chrome="embedded"
marketplaceVariant="full"
onInventoryChanged={handleInventoryChanged}
section="Plugins"
/>
) : tab === "rules" ? (
<CustomizationSectionView
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
section="Rules"
/>
) : tab === "hooks" ? (
<CustomizationSectionView
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
section="Hooks"
/>
) : (
<CustomizationSectionView
catalogPrimitive="skill"
chrome="embedded"
marketplaceVariant="installed"
onInventoryChanged={handleInventoryChanged}
section="Skills"
section="Tools"
/>
)}
</PageFrame>
@@ -7,6 +7,7 @@ import {
FileText,
MoreVertical,
Play,
Puzzle,
RefreshCw,
Server,
Trash2,
@@ -907,25 +908,23 @@ export function CustomizationSectionView({
);
};
const renderLocalActionRow = (target: LocalUninstallTarget) => {
// Sized and styled to match the Install/Uninstall button on marketplace
// entry cards so installed rows and browse rows read as one list.
const renderLocalActionButton = (target: LocalUninstallTarget) => {
const uninstalling = localUninstallingKeys.has(target.key);
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{renderLocalActionMessage(target.key)}
</div>
<Button
disabled={uninstalling}
onClick={() => {
void uninstallLocalPrimitive(target);
}}
type="button"
variant="destructive"
>
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
{uninstalling ? "Uninstalling..." : "Uninstall"}
</Button>
</div>
<Button
disabled={uninstalling}
onClick={() => {
void uninstallLocalPrimitive(target);
}}
size="xs"
type="button"
variant="destructive"
>
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
{uninstalling ? "Uninstalling..." : "Uninstall"}
</Button>
);
};
@@ -973,14 +972,26 @@ export function CustomizationSectionView({
) => {
const key = `${item.type}:${item.path}`;
return (
<div key={key} className="rounded-lg border border-border px-5 py-4">
<div className="flex items-center gap-3">
<div
key={key}
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="absolute top-4 right-4">
{renderLocalActionButton({
key,
type: item.type,
id: item.id,
name: item.name,
path: item.path,
})}
</div>
<div className="flex min-w-0 items-center gap-2 pr-28">
{item.type === "workflow" ? (
<Play className="h-4 w-4 shrink-0 text-primary" />
) : (
<Zap className="h-4 w-4 shrink-0 text-primary" />
)}
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
{item.name}
</h3>
<ScopeBadge scope={item.scope} />
@@ -993,26 +1004,16 @@ export function CustomizationSectionView({
</Badge>
) : null}
</div>
<p className="mt-2 ml-7 text-xs text-muted-foreground">
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{item.description?.trim() || previewText(item.instructions)}
</p>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{item.path}
</p>
{context?.matchedEntries?.length ? (
<div className="mt-2 ml-7">
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
</div>
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
) : null}
<div className="mt-3">
{renderLocalActionRow({
key,
type: item.type,
id: item.id,
name: item.name,
path: item.path,
})}
</div>
{renderLocalActionMessage(key)}
</div>
);
};
@@ -1049,12 +1050,10 @@ export function CustomizationSectionView({
},
].filter((group) => group.items.length > 0);
return (
<details
key={plugin.path}
className="rounded-lg border border-border px-5 py-4"
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
<details key={plugin.path} className="rounded-lg border bg-card p-4">
<summary className="flex cursor-pointer list-none items-center gap-2">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{plugin.name}
</h3>
<ScopeBadge scope={scope} />
@@ -1142,11 +1141,19 @@ export function CustomizationSectionView({
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4"
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="flex items-center gap-3">
<div className="absolute top-4 right-4">
{renderLocalActionButton({
key,
type: "mcp",
id: server.name,
name: server.name,
})}
</div>
<div className="flex min-w-0 items-center gap-2 pr-28">
<Server className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
{server.name}
</h3>
<ScopeBadge scope="Global" />
@@ -1158,11 +1165,13 @@ export function CustomizationSectionView({
Marketplace
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
{server.disabled ? "Disabled" : "Enabled"}
</span>
{server.disabled ? (
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Disabled
</Badge>
) : null}
</div>
<p className="mt-2 ml-7 text-xs text-muted-foreground">
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{server.url ??
([server.command, ...(server.args ?? [])]
.filter(Boolean)
@@ -1170,23 +1179,14 @@ export function CustomizationSectionView({
"No launch command configured.")}
</p>
{mcp.settingsPath ? (
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{mcp.settingsPath}
</p>
) : null}
{context?.matchedEntries?.length ? (
<div className="mt-2 ml-7">
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
</div>
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
) : null}
<div className="mt-3">
{renderLocalActionRow({
key,
type: "mcp",
id: server.name,
name: server.name,
})}
</div>
{renderLocalActionMessage(key)}
</div>
);
};
@@ -1313,11 +1313,6 @@ export function CustomizationSectionView({
{activeTab === "Rules" && (
<div>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
Enabled rules discovered from configured workspace/global
directories.
</p>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-foreground">
@@ -1331,19 +1326,19 @@ export function CustomizationSectionView({
{scopedRules.map(({ rule, scope }) => (
<div
key={rule.path}
className="rounded-lg border border-border px-4 py-3"
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="flex items-center gap-3">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-sm font-medium text-foreground">
<div className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-primary" />
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
{rule.name}
</span>
<ScopeBadge scope={scope} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{previewText(rule.instructions)}
</p>
<p className="mt-1 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{rule.path}
</p>
</div>
@@ -1360,9 +1355,6 @@ export function CustomizationSectionView({
{activeTab === "Hooks" && (
<div>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
Hook config files from workspace and global hook directories.
</p>
{hookExecutionLoading && hookExecutionSessionId && (
<p className="mb-4 text-xs text-muted-foreground">
Execution status is based on hook events in session{" "}
@@ -1383,43 +1375,47 @@ export function CustomizationSectionView({
{scopedHooks.map(({ hook, scope }) => (
<div
key={hook.path}
className="rounded-lg border border-border px-4 py-3"
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="flex items-center gap-3">
<Code className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-sm font-mono text-foreground">
<div className="flex min-w-0 items-center gap-2">
<Code className="h-4 w-4 shrink-0 text-primary" />
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
{hook.fileName}
</span>
<ScopeBadge scope={scope} />
{hook.hookEventName && (
<div className="flex items-center gap-2">
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
<>
<Badge
variant="outline"
className="shrink-0 text-muted-foreground"
>
{hook.hookEventName}
</span>
</Badge>
{(() => {
const stats =
hookExecutionByEvent[hook.hookEventName];
const executed = (stats?.count ?? 0) > 0;
return (
<span
<Badge
variant="outline"
className={cn(
"rounded border px-2 py-0.5 text-xs",
"shrink-0",
executed
? "border-emerald-400/50 text-emerald-600 dark:text-emerald-400"
: "border-border text-muted-foreground",
: "text-muted-foreground",
)}
>
{executed
? `${stats?.count ?? 0} executed`
: "never executed"}
</span>
</Badge>
);
})()}
</div>
</>
)}
</div>
{hook.hookEventName ? (
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-xs leading-5 text-muted-foreground">
Last run:{" "}
{formatExecutionTs(
hookExecutionByEvent[hook.hookEventName]?.lastTs ??
@@ -1427,7 +1423,7 @@ export function CustomizationSectionView({
)}
</p>
) : null}
<p className="mt-1 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{hook.path}
</p>
</div>
@@ -1659,27 +1655,27 @@ export function CustomizationSectionView({
{activeTab === "Tools" && (
<div>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
Builtin tool groups and plugin-contributed tools available to the
runtime.
</p>
<div className="mb-6">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Builtin Tools
</h3>
<div className="flex flex-col gap-3">
<div className="mb-6 grid gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-foreground">
Builtin Tools
</h3>
<span className="text-sm text-muted-foreground">
{builtinTools.length}
</span>
</div>
<div className="flex flex-col gap-2">
{builtinTools.map((tool) =>
(() => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="rounded-lg border border-border px-5 py-4"
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<Wrench className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{tool.name}
</h3>
<span className="text-xs text-muted-foreground">
@@ -1694,12 +1690,12 @@ export function CustomizationSectionView({
aria-label={`Toggle ${tool.name}`}
/>
</div>
<p className="mt-2 ml-7 text-xs text-muted-foreground">
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{tool.description?.trim() ||
"No description available."}
</p>
{!!tool.headlessToolNames?.length && (
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{tool.headlessToolNames.join(", ")}
</p>
)}
@@ -1715,28 +1711,36 @@ export function CustomizationSectionView({
</div>
</div>
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Plugin Tools
</h3>
<div className="flex flex-col gap-3">
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-foreground">
Plugin Tools
</h3>
<span className="text-sm text-muted-foreground">
{pluginTools.length}
</span>
</div>
<div className="flex flex-col gap-2">
{pluginTools.map((tool) =>
(() => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="rounded-lg border border-border px-5 py-4"
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
>
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<Wrench className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{tool.name}
</h3>
{tool.pluginName && (
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
plugin: {tool.pluginName}
</span>
<Badge
variant="outline"
className="shrink-0 text-muted-foreground"
>
{tool.pluginName}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
@@ -1750,12 +1754,12 @@ export function CustomizationSectionView({
aria-label={`Toggle ${tool.name}`}
/>
</div>
<p className="mt-2 ml-7 text-xs text-muted-foreground">
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{tool.description?.trim() ||
"No description available."}
</p>
{tool.path && (
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
<p className="truncate text-xs font-mono text-muted-foreground">
{tool.path}
</p>
)}
@@ -20,6 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@@ -623,9 +624,9 @@ export function McpServersContent({
return (
<div
key={server.name}
className="group relative rounded-lg border border-border px-5 py-4 hover:bg-surface-hover"
className="group relative rounded-lg border bg-card p-4 transition-colors hover:bg-surface-hover-lighter"
>
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
@@ -634,17 +635,17 @@ export function McpServersContent({
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
<h3 className="min-w-0 truncate 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">
<Badge variant="outline" className="shrink-0 text-muted-foreground">
{TRANSPORT_TYPE_LABELS[server.transportType] ??
server.transportType}
</span>
</Badge>
{context?.matchedEntries?.length ? (
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Marketplace
</span>
</Badge>
) : null}
<div className="flex-1" />
{renderServerToggle(server)}
@@ -813,7 +814,7 @@ export function McpServersContent({
installedItems={installedItems}
onInstalledItemsChanged={() => refreshServers()}
primitive="mcp"
variant={chrome === "embedded" ? "installed" : "full"}
variant="full"
/>
<Dialog
open={editorOpen}
@@ -83,11 +83,7 @@ export function NotificationSettings() {
<span className="shrink-0 text-xs font-medium text-muted-foreground">
Allowed by system
</span>
) : permission === "unsupported" ? (
<span className="shrink-0 text-xs text-muted-foreground">
Available in the desktop app
</span>
) : permission === null ? (
) : permission === "unsupported" ? null : permission === null ? (
<span className="shrink-0 text-xs text-muted-foreground">Checking</span>
) : (
<Button
@@ -101,9 +97,13 @@ export function NotificationSettings() {
</Button>
);
// One settings section: a top-level header row like the other General
// settings, with the per-event matrix nested in a card so its rows read
// as children of "Desktop notifications" rather than as siblings of
// top-level settings like Dark mode.
return (
<>
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="border-b py-4">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Desktop notifications
@@ -120,7 +120,7 @@ export function NotificationSettings() {
</div>
{permissionControl}
</div>
<div className="border-b">
<div className="mt-4 rounded-lg border bg-card px-4">
<div className="grid grid-cols-[minmax(0,1fr)_5rem_4rem] items-center gap-3 border-b py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>Event</span>
<span className="text-center">Notify</span>
@@ -165,6 +165,6 @@ export function NotificationSettings() {
);
})}
</div>
</>
</div>
);
}
@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Provider, VoiceInputSelection } from "@/lib/provider-schema";
import type { Provider } from "@/lib/provider-schema";
import {
ProviderDetailContent,
ProviderListContent,
@@ -107,57 +107,40 @@ describe("ProviderDetailContent models", () => {
});
});
const voiceProviders: Provider[] = [
const catalogProviders: Provider[] = [
{
id: "anthropic",
name: "Anthropic",
models: 4,
color: "#000000",
letter: "AN",
enabled: true,
apiKey: "sk-test",
capabilities: ["popular"],
modelList: [],
},
{
id: "cline",
name: "Cline",
models: 12,
color: "#000000",
letter: "CL",
enabled: false,
capabilities: ["oauth", "popular"],
modelList: [],
},
{
id: "elevenlabs",
name: "ElevenLabs",
models: 1,
color: "#000000",
letter: "EL",
enabled: true,
modelList: [
{
id: "scribe_v2",
name: "Scribe v2",
operation: "transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
},
{
id: "groq",
name: "Groq",
models: 3,
color: "#000000",
letter: "GR",
enabled: true,
modelList: [
{
id: "whisper-large-v3",
name: "Whisper Large v3",
operation: "transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "whisper-large-v3-turbo",
name: "Whisper Large v3 Turbo",
operation: "transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "llama-chat",
name: "Llama Chat",
inputModalities: ["text"],
outputModalities: ["text"],
},
],
enabled: false,
modelList: [],
},
];
describe("ProviderListContent voice input settings", () => {
describe("ProviderListContent", () => {
let container: HTMLDivElement;
let root: Root;
@@ -174,74 +157,210 @@ describe("ProviderListContent voice input settings", () => {
vi.restoreAllMocks();
});
it("lets the user choose and clear the voice provider and model", async () => {
const onVoiceInputChange = vi.fn();
let selection: VoiceInputSelection | undefined = {
providerId: "elevenlabs",
modelId: "scribe_v2",
};
const render = async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={onVoiceInputChange}
providers={voiceProviders}
voiceInput={selection}
/>,
);
});
};
await render();
const providerSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
);
const modelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
);
expect(providerSelect?.value).toBe("elevenlabs");
expect(modelSelect?.value).toBe("scribe_v2");
it("groups providers into Configured, Popular, and All providers", async () => {
const onConfigure = vi.fn();
await act(async () => {
if (!providerSelect) return;
providerSelect.value = "groq";
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3",
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={onConfigure}
providers={catalogProviders}
/>,
);
});
selection = {
providerId: "groq",
modelId: "whisper-large-v3",
};
await render();
const groqModelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
const headings = Array.from(container.querySelectorAll("h2")).map(
(heading) => heading.textContent,
);
expect(headings).toEqual(["Configured", "Popular", "All providers"]);
// No per-provider enable toggles anymore.
expect(container.querySelector('[role="switch"]')).toBeNull();
expect(container.textContent).toContain("1 configured");
const rows = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.includes("Anthropic"),
);
await act(async () => rows[0]?.click());
expect(onConfigure).toHaveBeenCalledWith("anthropic");
});
it("shows connection status and auth kind per row", async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
providers={catalogProviders}
/>,
);
});
const rowFor = (name: string) =>
Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes(name),
);
expect(rowFor("Anthropic")?.textContent).toContain("Configured");
expect(rowFor("Cline")?.textContent).toContain("Sign in");
expect(rowFor("ElevenLabs")?.textContent).toContain("API key");
});
it("filters providers by search across every group", async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
providers={catalogProviders}
/>,
);
});
const search = container.querySelector<HTMLInputElement>(
'[aria-label="Search model providers"]',
);
await act(async () => {
if (!groqModelSelect) return;
groqModelSelect.value = "whisper-large-v3-turbo";
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(search, "eleven");
search?.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3-turbo",
expect(container.textContent).toContain("ElevenLabs");
expect(container.textContent).not.toContain("Anthropic");
});
});
describe("ProviderDetailContent auth flows", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// "cline" is a featured provider, so mounting its detail refreshes the
// model list; resolve it to keep the auth assertions deterministic.
loadProviderModelsMock.mockReset().mockResolvedValue([]);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
const signInProvider: Provider = {
id: "cline",
name: "Cline",
models: 0,
color: "#000",
letter: "CL",
enabled: false,
capabilities: ["oauth"],
configFields: [
{
path: "apiKey",
label: "API Key",
type: "password",
secret: true,
},
],
modelList: [],
};
it("shows browser sign-in instead of an API key field for OAuth providers", async () => {
const onOAuthLogin = vi.fn();
await act(async () => {
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onOAuthLogin={onOAuthLogin}
onUpdate={vi.fn()}
provider={signInProvider}
/>,
);
});
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
expect(container.textContent).toContain("Sign in with browser");
expect(container.textContent).toContain("Not configured");
// The API key input stays collapsed until explicitly requested.
expect(container.querySelector('input[type="password"]')).toBeNull();
const signIn = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Sign in with browser"),
);
await act(async () => signIn?.click());
expect(onOAuthLogin).toHaveBeenCalledOnce();
const manualKey = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Use an API key instead"),
);
await act(async () => manualKey?.click());
expect(container.querySelector('input[type="password"]')).not.toBeNull();
});
it("offers sign out when OAuth is connected", async () => {
const onDisconnect = vi.fn();
await act(async () => {
if (!groqProviderSelect) return;
groqProviderSelect.value = "";
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onDisconnect={onDisconnect}
onUpdate={vi.fn()}
provider={{
...signInProvider,
enabled: true,
oauthAccessTokenPresent: true,
}}
/>,
);
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
expect(container.textContent).toContain("Signed in via browser");
expect(container.textContent).toContain("Configured");
const signOut = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Sign out",
);
await act(async () => signOut?.click());
expect(onDisconnect).toHaveBeenCalledOnce();
});
it("offers connect and disconnect for API-key providers", async () => {
const onConnect = vi.fn();
await act(async () => {
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onConnect={onConnect}
onUpdate={vi.fn()}
provider={{ ...provider, enabled: false }}
/>,
);
});
const connect = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Connect",
);
await act(async () => connect?.click());
expect(onConnect).toHaveBeenCalledOnce();
const onDisconnect = vi.fn();
await act(async () => {
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onDisconnect={onDisconnect}
onUpdate={vi.fn()}
provider={{ ...provider, apiKey: "sk-test" }}
/>,
);
});
const disconnect = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Disconnect",
);
await act(async () => disconnect?.click());
expect(onDisconnect).toHaveBeenCalledOnce();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
import { BookOpen, Bug, Newspaper, ShieldAlert } from "lucide-react";
import type { ComponentType } from "react";
export interface RoutineTemplate {
id: string;
title: string;
description: string;
icon: ComponentType<{ className?: string }>;
name: string;
prompt: string;
scheduleType: "daily" | "weekly";
scheduleDays: string[];
scheduleHour: string;
scheduleMinute: string;
}
const FIND_CRITICAL_BUGS_PROMPT = `You are an automated bug hunter that runs on a schedule. Dig through recent changes in this repository and catch severe correctness bugs before users hit them.
## Scope
Review the commits landed since your last run (roughly the past day). Only chase problems with serious consequences: data loss or corruption, crashes in critical paths, security regressions, races that drop writes, unbounded loops, resource leaks, and silent truncation of user data.
Explicitly skip style nits, theoretical edge cases with no realistic trigger, and minor issues that would merely degrade UX.
## How to investigate
- Read beyond the diff. Follow the caller chain and downstream consumers so you understand the real blast radius of each change instead of pattern-matching on the patch.
- For every suspect, construct the concrete sequence of events that makes it misbehave. If you cannot describe a plausible trigger, drop the finding.
## When you find something
- Apply the smallest fix you are confident in, and add or update a test to lock in the behavior when practical. No drive-by refactors in the same change.
- Before opening a PR, check whether an open PR already fixes the same bug. If one exists, note that it is awaiting review with a link instead of duplicating it. If a previous fix was closed without merging, do not re-open one unless the relevant code has materially changed.
- Only open a PR when you are highly confident the bug is real and the fix is correct. If PRs are not available in this workspace, commit the fix to a branch and describe it in your summary.
## Wrap up
Finish with a short report: what you inspected, and for each fix the bug, its impact, the root cause, and how you validated the change. If nothing clears the bar, a plain "no critical bugs found" summary is the expected outcome most runs.`;
const SECURITY_SCAN_PROMPT = `You are a scheduled security reviewer for this repository. Find medium, high, or critical vulnerabilities with a genuine end-to-end attack path, not theoretical weaknesses.
## Where to look
- Authentication, session handling, and permission checks
- Request handlers, RPC endpoints, webhook receivers, and other entry points
- Raw SQL, shell execution, file-system access, and template rendering
- Deserialization and parsing of untrusted input
- Secrets handling and anything that logs sensitive values
## Validation bar
A finding only counts if you can walk the entire chain: who the attacker is, what input they control, how that input reaches the vulnerable code, and what impact they gain. Trace the code to confirm every step. Skip lint-level "unsafe API" observations that lack a real route in, and skip best-practice notes without concrete impact.
## Avoiding repeats
Keep a running log of past findings in a local notes file (for example \`security-findings.local.md\` in the workspace root, excluded from version control). Read it before scanning, do not re-report anything already listed, and append new validated findings after each run.
## Reporting
For each new validated finding, write up the severity, the affected file, the full attack path, and the highest-leverage remediation. Treat findings as sensitive: keep them in the local report, and do not open a PR or publish them elsewhere from this scan. If nothing new clears the bar, say so briefly and stop.`;
const DAILY_DIGEST_PROMPT = `You write a daily engineering digest for this repository.
## Task
Review everything that landed in the last 24 hours (commits and merged PRs) and distill it into a brief a teammate could read in under a minute.
## Cover
- Changes that matter: new behavior, user-facing impact, and notable bug fixes
- Risky territory: large diffs, sensitive subsystems touched, migrations, dependency or security updates
- Loose ends: missing tests, TODOs introduced, rollout risks, likely follow-ups
## Style
- Group related changes into themes instead of listing every commit.
- Tie every claim to a concrete commit or PR; never guess at intent or invent details.
- Prioritize signal over completeness, and keep the whole digest easy to skim. On quiet days a two-line digest is fine.
## Format
Start with the date range covered, then 3-7 bullets of meaningful changes, then a short "Worth watching" section with 1-3 risks or pending follow-ups.`;
const UPDATE_DOCS_PROMPT = `You are a documentation maintainer that runs on a schedule. Keep this repository's docs accurate as the code evolves.
## Task
Compare recent code changes against the existing documentation and close the gaps.
## Priorities
- Docs that recent changes made stale or wrong. Fix these first.
- Recently touched subsystems with thin or missing coverage.
- Public interfaces, developer setup and troubleshooting guides, and operational runbooks.
## Standards
- Verify every statement against the source code; never document behavior you have not confirmed.
- Prefer updating existing pages over creating redundant new ones.
- Explain intent and usage with concrete examples and constraints, and keep pages structured for scanning.
- Match the style, tone, and location conventions of the docs already in this repository.
## Output
Open a focused, docs-only PR (or commit the updates to a branch if PRs are unavailable). Summarize which docs you added or updated, the code paths they now cover, and the knowledge gaps you closed. If everything is already accurate, report that and finish.`;
export const ROUTINE_TEMPLATES: RoutineTemplate[] = [
{
id: "find-critical-bugs",
title: "Find critical bugs",
description:
"Sweep recent commits for high-severity bugs that slipped past review, and fix the ones with a concrete trigger.",
icon: Bug,
name: "Find critical bugs",
prompt: FIND_CRITICAL_BUGS_PROMPT,
scheduleType: "daily",
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
scheduleHour: "9",
scheduleMinute: "0",
},
{
id: "security-scan",
title: "Scan for vulnerabilities",
description:
"Audit the codebase for exploitable security issues with a validated end-to-end attack path.",
icon: ShieldAlert,
name: "Security scan",
prompt: SECURITY_SCAN_PROMPT,
scheduleType: "weekly",
scheduleDays: ["MON"],
scheduleHour: "8",
scheduleMinute: "0",
},
{
id: "daily-digest",
title: "Summarize changes daily",
description:
"Get a skimmable digest of everything that landed in the last 24 hours, plus risks worth watching.",
icon: Newspaper,
name: "Daily change digest",
prompt: DAILY_DIGEST_PROMPT,
scheduleType: "daily",
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
scheduleHour: "17",
scheduleMinute: "0",
},
{
id: "update-docs",
title: "Keep docs updated",
description:
"Refresh documentation whenever the code drifts away from it, verified against the source.",
icon: BookOpen,
name: "Update docs",
prompt: UPDATE_DOCS_PROMPT,
scheduleType: "weekly",
scheduleDays: ["FRI"],
scheduleHour: "10",
scheduleMinute: "0",
},
];
@@ -86,6 +86,7 @@ import {
PageFrame,
PageHeader,
} from "../page-layout";
import { ROUTINE_TEMPLATES, type RoutineTemplate } from "./routine-templates";
type DateTimeValue = number | string;
@@ -868,7 +869,7 @@ export function RoutineSchedulesContent({
}
};
const openCreateDialog = async () => {
const openCreateDialog = async (template?: RoutineTemplate) => {
setEditingSchedule(null);
setErrorMessage(null);
setCreateFormError(null);
@@ -893,13 +894,20 @@ export function RoutineSchedulesContent({
? rememberedModel
: (modelsForProvider[0] ?? createForm.model);
setCreateForm({
name: "",
scheduleType: "once",
name: template?.name ?? "",
scheduleType: template?.scheduleType ?? "once",
scheduleDate: defaultScheduleDate(),
scheduleHour: "9",
scheduleMinute: "0",
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
prompt: "Review PRs opened yesterday and summarize issues.",
scheduleHour: template?.scheduleHour ?? "9",
scheduleMinute: template?.scheduleMinute ?? "0",
scheduleDays: template?.scheduleDays ?? [
"MON",
"TUE",
"WED",
"THU",
"FRI",
],
prompt:
template?.prompt ?? "Review PRs opened yesterday and summarize issues.",
provider: preferredProvider,
model: preferredModel,
workspaceRoot: context.workspaceRoot || context.cwd,
@@ -1075,6 +1083,16 @@ export function RoutineSchedulesContent({
[schedules],
);
// Hide suggestions the user has already created (matched by schedule name).
const visibleTemplates = useMemo(() => {
const existingNames = new Set(
schedules.map((schedule) => schedule.name.trim().toLowerCase()),
);
return ROUTINE_TEMPLATES.filter(
(template) => !existingNames.has(template.name.trim().toLowerCase()),
);
}, [schedules]);
const viewingExecutions = useMemo(() => {
if (!viewingSchedule) {
return [];
@@ -1097,8 +1115,8 @@ export function RoutineSchedulesContent({
return (
<PageFrame>
<PageHeader
description="Scheduled jobs are run through the hub."
title="Schedules"
description="Run agents on cron schedules for recurring automations like daily summaries and code reviews."
title="Schedule"
meta={<CommandBadge>cline schedule</CommandBadge>}
actions={
<>
@@ -1130,8 +1148,8 @@ export function RoutineSchedulesContent({
<PageEmptyState>Loading schedules...</PageEmptyState>
) : sortedSchedules.length === 0 ? (
<PageEmptyState>
No schedules found. Create a schedule to run routines on a recurring
basis.
No schedules yet. Start from a suggestion below, or create your own
with New Schedule.
</PageEmptyState>
) : (
<div className="flex flex-col gap-3">
@@ -1343,6 +1361,45 @@ export function RoutineSchedulesContent({
})}
</div>
)}
{!isLoading && visibleTemplates.length > 0 && (
<section className="mt-10">
<h2 className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
Suggested
</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{visibleTemplates.map((template) => {
const Icon = template.icon;
return (
<button
className="group flex items-start gap-3 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter"
key={template.id}
onClick={() => void openCreateDialog(template)}
type="button"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors group-hover:text-primary">
<Icon className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground">
{template.title}
</h3>
<span className="shrink-0 rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
{template.scheduleType === "daily" ? "Daily" : "Weekly"}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{template.description}
</p>
</div>
<Plus className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
})}
</div>
</section>
)}
<Dialog
open={Boolean(viewingSchedule)}
onOpenChange={(open) => {
@@ -5,26 +5,32 @@
* initial chat bundle. The heavy views load on demand via next/dynamic.
*/
export const SETTINGS_SECTIONS = [
const ALL_SETTINGS_SECTIONS = [
"General",
"Models",
"Voice",
"Channels",
"Schedules",
"Account",
] as const;
// Mirrors the Cline Hub dashboard's Customizations nav group. Plugins is the
// unified hub for installed plugins, MCP servers, and skills; Marketplace is
// the full catalog page for installing more.
export const CUSTOMIZATION_SECTIONS = [
"Plugins",
"Marketplace",
"Hooks",
"Rules",
"Agents",
"Tools",
] as const;
// Customize is the unified hub for everything that extends Cline — skills,
// MCP servers, plugins, rules, hooks, and tools — each as a sub-tab with
// inline marketplace browsing where a catalog exists.
const ALL_CUSTOMIZATION_SECTIONS = ["Customize"] as const;
export type SettingsSection =
| (typeof SETTINGS_SECTIONS)[number]
| (typeof CUSTOMIZATION_SECTIONS)[number];
| (typeof ALL_SETTINGS_SECTIONS)[number]
| (typeof ALL_CUSTOMIZATION_SECTIONS)[number];
// Temporarily hidden from the sidebar. The views and routes still exist —
// remove a section from this set to surface it again.
const HIDDEN_SECTIONS: ReadonlySet<SettingsSection> = new Set(["Channels"]);
export const SETTINGS_SECTIONS = ALL_SETTINGS_SECTIONS.filter(
(section) => !HIDDEN_SECTIONS.has(section),
);
export const CUSTOMIZATION_SECTIONS = ALL_CUSTOMIZATION_SECTIONS.filter(
(section) => !HIDDEN_SECTIONS.has(section),
);
@@ -3,6 +3,13 @@ import { Minus, Plus, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { isBetaVersion, productNameForVersion } from "@/lib/app-channel";
@@ -25,6 +32,10 @@ import {
} from "@/lib/app-icon";
import { desktopClient } from "@/lib/desktop-client";
import { resetOnboarding } from "@/lib/onboarding";
import {
getProviderAuthKind,
isProviderConnected,
} from "@/lib/provider-connection";
import {
fetchProviderCatalog,
invalidateProviderCatalogCache,
@@ -37,7 +48,6 @@ import type {
ProviderCatalogResponse,
ProviderModelsResponse,
ProviderSettingsUpdate,
VoiceInputSelection,
} from "@/lib/provider-schema";
import {
type HubAccent,
@@ -49,17 +59,12 @@ import {
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { MarketplaceView } from "../marketplace-view";
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,
invalidateExtensionInventoryCache,
} from "./extensions-view";
import { CustomizeView } from "./customize-view";
import { NotificationSettings } from "./notification-settings";
import { PluginsHubView } from "./plugins-hub-view";
import {
ProviderDetailContent,
ProviderListContent,
@@ -67,6 +72,7 @@ import {
import { RoutineSchedulesContent } from "./routine-view";
import type { SettingsSection } from "./sections";
import { toSettingsPatch } from "./settings-patch";
import { VoiceInputContent } from "./voice-input-view";
// Nav categories live in ./sections so the always-mounted sidebar can import
// them without pulling this module graph into the initial bundle.
@@ -88,7 +94,6 @@ let providerCatalogCache: {
providers: Provider[];
fetchedAt: number;
} | null = null;
let voiceInputCache: VoiceInputSelection | undefined;
// -----------------------------------------------------------
// Component
@@ -126,10 +131,14 @@ export function SettingsView({
null,
);
const [addingProvider, setAddingProvider] = useState(false);
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
() => voiceInputCache,
);
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
// Bumped by every optimistic provider mutation and catalog load. An
// in-flight catalog response is discarded when the generation moved on,
// so an older disk snapshot can never overwrite a newer edit.
const catalogGenerationRef = useRef(0);
// Bumped when a failed save resyncs the catalog from disk; keys the
// detail panel so its local field drafts remount from the reloaded
// props instead of keeping unpersisted values.
const [detailResetToken, setDetailResetToken] = useState(0);
useEffect(() => {
if (section !== "Models") {
@@ -155,35 +164,46 @@ export function SettingsView({
[],
);
const loadProviderCatalog = useCallback(async () => {
/**
* Loads the catalog into view state. Resolves to false when the response
* was discarded because a newer mutation or load superseded it while in
* flight (so an older disk snapshot never overwrites a newer edit);
* callers needing an authoritative resync should retry on false.
*/
const loadProviderCatalog = useCallback(async (): Promise<boolean> => {
const now = Date.now();
if (
providerCatalogCache &&
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
) {
setProviders(providerCatalogCache.providers);
setVoiceInput(voiceInputCache);
setProvidersLoading(false);
setProviderCatalogError(null);
return;
return true;
}
const generation = ++catalogGenerationRef.current;
setProvidersLoading(true);
setProviderCatalogError(null);
try {
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
"list_provider_catalog",
);
if (generation !== catalogGenerationRef.current) {
return false;
}
setProvidersWithCache(payload.providers);
voiceInputCache = payload.voiceInput;
setVoiceInput(payload.voiceInput);
} catch (error) {
if (generation !== catalogGenerationRef.current) {
return false;
}
const message = error instanceof Error ? error.message : String(error);
setProviderCatalogError(message);
setProviders([]);
} finally {
setProvidersLoading(false);
}
return true;
}, [setProvidersWithCache]);
useEffect(() => {
@@ -220,6 +240,19 @@ export function SettingsView({
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save provider settings for ${id}: ${message}`);
// The optimistic list update no longer matches disk: resync from
// the authoritative catalog. Retry when a concurrent edit
// superseded the in-flight response (that edit performs no
// reload of its own), then remount the detail panel so its
// field drafts re-seed from the reloaded state — not before,
// or they would re-capture the unpersisted optimistic values.
for (let attempt = 0; attempt < 3; attempt++) {
providerCatalogCache = null;
if (await loadProviderCatalog()) {
break;
}
}
setDetailResetToken((token) => token + 1);
return false;
} finally {
// Keep the shared short-lived catalog cache (composer model
@@ -227,66 +260,63 @@ export function SettingsView({
invalidateProviderCatalogCache();
}
},
[],
[loadProviderCatalog],
);
const toggleProvider = useCallback(
const connectProvider = useCallback(
(id: string) => {
// Persist an (empty) settings entry so the provider is enabled with
// whatever credentials it resolves at runtime (env vars, local CLI,
// keyless endpoints).
catalogGenerationRef.current++;
setProvidersWithCache((prev) =>
prev.map((p) => {
if (p.id !== id) {
return p;
}
const nextEnabled = !p.enabled;
const clearsVoiceInput =
!nextEnabled && voiceInput?.providerId === id;
void persistProviderSettings(id, { enabled: nextEnabled }).then(
(saved) => {
if (saved && clearsVoiceInput) {
voiceInputCache = undefined;
setVoiceInput(undefined);
notifyVoiceInputSettingsChanged();
}
},
);
return { ...p, enabled: nextEnabled };
}),
prev.map((p) => (p.id === id ? { ...p, enabled: true } : p)),
);
void persistProviderSettings(id, { enabled: true });
},
[persistProviderSettings, setProvidersWithCache, voiceInput],
[persistProviderSettings, setProvidersWithCache],
);
const updateVoiceInput = useCallback(
async (selection: VoiceInputSelection | undefined) => {
setVoiceInputSaving(true);
try {
const result = await desktopClient.invoke<{
voiceInput?: VoiceInputSelection;
}>("save_voice_input_settings", {
provider: selection?.providerId,
model: selection?.modelId,
});
voiceInputCache = result.voiceInput;
setVoiceInput(result.voiceInput);
const disconnectProvider = useCallback(
async (id: string) => {
catalogGenerationRef.current++;
setProvidersWithCache((prev) =>
prev.map((p) =>
p.id === id
? {
...p,
enabled: false,
apiKey: undefined,
oauthAccessTokenPresent: false,
}
: p,
),
);
const saved = await persistProviderSettings(id, { enabled: false });
if (saved) {
// Disconnecting removes the persisted entry (and the sidecar drops
// a voice-input selection pointing at it); reload so the view and
// the chat microphone reflect the real on-disk state.
providerCatalogCache = null;
notifyVoiceInputSettingsChanged();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save voice input settings: ${message}`);
} finally {
setVoiceInputSaving(false);
await loadProviderCatalog();
}
},
[],
[loadProviderCatalog, persistProviderSettings, setProvidersWithCache],
);
const updateProvider = useCallback(
(id: string, updates: ProviderSettingsUpdate) => {
// Saving settings creates the provider's persisted entry, which is
// what "connected" means for keyless providers — reflect it locally.
catalogGenerationRef.current++;
setProvidersWithCache((prev) =>
prev.map((p) =>
p.id === id
? {
...p,
...updates,
enabled: true,
configValues: updates.configValues
? {
...(p.configValues ?? {}),
@@ -359,12 +389,19 @@ export function SettingsView({
[loadProviderModels],
);
const selectedProvider = selectedProviderId
? (providers.find((p) => p.id === selectedProviderId) ?? null)
// The detail panel is always open: with no explicit selection, default to
// the first connected provider (the one in use), then the first provider.
const effectiveSelectedProviderId =
selectedProviderId ??
providers.find(isProviderConnected)?.id ??
providers[0]?.id ??
null;
const selectedProvider = effectiveSelectedProviderId
? (providers.find((p) => p.id === effectiveSelectedProviderId) ?? null)
: null;
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
getProviderAuthKind(provider) === "oauth";
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -405,14 +442,14 @@ export function SettingsView({
};
useEffect(() => {
if (!selectedProviderId) {
if (!effectiveSelectedProviderId) {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderModels(selectedProviderId);
void loadProviderModels(effectiveSelectedProviderId);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderModels, selectedProviderId]);
}, [loadProviderModels, effectiveSelectedProviderId]);
const backToProviderList = () => {
onNavigateSection("Models");
@@ -444,17 +481,36 @@ export function SettingsView({
const openAddProvider = () => {
onNavigateSection("Models");
setSelectedProviderId(null);
setAddingProvider(true);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
const addProviderDialog = (
<Dialog
onOpenChange={(open) => {
if (!open) {
setAddingProvider(false);
}
}}
open={addingProvider}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Add Provider</DialogTitle>
<DialogDescription>
Add an OpenAI-compatible provider and choose its available models.
</DialogDescription>
</DialogHeader>
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={() => setAddingProvider(false)}
onSave={saveNewProvider}
variant="dialog"
/>
</DialogContent>
</Dialog>
);
const providerContent = providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
@@ -466,23 +522,27 @@ export function SettingsView({
</div>
) : selectedProvider ? (
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
/>
{/* min-h-0/min-w-0: grid items default to min-size auto, which lets
the pane grow past its track and leaves the inner ScrollArea with
nothing to scroll. */}
<div className="min-h-0 min-w-0 overflow-hidden">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
/>
</div>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
key={`${selectedProvider.id}:${detailResetToken}`}
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onConnect={() => connectProvider(selectedProvider.id)}
onDisconnect={() => void disconnectProvider(selectedProvider.id)}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onUpdateModels={(models) =>
void updateProviderModels(selectedProvider.id, models)
@@ -502,34 +562,22 @@ export function SettingsView({
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
providers={providers}
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
/>
);
const content =
activeNav === "Models" ? (
providerContent
) : activeNav === "Plugins" ? (
<PluginsHubView
onOpenMarketplace={() => onNavigateSection("Marketplace")}
<>
{providerContent}
{addProviderDialog}
</>
) : activeNav === "Voice" ? (
<VoiceInputContent
onOpenModelProviders={() => onNavigateSection("Models")}
/>
) : activeNav === "Marketplace" ? (
<MarketplaceView
onInstalledItemsChanged={invalidateExtensionInventoryCache}
variant="directory"
/>
) : activeNav === "Hooks" ? (
<CustomizationSectionView section="Hooks" />
) : activeNav === "Rules" ? (
<CustomizationSectionView section="Rules" />
) : activeNav === "Agents" ? (
<CustomizationSectionView section="Agents" />
) : activeNav === "Tools" ? (
<CustomizationSectionView section="Tools" />
) : activeNav === "Customize" ? (
<CustomizeView />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
@@ -0,0 +1,203 @@
// @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 { Provider } from "@/lib/provider-schema";
import { defaultTranscriptionModel, VoiceInputContent } from "./voice-input-view";
const { fetchProviderCatalogMock, invokeMock, notifyMock } = vi.hoisted(() => ({
fetchProviderCatalogMock: vi.fn(),
invokeMock: vi.fn(),
notifyMock: vi.fn(),
}));
vi.mock("@/lib/provider-model-catalog", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/provider-model-catalog")>();
return {
...actual,
fetchProviderCatalog: fetchProviderCatalogMock,
notifyVoiceInputSettingsChanged: notifyMock,
};
});
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
openExternalUrl: vi.fn(),
}));
const transcriptionProvider: Provider = {
id: "elevenlabs",
name: "ElevenLabs",
models: 2,
color: "#000000",
letter: "EL",
enabled: true,
apiKey: "sk-test",
modelList: [
{
id: "scribe_v1",
name: "Scribe v1",
operation: "transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "scribe_v2_realtime",
name: "Scribe v2 Realtime",
operation: "transcription",
operationModes: ["streaming"],
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
};
const unconnectedProvider: Provider = {
...transcriptionProvider,
id: "groq",
name: "Groq",
enabled: false,
apiKey: undefined,
};
describe("defaultTranscriptionModel", () => {
it("prefers streaming models, then the first transcription model", () => {
expect(
defaultTranscriptionModel(transcriptionProvider.modelList ?? [])?.id,
).toBe("scribe_v2_realtime");
expect(
defaultTranscriptionModel([
{ id: "batch-only", name: "Batch", operation: "transcription" },
])?.id,
).toBe("batch-only");
});
});
describe("VoiceInputContent", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
fetchProviderCatalogMock.mockReset();
invokeMock.mockReset();
notifyMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (onOpenModelProviders = vi.fn()) => {
await act(async () => {
root.render(
<VoiceInputContent onOpenModelProviders={onOpenModelProviders} />,
);
});
return onOpenModelProviders;
};
it("locks the page until a voice-capable provider is connected", async () => {
fetchProviderCatalogMock.mockResolvedValue({
providers: [unconnectedProvider],
settingsPath: "/tmp/providers.json",
});
const onOpenModelProviders = await render();
expect(container.textContent).toContain(
"Voice input needs a configured model provider",
);
const openProviders = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("Open Model Providers"));
await act(async () => openProviders?.click());
expect(onOpenModelProviders).toHaveBeenCalledOnce();
});
it("explains when connected providers offer no transcription models", async () => {
fetchProviderCatalogMock.mockResolvedValue({
providers: [
{
...transcriptionProvider,
id: "anthropic",
name: "Anthropic",
modelList: [{ id: "claude", name: "Claude" }],
},
unconnectedProvider,
],
settingsPath: "/tmp/providers.json",
});
await render();
expect(container.textContent).toContain(
"None of your configured providers offer speech-to-text models",
);
expect(container.textContent).toContain("Groq");
});
it("enables voice input with the default (streaming) model preselected", async () => {
fetchProviderCatalogMock.mockResolvedValue({
providers: [transcriptionProvider],
settingsPath: "/tmp/providers.json",
});
invokeMock.mockResolvedValue({
voiceInput: {
providerId: "elevenlabs",
modelId: "scribe_v2_realtime",
},
});
await render();
const toggle = container.querySelector<HTMLButtonElement>(
'[aria-label="Enable voice input"]',
);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => toggle?.click());
expect(invokeMock).toHaveBeenCalledWith("save_voice_input_settings", {
provider: "elevenlabs",
model: "scribe_v2_realtime",
});
expect(notifyMock).toHaveBeenCalled();
const selected = container.querySelector('[role="radio"][aria-checked="true"]');
expect(selected?.textContent).toContain("Scribe v2 Realtime");
expect(selected?.textContent).toContain("Default");
});
it("saves model changes and clears the selection when disabled", async () => {
fetchProviderCatalogMock.mockResolvedValue({
providers: [transcriptionProvider],
settingsPath: "/tmp/providers.json",
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v2_realtime" },
});
invokeMock.mockResolvedValue({
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v1" },
});
await render();
const batchModel = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
).find((button) => button.textContent?.includes("Scribe v1"));
await act(async () => batchModel?.click());
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
provider: "elevenlabs",
model: "scribe_v1",
});
invokeMock.mockResolvedValue({});
const toggle = container.querySelector<HTMLButtonElement>(
'[aria-label="Enable voice input"]',
);
await act(async () => toggle?.click());
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
provider: undefined,
model: undefined,
});
});
});
@@ -0,0 +1,343 @@
"use client";
import { AudioLines, Mic, Radio } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
import { isProviderConnected } from "@/lib/provider-connection";
import {
fetchProviderCatalog,
isDedicatedTranscriptionModel,
notifyVoiceInputSettingsChanged,
} from "@/lib/provider-model-catalog";
import type {
Provider,
ProviderModel,
VoiceInputSelection,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
type VoiceProviderEntry = {
provider: Provider;
models: ProviderModel[];
};
/**
* The model preselected when the user enables voice input or switches
* provider: streaming (live) transcription when available, else the first
* transcription model the provider offers.
*/
export function defaultTranscriptionModel(
models: ProviderModel[],
): ProviderModel | undefined {
return (
models.find((model) => model.operationModes?.includes("streaming")) ??
models[0]
);
}
function isStreamingModel(model: ProviderModel): boolean {
return model.operationModes?.includes("streaming") === true;
}
export function VoiceInputContent({
onOpenModelProviders,
}: {
onOpenModelProviders: () => void;
}) {
const [providers, setProviders] = useState<Provider[] | null>(null);
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
undefined,
);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
void fetchProviderCatalog()
.then((payload) => {
if (cancelled) return;
setProviders(payload.providers ?? []);
setVoiceInput(payload.voiceInput);
setLoadError(null);
})
.catch((error) => {
if (cancelled) return;
setLoadError(error instanceof Error ? error.message : String(error));
setProviders([]);
});
return () => {
cancelled = true;
};
}, []);
const connectedProviders = (providers ?? []).filter(isProviderConnected);
const voiceProviders: VoiceProviderEntry[] = connectedProviders
.map((provider) => ({
provider,
models: (provider.modelList ?? []).filter(isDedicatedTranscriptionModel),
}))
.filter((entry) => entry.models.length > 0);
// Providers that would qualify once connected, so the empty states can
// point the user at something actionable.
const voiceCapableProviderNames = (providers ?? [])
.filter((provider) =>
(provider.modelList ?? []).some(isDedicatedTranscriptionModel),
)
.map((provider) => provider.name);
const selectedEntry = voiceProviders.find(
(entry) => entry.provider.id === voiceInput?.providerId,
);
const save = useCallback(
async (selection: VoiceInputSelection | undefined) => {
const previous = voiceInput;
setVoiceInput(selection);
setSaving(true);
setSaveError(null);
try {
const result = await desktopClient.invoke<{
voiceInput?: VoiceInputSelection;
}>("save_voice_input_settings", {
provider: selection?.providerId,
model: selection?.modelId,
});
setVoiceInput(result.voiceInput);
notifyVoiceInputSettingsChanged();
} catch (error) {
setVoiceInput(previous);
setSaveError(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
},
[voiceInput],
);
const enableWithDefaults = useCallback(() => {
const entry = voiceProviders[0];
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
if (!entry || !model) return;
void save({ providerId: entry.provider.id, modelId: model.id });
}, [save, voiceProviders]);
const selectProvider = useCallback(
(providerId: string) => {
const entry = voiceProviders.find(
(candidate) => candidate.provider.id === providerId,
);
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
if (!entry || !model) return;
void save({ providerId: entry.provider.id, modelId: model.id });
},
[save, voiceProviders],
);
const header = (
<PageHeader
description="Speak instead of typing: the microphone in chat transcribes your voice with the model chosen here. Live models show text as you speak; others transcribe when the recording stops."
title="Voice input"
/>
);
if (providers === null) {
return (
<PageFrame>
{header}
<p className="text-sm text-muted-foreground">Loading providers...</p>
</PageFrame>
);
}
if (loadError) {
return (
<PageFrame>
{header}
<p className="text-sm text-destructive">
Failed to load providers: {loadError}
</p>
</PageFrame>
);
}
if (voiceProviders.length === 0) {
const hasConnected = connectedProviders.length > 0;
return (
<PageFrame>
{header}
<div className="flex max-w-2xl flex-col items-start gap-3 rounded-lg border border-dashed px-6 py-8">
<Mic aria-hidden="true" className="size-6 text-muted-foreground" />
<p className="text-base font-medium text-foreground">
{hasConnected
? "None of your configured providers offer speech-to-text models"
: "Voice input needs a configured model provider"}
</p>
<p className="text-sm text-muted-foreground">
{voiceCapableProviderNames.length > 0
? `Connect a provider with transcription models — for example ${voiceCapableProviderNames
.slice(0, 4)
.join(", ")} and this page unlocks automatically.`
: "Connect a provider with transcription models and this page unlocks automatically."}
</p>
<Button onClick={onOpenModelProviders} size="sm" type="button">
Open Model Providers
</Button>
</div>
</PageFrame>
);
}
const enabled = Boolean(voiceInput);
return (
<PageFrame>
{header}
<section className="max-w-2xl">
<div className="flex items-center justify-between gap-5 border-y py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Enable voice input
</p>
<p className="text-sm text-muted-foreground">
Turns on the microphone button in chat. A default model is
preselected adjust it below.
</p>
</div>
<Switch
aria-label="Enable voice input"
checked={enabled}
disabled={saving}
onCheckedChange={(checked) => {
if (checked) enableWithDefaults();
else void save(undefined);
}}
/>
</div>
{saveError ? (
<p className="mt-3 text-xs text-destructive" role="alert">
Failed to save voice input settings: {saveError}
</p>
) : null}
{enabled ? (
<>
<div className="mt-6">
<p className="mb-2 text-sm font-semibold text-foreground">
Provider
</p>
<div className="flex flex-wrap gap-2">
{voiceProviders.map(({ provider }) => {
const isSelected = voiceInput?.providerId === provider.id;
return (
<button
aria-pressed={isSelected}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected
? "border-primary/40 bg-primary/8 text-foreground"
: "text-muted-foreground hover:bg-surface-hover-lighter hover:text-foreground",
)}
disabled={saving}
key={provider.id}
onClick={() => selectProvider(provider.id)}
type="button"
>
{provider.name}
</button>
);
})}
</div>
</div>
{selectedEntry ? (
<div className="mt-6">
<p className="mb-2 text-sm font-semibold text-foreground">
Model
</p>
<div
aria-label="Voice input model"
className="overflow-hidden rounded-lg border"
role="radiogroup"
>
{selectedEntry.models.map((model) => {
const isSelected = voiceInput?.modelId === model.id;
const isDefault =
defaultTranscriptionModel(selectedEntry.models)?.id ===
model.id;
return (
<button
aria-checked={isSelected}
className={cn(
"flex w-full items-center gap-3 border-b px-4 py-3 text-left last:border-b-0 hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected && "bg-surface-hover",
)}
disabled={saving}
key={model.id}
onClick={() =>
void save({
providerId: selectedEntry.provider.id,
modelId: model.id,
})
}
role="radio"
type="button"
>
<span
aria-hidden="true"
className={cn(
"grid size-4 shrink-0 place-items-center rounded-full border",
isSelected
? "border-primary"
: "border-muted-foreground/40",
)}
>
{isSelected ? (
<span className="size-2 rounded-full bg-primary" />
) : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-foreground">
{model.name}
</span>
{isStreamingModel(model) ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
<Radio aria-hidden="true" className="size-3" />
Live
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
<AudioLines
aria-hidden="true"
className="size-3"
/>
After recording
</span>
)}
{isDefault ? (
<span className="shrink-0 text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
Default
</span>
) : null}
</div>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{model.id}
</p>
</div>
</button>
);
})}
</div>
</div>
) : null}
</>
) : null}
</section>
</PageFrame>
);
}
@@ -11,14 +11,7 @@ export const CHAT_WS_ENDPOINT_RETRY_DELAY_MS = 100;
export const CHAT_WS_RECONNECT_BASE_DELAY_MS = 300;
export const CHAT_WS_RECONNECT_MAX_DELAY_MS = 3000;
export const CHAT_WS_REQUEST_TIMEOUT_MS = 120000;
export const OAUTH_MANAGED_PROVIDERS = new Set([
"cline",
// ClinePass shares the Cline account OAuth credentials (its auth handler
// stores under the "cline" provider), so it never has its own API key.
"cline-pass",
"oca",
"openai-codex",
]);
export { OAUTH_PROVIDER_IDS as OAUTH_MANAGED_PROVIDERS } from "@/lib/provider-connection";
export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
sessionId: undefined,
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { desktopClient } from "@/lib/desktop-client";
export type FeatureFlagValue =
| string
| number
| boolean
| null
| { [key: string]: FeatureFlagValue }
| FeatureFlagValue[];
type FeatureFlagsSnapshot = {
flags: Record<string, FeatureFlagValue>;
};
export type FeatureFlagsState = {
flags: Record<string, FeatureFlagValue>;
loaded: boolean;
refresh: () => Promise<void>;
};
export function useFeatureFlags(): FeatureFlagsState {
const [flags, setFlags] = useState<Record<string, FeatureFlagValue>>({});
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
try {
const snapshot =
await desktopClient.invoke<FeatureFlagsSnapshot>("get_feature_flags");
setFlags(snapshot?.flags ?? {});
} catch {
// Sidecar down or still starting. Leave the previous values in place;
// every consumer falls back to `false`, matching the registry default
// for a flag nobody has been opted into.
} finally {
setLoaded(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return { flags, loaded, refresh: load };
}
export function isFeatureEnabled(
flags: Record<string, FeatureFlagValue>,
flag: string,
): boolean {
return flags[flag] === true;
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import { isProviderConnected } from "@/lib/provider-connection";
import {
fetchProviderCatalog,
subscribeToProviderCatalogInvalidation,
} from "@/lib/provider-model-catalog";
/**
* Whether at least one model provider is connected (usable for turns).
* Returns null while unknown so callers can avoid flashing a disabled state
* before the catalog loads. Stays current across credential changes via the
* shared catalog invalidation channel.
*/
export function useHasConnectedProvider(): boolean | null {
const [hasConnectedProvider, setHasConnectedProvider] = useState<
boolean | null
>(null);
useEffect(() => {
let cancelled = false;
const load = () => {
// Failures (including an unavailable transport) keep the last known
// value; the catalog fetch is retried on the next invalidation.
try {
void Promise.resolve(fetchProviderCatalog())
.then((payload) => {
if (cancelled) return;
setHasConnectedProvider(
(payload?.providers ?? []).some(isProviderConnected),
);
})
.catch(() => {});
} catch {}
};
load();
const unsubscribe = subscribeToProviderCatalogInvalidation(load);
return () => {
cancelled = true;
unsubscribe();
};
}, []);
return hasConnectedProvider;
}
@@ -1209,7 +1209,7 @@ export function useSessionHistory({
);
};
// Favoriting is a single click, so apply it locally first and roll back
// Pinning is a single click, so apply it locally first and roll back
// if the write fails rather than blocking the row on a round trip.
applyPinned(pinned);
try {
@@ -1228,7 +1228,7 @@ export function useSessionHistory({
applyPinned(!pinned);
toast({
variant: "destructive",
title: pinned ? "Favorite failed" : "Unfavorite failed",
title: pinned ? "Pin failed" : "Unpin failed",
description:
error instanceof Error
? error.message
@@ -0,0 +1,8 @@
/**
* The Agenda (Todo) UI is temporarily hidden while its UX is reworked, in
* lockstep with `AGENDA_TODO_TOOL_ENABLED` in the hub server transport, which
* disables the agent-facing todo kind of the `tasks` tool. All Agenda
* components, hooks, and sidecar plumbing stay in the codebase; flip this back
* to true (together with the hub flag) to restore the feature.
*/
export const AGENDA_UI_ENABLED = false;
@@ -0,0 +1,24 @@
/**
* Tiny cross-tree signal for focusing the chat prompt input. The sidebar's
* "New" action lives far from the chat pane, so instead of threading a focus
* callback through the whole component chain it dispatches a window event
* that the mounted prompt input listens for.
*/
const FOCUS_PROMPT_INPUT_EVENT = "cline:focus-prompt-input";
export function requestPromptInputFocus(): void {
if (typeof window === "undefined") {
return;
}
// Deferred so a focus request issued alongside a navigation (e.g. the New
// button remounting the chat pane) reaches the freshly mounted input.
window.setTimeout(() => {
window.dispatchEvent(new Event(FOCUS_PROMPT_INPUT_EVENT));
}, 0);
}
export function subscribeToPromptInputFocus(listener: () => void): () => void {
window.addEventListener(FOCUS_PROMPT_INPUT_EVENT, listener);
return () => window.removeEventListener(FOCUS_PROMPT_INPUT_EVENT, listener);
}
@@ -1,5 +1,41 @@
import type { Provider } from "@/lib/provider-schema";
/**
* Providers whose credentials are managed through an OAuth sign-in flow
* rather than a pasted API key. Mirrors the SDK's provider auth registry
* (see sdk/packages/core/src/auth/provider-auth-registry.ts); the catalog's
* "oauth" capability is the primary signal and this set is the fallback for
* entries whose capability metadata is incomplete.
*/
export const OAUTH_PROVIDER_IDS = new Set([
"cline",
// ClinePass shares the Cline account OAuth credentials (its auth handler
// stores under the "cline" provider), so it never has its own API key.
"cline-pass",
"oca",
"openai-codex",
]);
export type ProviderAuthKind = "oauth" | "local" | "api-key";
/**
* How a provider expects to be authenticated, which drives which connect UI
* to show: a browser sign-in button (oauth), a "uses your local CLI" notice
* (local), or credential fields (api-key).
*/
export function getProviderAuthKind(provider: Provider): ProviderAuthKind {
if (
provider.capabilities?.includes("oauth") ||
OAUTH_PROVIDER_IDS.has(provider.id)
) {
return "oauth";
}
if (provider.capabilities?.includes("local-auth")) {
return "local";
}
return "api-key";
}
/**
* Whether a provider from the catalog is usable for turns, for the purpose
* of the first-run "connect a model" notice. Plain API keys and OAuth are
@@ -8,7 +8,7 @@ export type SessionHistoryStatus =
export type SessionMetadata = {
title?: string;
/**
* Favorited sessions. Stored in session metadata rather than desktop-local
* Pinned sessions. Stored in session metadata rather than desktop-local
* state so every client reading the session sees the same flag.
*/
pinned?: boolean;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.1.11",
"version": "4.1.15",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -6,6 +6,7 @@ import type { Controller } from "../../index"
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
const marketplaceHelpersMock = () => ({
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
isMcpEntryAllowedByPolicy: () => true,
})
mock.module("../marketplace-helpers", marketplaceHelpersMock)
@@ -23,6 +24,7 @@ describe("installMarketplaceEntry", () => {
const controller = {
mcpHub: { reconcileMcpServersFromSettingsRPC },
invalidateUserInstructionService,
stateManager: { getRemoteConfigSettings: () => ({}) },
} as unknown as Controller
installMarketplaceEntryFromCatalogStub.resolves({
id: "chrome-devtools",
@@ -1,8 +1,11 @@
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { MarketplaceCatalog } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { fetchMarketplaceCatalog } from "./marketplace-helpers"
import { fetchMarketplaceCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
export async function getMarketplaceCatalog(_controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
return fetchMarketplaceCatalog()
export async function getMarketplaceCatalog(controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
const catalog = await fetchMarketplaceCatalog()
// Filter out MCP entries blocked by enterprise remote config so they never reach the webview.
const policy = controller.stateManager.getRemoteConfigSettings()
return { ...catalog, entries: catalog.entries.filter((entry) => isMcpEntryAllowedByPolicy(entry, policy)) }
}
@@ -1,6 +1,6 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
import { installMarketplaceEntryFromCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
export async function installMarketplaceEntry(
controller: Controller,
@@ -9,6 +9,11 @@ export async function installMarketplaceEntry(
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
if (!isMcpEntryAllowedByPolicy(request.entry, controller.stateManager.getRemoteConfigSettings())) {
throw new Error(
`Installing "${request.entry.name || request.entry.id}" is blocked by your organization's MCP server policy.`,
)
}
const result = await installMarketplaceEntryFromCatalog(request.entry)
if (request.entry.type === "mcp") {
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
@@ -82,8 +82,17 @@ function sanitizeEntry(raw: unknown): MarketplaceEntry | undefined {
description: typeof record.description === "string" ? record.description : undefined,
tags: asStringArray(record.tags),
author: typeof record.author === "string" ? record.author : undefined,
sourceUrl: typeof record.sourceUrl === "string" ? record.sourceUrl : undefined,
homepageUrl: typeof record.homepageUrl === "string" ? record.homepageUrl : undefined,
// The published catalog uses "repo"/"homepage"; older entries may use
// "sourceUrl"/"homepageUrl". Accept both so URL-based enterprise
// allowlist ids can be matched against the entry.
sourceUrl:
typeof record.sourceUrl === "string" ? record.sourceUrl : typeof record.repo === "string" ? record.repo : undefined,
homepageUrl:
typeof record.homepageUrl === "string"
? record.homepageUrl
: typeof record.homepage === "string"
? record.homepage
: undefined,
install: install
? {
args: asStringArray(install.args),
@@ -134,6 +143,31 @@ function marketplaceKey(entry: MarketplaceEntry): string {
return `${entry.type}:${entry.id}`
}
/** Normalizes an allowlist id or entry identifier; legacy allowlist ids may be GitHub repo URLs. */
function normalizePolicyValue(value: string | undefined): string {
return normalizeMatchValue((value ?? "").replace(/^https?:\/\//i, "").replace(/\/+$/, ""))
}
/**
* Enterprise remote config can disable the MCP marketplace (`mcpMarketplaceEnabled: false`)
* or restrict it to an allowlist (`allowedMCPServers`). Non-MCP entries are not governed
* by these controls. Allowlist ids match the entry id, display name, installed server
* name, or source/homepage URL.
*/
export function isMcpEntryAllowedByPolicy(
entry: MarketplaceEntry,
policy: { mcpMarketplaceEnabled?: boolean; allowedMCPServers?: Array<{ id: string }> },
): boolean {
if (entry.type !== "mcp") return true
if (policy.mcpMarketplaceEnabled === false) return false
if (!policy.allowedMCPServers?.length) return true
const candidates = new Set(
[entry.id, entry.name, getEntryArgs(entry)[0], entry.sourceUrl, entry.homepageUrl].map(normalizePolicyValue),
)
candidates.delete("")
return policy.allowedMCPServers.some((server) => candidates.has(normalizePolicyValue(server.id)))
}
function getEntryArgs(entry: MarketplaceEntry): string[] {
return entry.install?.args ?? []
}
@@ -102,8 +102,14 @@ export class HookDiscoveryCache {
/**
* Get cached hook scripts or scan if not cached
*
* @param hooksDirs Optional snapshot of the hooks directories to scan on a
* cache miss. Callers that already resolved the window's workspace roots
* pass this so discovery uses the same snapshot as cwd selection and hook
* input metadata (and the miss costs no extra host lookup). Ignored on a
* cache hit.
*/
async get(hookName: HookName): Promise<string[]> {
async get(hookName: HookName, hooksDirs?: string[]): Promise<string[]> {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
@@ -127,7 +133,7 @@ export class HookDiscoveryCache {
} else {
// This caller initiates the scan
initiatedScan = true
scripts = await this.scan(hookName)
scripts = await this.scan(hookName, hooksDirs)
}
}
@@ -147,7 +153,7 @@ export class HookDiscoveryCache {
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
private async scan(hookName: HookName, knownHooksDirs?: string[]): Promise<string[]> {
// Check if a scan is already in progress for this hook
const existingPromise = this.scanningPromises.get(hookName)
if (existingPromise) {
@@ -158,8 +164,9 @@ export class HookDiscoveryCache {
// Create a new scan promise
const scanPromise = (async () => {
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
// Use the caller's hooks-dir snapshot when provided, otherwise
// resolve the current hooks directories
const hooksDirs = knownHooksDirs ?? (await getAllHooksDirs())
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
@@ -26,18 +26,21 @@ fixtures/
The `loadFixture()` helper function copies a fixture to your test environment:
```typescript
import { loadFixture } from '../test-utils'
import { createHookTestEnv, loadFixture } from '../test-utils'
it("should work with real hook", async () => {
const { getEnv } = setupHookTests()
await loadFixture("hooks/pretooluse/success", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.cancel.should.be.false()
const env = await createHookTestEnv()
try {
await loadFixture("hooks/pretooluse/success", env.tempDir)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.cancel.should.be.false()
} finally {
await env.cleanup()
}
})
```
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { HookDiscoveryCache } from "../HookDiscoveryCache"
import { createHookTestEnv, createTestHook, HookTestEnv, resetHookCache } from "./test-utils"
describe("HookDiscoveryCache", () => {
let env: HookTestEnv
beforeEach(async () => {
env = await createHookTestEnv()
})
afterEach(async () => {
await env.cleanup()
})
it("scans the caller-provided hooks-dir snapshot on a cache miss", async () => {
// A hooks dir outside what the stubbed getAllHooksDirs returns; finding
// its script proves the provided snapshot was used instead.
const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-cache-test-"))
try {
const scriptPath = await createTestHook(otherDir, "PreToolUse", { cancel: false })
const otherHooksDir = path.dirname(scriptPath)
resetHookCache()
const scripts = await HookDiscoveryCache.getInstance().get("PreToolUse", [otherHooksDir])
scripts.should.eql([scriptPath])
} finally {
await fs.rm(otherDir, { recursive: true, force: true })
}
})
it("falls back to getAllHooksDirs when no snapshot is provided", async () => {
const scriptPath = await createTestHook(env.tempDir, "PreToolUse", { cancel: false })
resetHookCache()
const scripts = await HookDiscoveryCache.getInstance().get("PreToolUse")
scripts.should.eql([scriptPath])
})
})
@@ -4,7 +4,7 @@ import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { setDistinctId } from "@/services/logging/distinctId"
import { HookFactory } from "../hook-factory"
import { HookFactory, isPathWithin } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withPlatform, writeHookScriptForPlatform } from "./test-utils"
describe("Hook System", () => {
@@ -37,6 +37,8 @@ describe("Hook System", () => {
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
runner.isNoOp.should.be.true()
const result = await runner.run({
taskId: "test-task",
preToolUse: {
@@ -108,6 +110,8 @@ console.log(JSON.stringify({
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
runner.isNoOp.should.be.false()
const result = await runner.run({
taskId: "test-task",
preToolUse: {
@@ -686,4 +690,40 @@ console.log(JSON.stringify({
result.contextModification?.should.equal("Global observed: true")
})
})
describe("workspace root matching", () => {
it("isPathWithin requires a whole path-segment boundary", () => {
const root = path.join(path.sep, "repo", "app")
const sibling = path.join(path.sep, "repo", "app-web", "x")
const child = path.join(root, "x")
isPathWithin(root, root).should.be.true()
isPathWithin(root, child).should.be.true()
isPathWithin(root, sibling).should.be.false()
isPathWithin(root + path.sep, child).should.be.true()
})
it("determineHookCwd picks the owning root for prefix-sharing and nested roots", () => {
const factory = new HookFactory() as any
const app = path.join(path.sep, "repo", "app")
const appWeb = path.join(path.sep, "repo", "app-web")
const hooksDir = (root: string) => path.join(root, ".clinerules", "hooks")
const script = (root: string) => path.join(hooksDir(root), "PreToolUse")
// Prefix-sharing sibling roots: the hook must run from its own root,
// not the root that happens to be a string prefix of it.
factory.determineHookCwd(script(appWeb), [hooksDir(app), hooksDir(appWeb)], [app, appWeb]).should.equal(appWeb)
// Nested roots: the innermost matching root wins regardless of order.
const outer = path.join(path.sep, "repo")
const inner = path.join(path.sep, "repo", "packages", "x")
factory.determineHookCwd(script(inner), [hooksDir(outer), hooksDir(inner)], [outer, inner]).should.equal(inner)
// Global hooks (and unplaceable scripts) fall back to the primary root.
const globalScript = path.join(path.sep, "home", "Documents", "Cline", "Hooks", "PreToolUse")
factory
.determineHookCwd(globalScript, [path.join(path.sep, "home", "Documents", "Cline", "Hooks")], [app, appWeb])
.should.equal(app)
})
})
})
@@ -1,117 +0,0 @@
import { afterEach, beforeEach } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import sinon from "sinon"
import { StateManager } from "../../storage/StateManager"
import { createHooksDirectory } from "./test-utils"
/**
* Test environment containing temp directories and cleanup functions.
*/
export interface HookTestEnvironment {
/** Temporary directory for this test */
tempDir: string
/** Array of hooks directories (.clinerules/hooks paths) */
hooksDirs: string[]
/** Cleanup function to remove temp directories */
cleanup: () => Promise<void>
}
/**
* Creates a fresh test environment with temp directories.
* Automatically creates .clinerules/hooks structure.
*
* @returns Test environment with cleanup function
*
* @example
* const env = await createHookTestEnvironment()
* // Use env.tempDir, env.hooksDirs in tests
* await env.cleanup() // Clean up after tests
*/
export async function createHookTestEnvironment(): Promise<HookTestEnvironment> {
const tempDir = path.join(os.tmpdir(), `hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
const hooksDir = await createHooksDirectory(tempDir)
return {
tempDir,
hooksDirs: [hooksDir],
cleanup: async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error: any) {
// Only ignore ENOENT (already deleted), log other errors
if (error.code !== "ENOENT") {
console.warn(`Cleanup warning for ${tempDir}:`, error.message)
}
}
},
}
}
/**
* Standard setup for hook tests. Returns accessor to environment.
* Use in describe() blocks for automatic setup/teardown.
*
* @returns Object with getEnv() method to access test environment
*
* @example
* describe("My Hook Tests", () => {
* const { getEnv } = setupHookTests()
*
* it("should do something", async () => {
* const env = getEnv()
* // env.tempDir is ready to use
* })
* })
*/
export function setupHookTests(): {
getEnv: () => HookTestEnvironment
} {
let env: HookTestEnvironment
let sandbox: sinon.SinonSandbox
beforeEach(async () => {
sandbox = sinon.createSandbox()
env = await createHookTestEnvironment()
// Mock StateManager to return test workspace
mockStateManager(sandbox, [env.tempDir])
})
afterEach(async () => {
sandbox.restore()
await env.cleanup()
})
return {
getEnv: () => {
if (!env) {
throw new Error("Test environment not initialized. Called getEnv() outside of test?")
}
return env
},
}
}
/**
* Mocks StateManager to return test workspace roots.
* Useful for testing hook discovery across multiple workspace roots.
*
* @param sandbox Sinon sandbox for cleanup
* @param workspaceRoots Array of workspace root paths
*
* @example
* const sandbox = sinon.createSandbox()
* mockStateManager(sandbox, ["/path/to/workspace1", "/path/to/workspace2"])
* // StateManager.get().getGlobalStateKey("workspaceRoots") now returns mocked roots
* sandbox.restore() // Clean up after tests
*/
export function mockStateManager(sandbox: sinon.SinonSandbox, workspaceRoots: string[]): void {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => workspaceRoots.map((rootPath) => ({ path: rootPath })),
} as any)
}
@@ -6,9 +6,8 @@ import should from "should"
import sinon from "sinon"
import { HostProvider } from "../../../hosts/host-provider"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { setVscodeHostProviderMock } from "../../../test/host-provider-test-utils"
import { setVscodeHostProviderMock, stubWorkspacePaths } from "../../../test/host-provider-test-utils"
import * as diskModule from "../../storage/disk"
import { StateManager } from "../../storage/StateManager"
import { HookDiscoveryCache } from "../HookDiscoveryCache"
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
@@ -97,17 +96,7 @@ export async function createHookTestEnv(): Promise<HookTestEnv> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
const hooksDir = await createHooksDirectory(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: tempDir }]
}
if (key === "primaryRootIndex") {
return 0
}
return undefined
},
} as any)
stubWorkspacePaths(sandbox, [tempDir])
resetHookCache()
stubHookDirs(sandbox, [hooksDir])
+96 -99
View File
@@ -18,8 +18,7 @@ import {
TaskStartData,
UserPromptSubmitData,
} from "../../shared/proto/cline/hooks"
import { getAllHooksDirs } from "../storage/disk"
import { StateManager } from "../storage/StateManager"
import { getAllHooksDirs, getWindowWorkspaceRoots } from "../storage/disk"
import { HookExecutionError } from "./HookError"
import { HookProcess } from "./HookProcess"
@@ -163,7 +162,23 @@ const exec = Symbol()
* - Results are immediately consumed and added to the conversation context
*/
export abstract class HookRunner<Name extends HookName> {
constructor(public readonly hookName: Name) {}
/**
* @param workspaceRoots This window's workspace root paths, resolved when
* the runner was created. Kept alongside the runner so hook input metadata
* matches the roots used for discovery and cwd selection.
*/
constructor(
public readonly hookName: Name,
protected readonly workspaceRoots?: string[],
) {}
/**
* True when no hook scripts were discovered and run() is a no-op. Callers
* use this to skip status reporting for hooks that don't exist.
*/
get isNoOp(): boolean {
return false
}
/**
* Execute the hook with the given parameters.
@@ -196,10 +211,7 @@ export abstract class HookRunner<Name extends HookName> {
* @returns Complete HookInput ready to be serialized and sent to the hook script
*/
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
const workspaceRoots =
StateManager.get()
.getGlobalStateKey("workspaceRoots")
?.map((root) => root.path) || []
const workspaceRoots = this.workspaceRoots ?? (await getWindowWorkspaceRoots())
const model: HookModelContext = {
provider: params.model?.provider?.trim() || "unknown",
@@ -228,6 +240,10 @@ export abstract class HookRunner<Name extends HookName> {
* @template Name The type of hook this runner represents
*/
class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
override get isNoOp(): boolean {
return true
}
/**
* Executes a no-op hook that always succeeds.
* @param _ Hook input (ignored)
@@ -282,8 +298,9 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
private readonly taskId?: string,
private readonly toolName?: string,
private readonly cwd?: string,
workspaceRoots?: string[],
) {
super(hookName)
super(hookName, workspaceRoots)
}
override async [exec](input: HookInput): Promise<HookOutput> {
@@ -652,8 +669,9 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
private readonly runners: readonly HookRunner<Name>[],
workspaceRoots?: string[],
) {
super(hookName)
super(hookName, workspaceRoots)
}
override async [exec](input: HookInput): Promise<HookOutput> {
@@ -717,13 +735,26 @@ function isExpectedHookError(error: unknown): boolean {
return false
}
/**
* True when `child` is the same path as `parent` or inside it. A plain
* startsWith check would treat /a/b as containing /a/bc, so require the match
* to end on a whole path segment.
*/
export function isPathWithin(parent: string, child: string): boolean {
if (child === parent) {
return true
}
const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep
return child.startsWith(prefix)
}
export class HookFactory {
/**
* @param options.sessionWorkspaceRoot The workspace root of the session the
* hooks run for. Discovery additionally scans this root's .clinerules/hooks:
* the global `workspaceRoots` state is shared across every Cline instance,
* so another window can repoint it and workspace hooks would silently stop
* being discovered without this session-scoped fallback.
* hooks run for. Discovery additionally scans this root's .clinerules/hooks
* and hooks discovered there execute from it: the session's root is not
* always among the window's workspace folders (e.g. the chat-workspace
* fallback when no folder is open).
*/
constructor(private readonly options?: { sessionWorkspaceRoot?: string }) {}
@@ -742,34 +773,11 @@ export class HookFactory {
}
/**
* Get information about discovered hooks including their script paths
* @param hookName The type of hook to query
* @returns Object containing array of script paths
*/
async getHookInfo<Name extends HookName>(
hookName: Name,
): Promise<{
scriptPaths: string[]
}> {
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
return { scriptPaths: scripts }
}
/**
* Check if any hook scripts exist for the given hook name
* @returns true if at least one hook script exists, false otherwise
*/
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
const scripts = await HookFactory.findHookScripts(hookName)
if (scripts.length > 0) {
return true
}
return (await this.findSessionScripts(hookName)).length > 0
}
/**
* Create a hook runner without streaming support (backwards compatible)
* Create a hook runner without streaming support (backwards compatible).
* Check the returned runner's isNoOp to learn whether any hook scripts
* exist creation is the single point where workspace roots and hook
* discovery are resolved, so there is no separate existence check that
* could disagree with what the runner will execute.
*/
async create<Name extends HookName>(hookName: Name, taskId?: string, toolName?: string): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName, undefined, undefined, taskId, toolName)
@@ -802,15 +810,26 @@ export class HookFactory {
taskId?: string,
toolName?: string,
): Promise<HookRunner<Name>> {
// Resolve this window's workspace roots once, then reuse the same set
// for hooks-dir discovery, cwd selection, and hook input metadata so
// they can't disagree. The first workspace folder is the primary root;
// the session's root joins the set when it isn't a window folder, so
// session-discovered hooks execute from their own workspace.
const windowRoots = await getWindowWorkspaceRoots()
const sessionRoot = this.options?.sessionWorkspaceRoot
const workspaceRoots = sessionRoot && !windowRoots.includes(sessionRoot) ? [...windowRoots, sessionRoot] : windowRoots
const hooksDirs = await getAllHooksDirs(windowRoots)
// Use cache for hook discovery instead of direct file system scan,
// unioned with a session-scoped scan (see constructor doc).
// giving it the window-scoped hooks-dir snapshot for cache misses. The
// session dir is scanned outside the cache: the cache is a process-wide
// singleton, so per-session results must not seed it.
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const cachedScripts = await HookDiscoveryCache.getInstance().get(hookName)
const cachedScripts = await HookDiscoveryCache.getInstance().get(hookName, hooksDirs)
const sessionScripts = await this.findSessionScripts(hookName)
const scripts = [...new Set([...cachedScripts, ...sessionScripts])]
// Fetch hooks dirs once for source determination and telemetry
const hooksDirs = await getAllHooksDirs()
const sessionDir = this.sessionHooksDir()
if (sessionDir && !hooksDirs.includes(sessionDir)) {
hooksDirs.push(sessionDir)
@@ -826,31 +845,29 @@ export class HookFactory {
)
}
// Get workspace roots for cwd determination; the session root joins
// them so session-discovered hooks execute from their own workspace.
const stateManager = StateManager.get()
const storedRoots = stateManager.getGlobalStateKey("workspaceRoots")
const sessionRoot = this.options?.sessionWorkspaceRoot
const workspaceRoots =
sessionRoot && !storedRoots?.some((root) => root.path === sessionRoot)
? [...(storedRoots ?? []), { path: sessionRoot }]
: storedRoots
const primaryRootIndex = stateManager.getGlobalStateKey("primaryRootIndex") ?? 0
const primaryCwd = storedRoots?.[primaryRootIndex]?.path ?? sessionRoot
// Create runners with source and cwd determination for each script
// Global hooks run from primary workspace root
// Workspace-specific hooks run from their respective workspace root
const runners = scripts.map((script) => {
const source = this.determineScriptSource(script, hooksDirs)
const cwd = this.determineHookCwd(script, hooksDirs, workspaceRoots, primaryCwd)
return new StdioHookRunner(hookName, script, source, streamCallback, abortSignal, taskId, toolName, cwd)
const cwd = this.determineHookCwd(script, hooksDirs, workspaceRoots)
return new StdioHookRunner(
hookName,
script,
source,
streamCallback,
abortSignal,
taskId,
toolName,
cwd,
workspaceRoots,
)
})
if (runners.length === 0) {
return new NoOpRunner(hookName)
return new NoOpRunner(hookName, workspaceRoots)
}
return runners.length === 1 ? runners[0] : new CombinedHookRunner(hookName, runners)
return runners.length === 1 ? runners[0] : new CombinedHookRunner(hookName, runners, workspaceRoots)
}
/**
@@ -865,7 +882,7 @@ export class HookFactory {
* Determines if a single script is from global or workspace location
*/
private determineScriptSource(scriptPath: string, hooksDirs: string[]): "global" | "workspace" {
const containingDir = hooksDirs.find((dir) => scriptPath.startsWith(dir))
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, scriptPath))
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
return "global"
}
@@ -883,35 +900,29 @@ export class HookFactory {
*
* @param scriptPath The full path to the hook script
* @param hooksDirs Array of all hooks directories
* @param workspaceRoots Array of workspace root objects
* @param primaryCwd The primary workspace root path (fallback)
* @param workspaceRoots Array of workspace root paths; the first entry is
* the primary root
* @returns The working directory to use for this hook
*/
private determineHookCwd(
scriptPath: string,
hooksDirs: string[],
workspaceRoots: Array<{ path: string }> | undefined,
primaryCwd: string | undefined,
): string | undefined {
const containingDir = hooksDirs.find((dir) => scriptPath.startsWith(dir))
private determineHookCwd(scriptPath: string, hooksDirs: string[], workspaceRoots: string[]): string | undefined {
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, scriptPath))
// If global hook, use primary workspace root
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
return primaryCwd
}
// If workspace hook, find which workspace root it belongs to
// Workspace hooks are at: workspaceRoot/.clinerules/hooks/
// So find the workspace root whose path is a prefix of the containing hooks dir
if (containingDir && workspaceRoots) {
const workspaceRoot = workspaceRoots.find((root) => containingDir.startsWith(root.path))
// If workspace hook, find which workspace root it belongs to.
// Workspace hooks are at: workspaceRoot/.clinerules/hooks/. Prefer the
// longest matching root so nested workspace roots resolve to the
// innermost one.
if (containingDir && !HookFactory.isGlobalHooksDir(containingDir)) {
const workspaceRoot = [...workspaceRoots]
.sort((a, b) => b.length - a.length)
.find((root) => isPathWithin(root, containingDir))
if (workspaceRoot) {
return workspaceRoot.path
return workspaceRoot
}
}
// Fallback to primary cwd
return primaryCwd
// Global hooks (and any script we can't place) run from the primary
// workspace root.
return workspaceRoots[0]
}
/**
@@ -932,7 +943,7 @@ export class HookFactory {
let workspaceCount = 0
for (const script of scripts) {
const containingDir = hooksDirs.find((dir) => script.startsWith(dir))
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, script))
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
globalCount++
} else {
@@ -943,20 +954,6 @@ export class HookFactory {
return { globalCount, workspaceCount }
}
/**
* @returns A list of paths to scripts for the given hook name.
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
* (from .clinerules/hooks/ in each workspace root).
*/
private static async findHookScripts(hookName: HookName): Promise<string[]> {
const hookScripts = []
for (const hooksDir of await getAllHooksDirs()) {
hookScripts.push(HookFactory.findHookInHooksDir(hookName, hooksDir))
}
const isDefined = (scriptPath: string | undefined): scriptPath is string => Boolean(scriptPath)
return (await Promise.all(hookScripts)).filter(isDefined)
}
/**
* Finds the path to a hook in a .clinerules hooks directory.
*
@@ -6,7 +6,7 @@ import os from "os"
import path from "path"
import sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { setVscodeHostProviderMock, stubWorkspacePaths } from "@/test/host-provider-test-utils"
// bun loads real ESM, so sinon cannot stub the `@utils/fs` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
@@ -19,8 +19,7 @@ const fsUtilsMock = () => ({ ...actualFsUtils, isDirectory: isDirectoryStub })
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
import { getAllHooksDirs, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
import { StateManager } from "../StateManager"
import { getAllHooksDirs, getWindowWorkspaceRoots, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
describe("disk - hooks functionality", () => {
let sandbox: sinon.SinonSandbox
@@ -47,20 +46,8 @@ describe("disk - hooks functionality", () => {
})
describe("getWorkspaceHooksDirs", () => {
it("should return empty array when no workspace roots exist", async () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => undefined,
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(0)
})
it("should return empty array when workspace roots is empty array", async () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
it("should return empty array when the window has no workspace folders", async () => {
stubWorkspacePaths(sandbox, [])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -72,9 +59,7 @@ describe("disk - hooks functionality", () => {
const workspaceRoot = path.join(tempDir, "workspace1")
await fs.mkdir(workspaceRoot, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -87,9 +72,7 @@ describe("disk - hooks functionality", () => {
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(hooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -104,9 +87,7 @@ describe("disk - hooks functionality", () => {
await fs.mkdir(path.dirname(hooksPath), { recursive: true })
await fs.writeFile(hooksPath, "not a directory")
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -123,9 +104,7 @@ describe("disk - hooks functionality", () => {
await fs.mkdir(hooksDir1, { recursive: true })
await fs.mkdir(hooksDir2, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot1, workspaceRoot2])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -146,9 +125,7 @@ describe("disk - hooks functionality", () => {
await fs.mkdir(workspaceRoot2, { recursive: true }) // No hooks dir
await fs.mkdir(hooksDir3, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }, { path: workspaceRoot3 }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot1, workspaceRoot2, workspaceRoot3])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -162,9 +139,7 @@ describe("disk - hooks functionality", () => {
const workspaceRoot = path.join(tempDir, "workspace1")
await fs.mkdir(workspaceRoot, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot])
// Stub isDirectory to throw an error
isDirectoryStub.rejects(new Error("Permission denied"))
@@ -183,9 +158,7 @@ describe("disk - hooks functionality", () => {
const expectedHooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(expectedHooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRoot])
const result = await getWorkspaceHooksDirs()
result[0].should.equal(expectedHooksDir)
@@ -199,9 +172,7 @@ describe("disk - hooks functionality", () => {
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(hooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRootWithSlash }],
} as any)
stubWorkspacePaths(sandbox, [workspaceRootWithSlash])
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
@@ -210,15 +181,34 @@ describe("disk - hooks functionality", () => {
})
})
describe("getWindowWorkspaceRoots", () => {
it("should filter out blank workspace paths", async () => {
const workspaceRoot = path.join(tempDir, "workspace1")
stubWorkspacePaths(sandbox, ["", " ", workspaceRoot])
const result = await getWindowWorkspaceRoots()
result.should.eql([workspaceRoot])
})
it("should return empty array when the host lookup fails", async () => {
sandbox.stub(HostProvider, "workspace").get(() => ({
getWorkspacePaths: async () => {
throw new Error("host bridge unavailable")
},
}))
const result = await getWindowWorkspaceRoots()
result.should.eql([])
})
})
describe("getAllHooksDirs", () => {
it("should include the runtime hooks directory when it exists", async () => {
const runtimeHooksDir = path.join(tempDir, "runtime-hooks")
await fs.mkdir(runtimeHooksDir, { recursive: true })
sandbox.stub(os, "homedir").returns(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
stubWorkspacePaths(sandbox, [])
isDirectoryStub.callsFake(async (targetPath: string) => targetPath === runtimeHooksDir)
@@ -232,9 +222,7 @@ describe("disk - hooks functionality", () => {
const runtimeHooksDir = path.join(tempDir, "missing-runtime-hooks")
sandbox.stub(os, "homedir").returns(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
stubWorkspacePaths(sandbox, [])
isDirectoryStub.resolves(false)
+23 -8
View File
@@ -9,7 +9,6 @@ import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { getDocumentsPath } from "./documents-path"
import { StateManager } from "./StateManager"
export { getDocumentsPath } from "./documents-path"
@@ -298,7 +297,7 @@ export function setRuntimeHooksDir(dir: string | undefined): void {
* A workspace may not use hooks, and the resulting array will be empty. A
* multi-root workspace may have multiple hooks directories.
*/
export async function getAllHooksDirs(): Promise<string[]> {
export async function getAllHooksDirs(workspaceRootPaths?: string[]): Promise<string[]> {
const hooksDirs: string[] = []
// Add runtime hooks directory (set by --hooks-dir CLI flag)
@@ -313,22 +312,38 @@ export async function getAllHooksDirs(): Promise<string[]> {
}
// Add workspace hooks directories
const workspaceHooksDirs = await getWorkspaceHooksDirs()
const workspaceHooksDirs = await getWorkspaceHooksDirs(workspaceRootPaths)
hooksDirs.push(...workspaceHooksDirs)
return hooksDirs
}
/**
* Resolves this window's workspace root paths for hook discovery. Workspace
* identity must come from the window's host, not global state: ~/.cline global
* state is shared across every Cline instance, so a persisted value can point
* at another window's project.
*
* Blank entries are filtered out, and a host lookup failure degrades to no
* workspace roots (global and runtime hooks don't depend on them).
*/
export async function getWindowWorkspaceRoots(): Promise<string[]> {
try {
const { paths } = await HostProvider.workspace.getWorkspacePaths({})
return paths.filter((workspacePath) => workspacePath.trim().length > 0)
} catch (error) {
Logger.error("Failed to resolve workspace paths for hooks", error)
return []
}
}
/**
* Gets the paths to the workspace's .clinerules/hooks directories to search for
* hooks. A workspace may not use hooks, and the resulting array will be empty. A
* multi-root workspace may have multiple hooks directories.
*/
export async function getWorkspaceHooksDirs(): Promise<string[]> {
const workspaceRootPaths =
StateManager.get()
.getGlobalStateKey("workspaceRoots")
?.map((root) => root.path) || []
export async function getWorkspaceHooksDirs(workspaceRootPaths?: string[]): Promise<string[]> {
workspaceRootPaths ??= await getWindowWorkspaceRoots()
return (
await Promise.all(
@@ -37,4 +37,4 @@ export async function getDocumentsPath(): Promise<string> {
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
}
}
+1 -1
View File
@@ -368,7 +368,7 @@ export class Controller {
},
shouldAutoApproveTool: (request) => {
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
return autoApprovalSettings ? isToolAutoApproved(request.toolName, autoApprovalSettings, this.mcpHub) : false
return autoApprovalSettings ? isToolAutoApproved(request.toolName, autoApprovalSettings) : false
},
getCwd: () => this.lastKnownWorkspaceRoot,
})
+57 -1
View File
@@ -130,18 +130,26 @@ vi.mock("@cline/core", async () => ({
loginOpenAICodex: vi.fn(),
refreshClineToken: vi.fn(),
getValidClineCredentials: vi.fn(),
// Mirrors the SDK registry: cline-pass stores credentials under "cline".
getProviderAuthStorageId: (providerId: string) =>
providerId === "cline" || providerId === "cline-pass" ? "cline" : undefined,
}))
// Stateful in-memory provider-settings store. Cline credentials are persisted
// to providers.json (via the SDK's ProviderSettingsManager), not to secrets, so
// the credential round-trip tests exercise this store.
const mockProviderSettings = new Map<string, Record<string, unknown>>()
let mockLastUsedProvider: string | undefined
vi.mock("./provider-migration", () => ({
getProviderSettingsManager: () => ({
getProviderSettings: (provider: string) => mockProviderSettings.get(provider),
saveProviderSettings: (settings: Record<string, unknown>) => {
read: () => ({ lastUsedProvider: mockLastUsedProvider }),
saveProviderSettings: (settings: Record<string, unknown>, options?: { setLastUsed?: boolean }) => {
const provider = settings.provider as string
mockProviderSettings.set(provider, { ...settings })
if (options?.setLastUsed !== false) {
mockLastUsedProvider = provider
}
},
}),
}))
@@ -237,6 +245,7 @@ describe("AuthService", () => {
mockSecrets.clear()
mockGlobalState.clear()
mockProviderSettings.clear()
mockLastUsedProvider = undefined
vi.clearAllMocks()
})
@@ -590,6 +599,53 @@ describe("AuthService", () => {
})
})
it("keeps a ClinePass last-used selection when credentials are refreshed on restore (#13501)", async () => {
mockLastUsedProvider = "cline-pass"
mockProviderSettings.set("cline", {
provider: "cline",
auth: {
accessToken: "workos:persisted-access-token",
refreshToken: "persisted-refresh-token",
accountId: "user-123",
},
})
vi.mocked(getValidClineCredentials).mockResolvedValue({
access: "refreshed-access-token",
refresh: "refreshed-refresh-token",
expires: Date.now() + 3600 * 1000,
accountId: "user-123",
email: "test@example.com",
})
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
expect(testAccess(authService)._authenticated).toBe(true)
expect(mockLastUsedProvider).toBe("cline-pass")
})
it("claims the last-used slot on restore when no cline-backed provider is selected", async () => {
mockLastUsedProvider = undefined
mockProviderSettings.set("cline", {
provider: "cline",
auth: {
accessToken: "workos:persisted-access-token",
refreshToken: "persisted-refresh-token",
accountId: "user-123",
},
})
vi.mocked(getValidClineCredentials).mockResolvedValue({
access: "refreshed-access-token",
refresh: "refreshed-refresh-token",
expires: Date.now() + 3600 * 1000,
accountId: "user-123",
email: "test@example.com",
})
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
expect(mockLastUsedProvider).toBe("cline")
})
it("sets unauthenticated state when providers.json has no Cline auth", async () => {
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
+10 -1
View File
@@ -11,6 +11,7 @@
import type { ITelemetryService, OAuthCredentials, ProviderSettings } from "@cline/core"
import {
createOAuthClientCallbacks,
getProviderAuthStorageId,
getValidClineCredentials,
hashSecret,
loginClineOAuth,
@@ -184,13 +185,21 @@ function writeClineCredentials(credentials: {
auth.expiresAt = credentials.expiresAt
}
// Token refreshes and startup restores also land here. Only claim the
// last-used slot when the current selection isn't already a provider
// backed by this credential entry (cline-pass stores under "cline") —
// otherwise every refresh resets a ClinePass selection back to
// usage-billing in the shared providers.json (#13501).
const lastUsedProvider = manager.read().lastUsedProvider
const setLastUsed = getProviderAuthStorageId(lastUsedProvider ?? "") !== "cline"
manager.saveProviderSettings(
{
...(existing ?? { provider: "cline" }),
provider: "cline",
auth: auth as { accessToken?: string; refreshToken?: string; accountId?: string; metadata?: AuthMetadata },
},
{ tokenSource: "oauth", setLastUsed: true },
{ tokenSource: "oauth", setLastUsed },
)
sdkDebug(
`[SdkAuthService] writeClineCredentials: wrote (accessHash=${hashSecret(credentials.accessToken)}, refreshHash=${hashSecret(credentials.refreshToken)}, expiresAt=${credentials.expiresAt}, sessionStartedAtMs=${sessionStartedAtMs})`,
@@ -908,6 +908,35 @@ describe("buildSessionConfig", () => {
expect(knownModel.modalities).toEqual({ input: ["text", "image"], output: ["text", "image"] })
})
it("defaults tool-calling on when the preserved capability list is defined but empty", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
actModeOpenRouterModelId: "mock/empty-capabilities-model",
openRouterApiKey: "openrouter-key",
// A capabilities field that round-tripped through a boundary
// defaulting the missing array to [] — same "no signal" state as
// an absent one (modelHasCapability treats both as unspecified).
// Before the fix, the strict `=== undefined` guard skipped the
// tools seeding, supportsReasoning populated the array, and the
// runtime gate silently dropped every tool definition (#13463).
actModeOpenRouterModelInfo: {
name: "Empty Capabilities Model",
contextWindow: 16_000,
// Required by the store's isModelInfo gate: without a boolean
// supportsPromptCache the state snapshot is rejected and the
// model never reaches knownModels at all.
supportsPromptCache: false,
supportsReasoning: true,
capabilities: [],
},
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["mock/empty-capabilities-model"]
expect(knownModel.capabilities).toEqual(expect.arrayContaining(["reasoning", "tools"]))
})
it("keeps legacy supportsTools=false authoritative for dynamic-list models", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
+9 -1
View File
@@ -258,13 +258,21 @@ function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
setCapability("prompt-cache", modelInfo.supportsPromptCache)
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
if (preservedCapabilities === undefined) {
if (preservedCapabilities === undefined || preservedCapabilities.length === 0) {
// No authoritative SDK list survived to here (dynamic-list snapshot,
// fallback metadata, or a custom model). The array we are rebuilding
// from booleans must still carry a definitive tool-calling signal,
// because a non-empty capabilities array without "tools" reads as
// "cannot call tools" to the SDK runtime. Legacy metadata only models
// tool support for OpenAI-compatible entries via `supportsTools`.
//
// An EMPTY array is the same "no signal" state as an absent one —
// modelHasCapability treats both as unspecified — and configs carried
// over from before the field existed (or round-tripped through a
// boundary that defaults it to []) land exactly here. Guarding only
// `undefined` let those custom models keep a non-empty, tool-less
// array once any boolean projection (e.g. reasoning) populated it,
// silently disabling tool calling at the runtime gate (#13463).
const supportsTools = (modelInfo as { supportsTools?: boolean }).supportsTools
setCapability("tools", supportsTools !== false)
}
+13 -14
View File
@@ -100,9 +100,9 @@ export function buildAgentHooks(
sessionWorkspaceRoot?: string,
): AgentHooks {
const hooksEnabled = () => getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled"))
// Session-scoped discovery: the shared workspaceRoots global state can be
// repointed by another Cline instance, so the factory also scans this
// session's own workspace for hook files.
// Session-scoped discovery: the session's root is not always among the
// window's workspace folders (e.g. the chat-workspace fallback when no
// folder is open), so the factory also scans this session's own workspace.
const createFactory = () => new HookFactory({ sessionWorkspaceRoot })
return {
@@ -124,7 +124,8 @@ export function buildAgentHooks(
}
const factory = createFactory()
if (!(await factory.hasHook("PreToolUse"))) {
const runner = await factory.create("PreToolUse")
if (runner.isNoOp) {
return undefined
}
@@ -133,7 +134,6 @@ export function buildAgentHooks(
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
preToolUse: {
@@ -183,7 +183,8 @@ export function buildAgentHooks(
}
const factory = createFactory()
if (!(await factory.hasHook("PostToolUse"))) {
const runner = await factory.create("PostToolUse")
if (runner.isNoOp) {
return undefined
}
@@ -192,7 +193,6 @@ export function buildAgentHooks(
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("PostToolUse")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
postToolUse: {
@@ -254,7 +254,8 @@ export function buildAgentHooks(
}
const factory = createFactory()
if (!(await factory.hasHook(hookName))) {
const runner = await factory.create(hookName)
if (runner.isNoOp) {
return
}
@@ -264,7 +265,6 @@ export function buildAgentHooks(
emitHookMessage?.(runningMsg)
if (hookName === "TaskComplete") {
const runner = await factory.create("TaskComplete")
await runner.run({
taskId,
taskComplete: {
@@ -277,7 +277,6 @@ export function buildAgentHooks(
},
})
} else {
const runner = await factory.create("TaskCancel")
await runner.run({
taskId,
taskCancel: {
@@ -315,7 +314,8 @@ async function runTaskStart(
}
const factory = createFactory()
if (!(await factory.hasHook("TaskStart"))) {
const runner = await factory.create("TaskStart")
if (runner.isNoOp) {
return undefined
}
@@ -324,7 +324,6 @@ async function runTaskStart(
emitHookMessage?.(runningMsg)
const taskId = taskIdFromSnapshot(ctx.snapshot)
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId,
taskStart: {
@@ -364,7 +363,8 @@ async function runUserPromptSubmit(
}
const factory = createFactory()
if (!(await factory.hasHook("UserPromptSubmit"))) {
const runner = await factory.create("UserPromptSubmit")
if (runner.isNoOp) {
return undefined
}
@@ -372,7 +372,6 @@ async function runUserPromptSubmit(
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
userPromptSubmit: {
@@ -490,7 +490,11 @@ describe("createProviderConfigStore", () => {
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
capabilities: ["prompt-cache"],
// "tools" must always ride along: legacy ModelInfo carries no
// tool-calling boolean, and a persisted capability list without
// "tools" reads as authoritative "cannot call tools" to the SDK
// runtime (#13463).
capabilities: ["tools", "prompt-cache"],
supportsVision: false,
supportsReasoning: true,
inputPrice: 1,
+7 -1
View File
@@ -731,7 +731,13 @@ function legacyModelInfoToOverrides(modelInfo: ModelInfo, fallback: ModelInfo):
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
const capabilities: string[] = []
// Legacy ModelInfo has no tool-calling boolean, so this projection
// carries no "no tools" signal. Persisting the list without "tools"
// would read as an authoritative tool-less capability list to the SDK
// runtime once stored in models.json, silently disabling tool calling
// (#13463). Match the providers.json migration, which always writes
// "tools" for OpenAI-compatible custom models.
const capabilities: string[] = ["tools"]
if (supportsVision) capabilities.push("images")
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
overrides.capabilities = capabilities
@@ -19,4 +19,22 @@ describe("isToolAutoApproved", () => {
expect(isToolAutoApproved("run_commands", settings)).toBe(false)
})
it("auto-approves all MCP tools when the Use MCP servers toggle is on", () => {
const settings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
actions: { ...DEFAULT_AUTO_APPROVAL_SETTINGS.actions, useMcp: true },
}
expect(isToolAutoApproved("firecrawl__scrape", settings)).toBe(true)
})
it("prompts for MCP tools when the Use MCP servers toggle is off", () => {
const settings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
actions: { ...DEFAULT_AUTO_APPROVAL_SETTINGS.actions, useMcp: false },
}
expect(isToolAutoApproved("firecrawl__scrape", settings)).toBe(false)
})
})
+8 -16
View File
@@ -5,7 +5,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
* Build SDK `toolPolicies` for tools governed by Cline's auto-approval UI.
*
* The SDK defaults unlisted tools to auto-approved. For tools controlled by
* AutoApproveBar/MCP per-tool settings, force the SDK to call
* the AutoApproveBar toggles (including all MCP tools), force the SDK to call
* `requestToolApproval`; the approval callback then evaluates the latest
* settings and either silently approves or shows the approval UI. This keeps
* active sessions in sync when the user toggles auto-approval mid-task.
@@ -45,7 +45,7 @@ export function buildToolPolicies(
* approval callback, so changes from the AutoApproveBar are respected even if
* an SDK session was created before the toggle changed.
*/
export function isToolAutoApproved(toolName: string, settings: AutoApprovalSettings, mcpHub?: McpHub): boolean {
export function isToolAutoApproved(toolName: string, settings: AutoApprovalSettings): boolean {
if (isReadTool(toolName)) {
return !!settings.actions.readFiles
}
@@ -59,14 +59,9 @@ export function isToolAutoApproved(toolName: string, settings: AutoApprovalSetti
return !!settings.actions.useBrowser
}
const mcpTool = parseMcpToolName(toolName)
if (mcpTool) {
if (!settings.actions.useMcp || !mcpHub) {
return false
}
const server = mcpHub.getServers().find((entry) => entry.name === mcpTool.serverName)
const tool = server?.tools?.find((entry) => entry.name === mcpTool.toolName)
return !!tool?.autoApprove
if (isMcpToolName(toolName)) {
// One global toggle governs all MCP tools; there is no per-tool opt-in.
return !!settings.actions.useMcp
}
return false
@@ -90,11 +85,8 @@ function isBrowserTool(toolName: string): boolean {
return toolName === "fetch_web_content" || toolName === "web_fetch" || toolName === "web_search"
}
function parseMcpToolName(toolName: string): { serverName: string; toolName: string } | undefined {
/** MCP tools are registered by `createMcpTools` under `serverName__toolName`. */
function isMcpToolName(toolName: string): boolean {
const separatorIndex = toolName.indexOf("__")
if (separatorIndex <= 0) return undefined
const serverName = toolName.substring(0, separatorIndex)
const mcpToolName = toolName.substring(separatorIndex + 2)
if (!mcpToolName) return undefined
return { serverName, toolName: mcpToolName }
return separatorIndex > 0 && separatorIndex + 2 < toolName.length
}
@@ -13,7 +13,6 @@ import { DEFAULT_FOCUS_CHAIN_SETTINGS, FocusChainSettings } from "@shared/FocusC
import { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@shared/McpDisplayMode"
import { toLegacyApiProvider } from "@shared/model-catalog/provider-helpers"
import { WorkspaceRoot } from "@shared/multi-root/types"
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
import { Mode } from "@shared/storage/types"
import { TelemetrySetting } from "@shared/TelemetrySetting"
@@ -83,8 +82,6 @@ const GLOBAL_STATE_FIELDS = {
isNewUser: { default: true as boolean },
welcomeViewCompleted: { default: undefined as boolean | undefined },
mcpDisplayMode: { default: DEFAULT_MCP_DISPLAY_MODE as McpDisplayMode },
workspaceRoots: { default: undefined as WorkspaceRoot[] | undefined },
primaryRootIndex: { default: 0 as number },
multiRootEnabled: { default: true as boolean },
lastDismissedInfoBannerVersion: { default: 0 as number },
lastDismissedModelBannerVersion: { default: 0 as number },
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// E2E fixture hook: records where it ran and what workspace identity it was
// given, so the test can assert workspace-scoped discovery and cwd selection.
const fs = require("fs")
const input = JSON.parse(fs.readFileSync(0, "utf-8"))
fs.writeFileSync("hook-ran.json", JSON.stringify({ cwd: process.cwd(), workspaceRoots: input.workspaceRoots }))
console.log(JSON.stringify({ cancel: false }))
@@ -0,0 +1,6 @@
# E2E fixture hook (Windows): records where it ran and what workspace identity
# it was given, so the test can assert workspace-scoped discovery and cwd.
$ErrorActionPreference = 'Stop'
$hookInput = [Console]::In.ReadToEnd() | ConvertFrom-Json
@{ cwd = (Get-Location).Path; workspaceRoots = $hookInput.workspaceRoots } | ConvertTo-Json -Compress | Set-Content -Path "hook-ran.json"
Write-Output '{"cancel": false}'
@@ -0,0 +1 @@
hook-ran.json
@@ -0,0 +1,2 @@
# Dedicated e2e fixture for hooks.test.ts — keep hooks out of the shared
# workspace fixture so other specs don't pay hook spawn cost per prompt.
+65
View File
@@ -0,0 +1,65 @@
import { expect } from "@playwright/test"
import fs from "fs/promises"
import path from "path"
import { e2e, E2ETestHelper } from "./utils/helpers"
// This spec runs against its own fixture workspace: hooks execute on every
// prompt once discovered (hooksEnabled defaults to true), so keeping the hook
// out of the shared workspace fixture spares every other prompt-sending spec
// the per-prompt hook spawn (a cold PowerShell start on Windows).
const hooksE2e = e2e.extend({
workspaceDir: async ({}, use) => {
await use(path.join(E2ETestHelper.E2E_TESTS_DIR, "fixtures", "workspace-hooks"))
},
})
// The fixture ships a UserPromptSubmit hook
// (.clinerules/hooks/UserPromptSubmit[.ps1]) that writes hook-ran.json into its
// working directory, recording process.cwd() and the workspaceRoots it received
// on stdin. Discovery, cwd selection, and hook input metadata all resolve from
// the window's actual workspace folders, so the marker must land in this
// window's workspace root and name it — regardless of what any other Cline
// instance recorded in shared state.
hooksE2e("Hooks - workspace hook runs from this window's workspace root", async ({ helper, sidebar, workspaceDir }) => {
const markerPath = path.join(workspaceDir, "hook-ran.json")
await fs.rm(markerPath, { force: true })
try {
await helper.signin(sidebar)
const inputbox = sidebar.getByTestId("chat-input")
await expect(inputbox).toBeVisible()
await inputbox.fill("Trigger the prompt hook")
await sidebar.getByTestId("send-button").click()
// The hook runs during beforeRun, ahead of the model call, so the
// marker exists by the time the mock response renders.
await expect(sidebar.getByText("mock Cline API response")).toBeVisible()
let markerRaw: string | undefined
await expect
.poll(
async () => {
markerRaw = await fs.readFile(markerPath, "utf-8").catch(() => undefined)
return markerRaw
},
{ message: "workspace hook should have written hook-ran.json into the workspace root" },
)
.toBeDefined()
const marker = JSON.parse(markerRaw ?? "{}")
// Normalize with realpath: macOS reports temp/workspace paths under
// /private while the fixture path may not carry the prefix.
const expectedRoot = await fs.realpath(workspaceDir)
expect(await fs.realpath(marker.cwd)).toBe(expectedRoot)
expect(Array.isArray(marker.workspaceRoots)).toBe(true)
const reportedRoots: string[] = []
for (const root of marker.workspaceRoots) {
reportedRoots.push(await fs.realpath(root))
}
expect(reportedRoots).toContain(expectedRoot)
} finally {
await fs.rm(markerPath, { force: true })
}
})
@@ -11,8 +11,7 @@ import { refreshHooks } from "../core/controller/file/refreshHooks"
import { toggleHook } from "../core/controller/file/toggleHook"
import { hookFileName } from "../core/hooks/__tests__/test-utils"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { StateManager } from "../core/storage/StateManager"
import { HostProvider } from "../hosts/host-provider"
import { stubWorkspacePaths } from "./host-provider-test-utils"
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
/**
@@ -26,8 +25,6 @@ describe("Hook Management Integration", () => {
let globalHooksDir: string
let workspaceHooksDir: string
let mockController: Controller
let stateManagerStub: sinon.SinonStub
let getWorkspacePathsStub: sinon.SinonStub
beforeEach(async () => {
// Reset the hook discovery cache before each test
@@ -48,23 +45,8 @@ describe("Hook Management Integration", () => {
},
} as any
// Mock StateManager to return test workspace
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: path.join(tempDir, "workspace") }]
}
return undefined
},
} as any)
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
getWorkspacePathsStub = sinon.stub().resolves({
paths: [path.join(tempDir, "workspace")],
})
sinon.stub(HostProvider, "workspace").value({
getWorkspacePaths: getWorkspacePathsStub,
})
// Resolve the test workspace as this window's workspace root
stubWorkspacePaths(sinon, [path.join(tempDir, "workspace")])
})
afterEach(async () => {
+3 -21
View File
@@ -11,8 +11,7 @@ import { refreshHooks } from "../core/controller/file/refreshHooks"
import { toggleHook } from "../core/controller/file/toggleHook"
import { hookFileName, withPlatform } from "../core/hooks/__tests__/test-utils"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { StateManager } from "../core/storage/StateManager"
import { HostProvider } from "../hosts/host-provider"
import { stubWorkspacePaths } from "./host-provider-test-utils"
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
/**
@@ -27,8 +26,6 @@ describe("Hook Management", () => {
let globalHooksDir: string
let workspaceHooksDir: string
let mockController: Controller
let stateManagerStub: sinon.SinonStub
let getWorkspacePathsStub: sinon.SinonStub
beforeEach(async () => {
// Reset the hook discovery cache before each test
@@ -49,23 +46,8 @@ describe("Hook Management", () => {
},
} as any
// Mock StateManager to return test workspace
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: path.join(tempDir, "workspace") }]
}
return undefined
},
} as any)
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
getWorkspacePathsStub = sinon.stub().resolves({
paths: [path.join(tempDir, "workspace")],
})
sinon.stub(HostProvider, "workspace").value({
getWorkspacePaths: getWorkspacePathsStub,
})
// Resolve the test workspace as this window's workspace root
stubWorkspacePaths(sinon, [path.join(tempDir, "workspace")])
})
afterEach(async () => {
@@ -1,3 +1,4 @@
import type sinon from "sinon"
import { CommentReviewControllerCreator, EditPreviewCreator, HostProvider, WebviewProviderCreator } from "@/hosts/host-provider"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
@@ -32,3 +33,13 @@ export function setVscodeHostProviderMock(options?: {
options?.globalStorageFsPath ?? "/mock/path/to/globalstorage",
)
}
/**
* Stubs HostProvider.workspace so code under test resolves the given paths as
* this window's workspace roots.
*/
export function stubWorkspacePaths(sandbox: sinon.SinonSandbox, paths: string[]): void {
sandbox.stub(HostProvider, "workspace").get(() => ({
getWorkspacePaths: async () => ({ paths }),
}))
}
@@ -10,6 +10,12 @@ type McpToolRowProps = {
serverName?: string
}
// Per-tool MCP auto-approve checkboxes are hidden for now: the SDK approval
// path (shared with the CLI and desktop app) is all-or-nothing via the global
// "Use MCP servers" toggle, so they would be no-ops. Flip this back on if the
// SDK ever gains per-tool approval granularity.
export const SHOW_MCP_PER_TOOL_AUTO_APPROVE = false
const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
const { autoApprovalSettings } = useExtensionState()
@@ -50,7 +56,7 @@ const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px", flexShrink: 0 }}></span>
<span style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis" }}>{tool.name}</span>
</div>
{serverName && autoApprovalSettings.actions.useMcp && (
{SHOW_MCP_PER_TOOL_AUTO_APPROVE && serverName && autoApprovalSettings.actions.useMcp && (
<VSCodeCheckbox
checked={tool.autoApprove ?? false}
data-tool={tool.name}
@@ -26,7 +26,7 @@ import { McpServiceClient } from "@/services/grpc-client"
import type { MarketplaceMcpMetadata } from "../ServersToggleList"
import McpPromptRow from "./McpPromptRow"
import McpResourceRow from "./McpResourceRow"
import McpToolRow from "./McpToolRow"
import McpToolRow, { SHOW_MCP_PER_TOOL_AUTO_APPROVE } from "./McpToolRow"
// constant JSX.Elements
const TimeoutOptions = [
@@ -328,7 +328,7 @@ const ServerRow = ({
<VSCodePanelView id="tools-view">
{server.tools && server.tools.length > 0 ? (
<div className="flex flex-col gap-2 w-full pt-2">
{server.name && autoApprovalSettings.actions.useMcp && (
{SHOW_MCP_PER_TOOL_AUTO_APPROVE && server.name && autoApprovalSettings.actions.useMcp && (
<VSCodeCheckbox
checked={server.tools.every((tool) => tool.autoApprove)}
className="mb-1 text-xs"
+8 -6
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.56",
"version": "3.0.58",
"bin": {
"cline": "src/index.ts",
},
@@ -219,6 +219,7 @@
"next": "16.2.11",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -635,7 +636,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.77",
"version": "0.0.79",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -644,7 +645,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.77",
"version": "0.0.79",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -682,7 +683,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.77",
"version": "0.0.79",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.50",
"@ai-sdk/anthropic": "^4.0.36",
@@ -698,6 +699,7 @@
"@aws-sdk/credential-providers": "^3.922.0",
"@cline/shared": "workspace:*",
"@jerome-benoit/sap-ai-provider": "4.8.0",
"@langfuse/core": "5.10.1",
"@langfuse/otel": "5.10.1",
"@langfuse/vercel-ai-sdk": "5.9.1",
"@openrouter/ai-sdk-provider": "^3",
@@ -728,14 +730,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.77",
"version": "0.0.79",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.77",
"version": "0.0.79",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -4,7 +4,7 @@ sidebarTitle: "MCP Server Controls"
description: "Enterprise controls for MCP server allowlisting and remote MCP server management"
---
Cline no longer exposes an MCP Marketplace browse/install surface in the SDK-backed VS Code extension. For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and now govern MCP server access: local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers.
For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and govern MCP access: the MCP marketplace in the Customize view, local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers. `mcpMarketplaceEnabled: false` also hides the MCP marketplace, and `allowedMCPServers` also restricts which marketplace entries are visible and installable.
## Overview
+17 -6
View File
@@ -130,12 +130,16 @@ Completion telemetry is anchored to the assistant's explicit completion
declaration, not session shutdown. After each agent turn, the local
runtime inspects `AgentResult.toolCalls` and emits `task.completed` the
moment a successful `submit_and_exit` (the SDK analog of original
Cline's `attempt_completion`) is observed. `shutdownSession(...)`
retains a fallback emission for completed sessions that finished
without an explicit completion-tool observation, so non-interactive
runs not using the yolo preset still produce a `task.completed` signal.
Each session emits at most one `task.completed`. See `DOC.md` for the
event payload and `source` field.
Cline's `attempt_completion`) is observed. A single teardown choke
point (`emitTaskCompletedOnTeardown(...)`) retains a fallback emission
for sessions whose final turn finished cleanly without an explicit
completion-tool observation (non-interactive runs not using the yolo
preset, or hosts that disable `submit_and_exit`). It is invoked from
every session exit path — both `shutdownSession(...)` and
`releaseSessionRuntime(...)` — so the emission never depends on which
teardown branch a stop routes through. Each session emits at most one
`task.completed`. See `DOC.md` for the event payload and `source`
field.
### Hub-Backed Runtime
@@ -554,6 +558,13 @@ agent-team task board. Shared, browser-safe contracts use `AgendaTaskRecord`
and `AgendaTaskRunRecord`; orchestration and persistence remain in
`@cline/core`.
> **Status:** the agent-facing `kind: "todo"` half of the `tasks` tool and the
> desktop Agenda UI are temporarily disabled while the Agenda UX is reworked
> (`AGENDA_TODO_TOOL_ENABLED` in `hub-server-transport.ts` and
> `AGENDA_UI_ENABLED` in the desktop webview). The backend described below —
> the manager, storage, `task.*` Hub commands, and desktop plumbing — stays
> fully wired, and the schedule kind remains active.
### Authority and persistence
- A Hub process owns one Agenda task manager. Its
+13
View File
@@ -1,5 +1,18 @@
# Cline SDK Changelog
## 0.0.79
- The hub's durable event log is now capped at 64 MiB on disk. Event envelopes carrying full session snapshots could previously accumulate to tens of gigabytes, since row and time retention alone did not bound file size and deletes never shrink a SQLite file. Oldest events are dropped first and the file is vacuumed to return the space, and pruning now also runs after every 16 MiB appended instead of waiting for the hourly sweep
- Fixed `task.completed` telemetry being dropped for most interactive sessions. The event is now emitted from every session teardown path, exactly once per session
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
## 0.0.78
- The hub can now be drained and upgraded without losing work. A draining hub refuses new mutating work while it finishes what it is running, a durable event log lets a reconnecting client replay everything it missed, and durable runs are queued rather than dropped. Replayed events are deduped by event id, so an event that arrives on both the replay and the live stream is delivered once
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was synthesized from convenience flags like `supportsReasoning`. An inferred list read as an authoritative denial and stripped every tool from the request; explicitly authored capability lists still decide
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs. Hub sessions rebuild client name and version from request headers, and delegated agents group under their parent session instead of appearing as unattributed traces
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT). If you use one of those providers without pinning a model, you will get a different default
## 0.0.77
- The `tasks` tool (durable todos and one-time or recurring agent schedules) is now scoped to the clients that can service it. Hosts declare their client type and the core tool catalog resolves availability centrally, so CLI and VS Code sessions no longer register a tool they cannot act on; hub sessions resolve the same way from the requesting client's metadata
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.77",
"version": "0.0.79",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+3
View File
@@ -1026,6 +1026,9 @@ export class AgentRuntime {
const usageBeforeModel = cloneUsage(this.state.usage);
const modelRequestMetadata = omitUndefinedValues({
distinctId: trimNonEmpty(this.config.distinctId),
clientName: trimNonEmpty(this.config.clientName),
clientVersion: trimNonEmpty(this.config.clientVersion),
clineCoreVersion: trimNonEmpty(this.config.clineCoreVersion),
sessionId: trimNonEmpty(this.config.sessionId),
agentId: this.state.agentId,
conversationId: trimNonEmpty(this.config.conversationId),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.77",
"version": "0.0.79",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -131,6 +131,31 @@ describe("createApplyPatchExecutor", () => {
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("hello");
});
// The CRLF branch is exercised for real by Windows CI, where os.EOL is
// "\r\n" (github.com/cline/cline/issues/13504).
it("adds new files with the platform-native line ending", async () => {
const filePath = path.join(tempDir, "added.txt");
const execute = createApplyPatchExecutor();
await execute(
{
input: [
"*** Begin Patch",
"*** Add File: added.txt",
"+one",
"+two",
"*** End Patch",
].join("\n"),
},
tempDir,
{} as never,
);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
["one", "two"].join(os.EOL),
);
});
it("rejects incomplete patch sentinels", async () => {
const execute = createApplyPatchExecutor();
@@ -20,6 +20,7 @@ import {
PatchParser,
type PatchWarning,
} from "./apply-patch-parser";
import { normalizeNewFileLineEndings } from "./line-endings";
export interface PatchFileChange {
type: PatchActionType;
@@ -232,7 +233,7 @@ function patchToChanges(
}
changes[filePath] = {
type: PatchActionType.ADD,
newContent: action.newFile,
newContent: normalizeNewFileLineEndings(action.newFile),
};
break;
case PatchActionType.UPDATE:

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