Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 40c3a4dbd8 chore(desktop): release v0.0.19 2026-08-26 02:14:21 -07:00
Saoud Rizwan 6859d00e51 fix(hub): stop shipping full transcripts inside broadcast hub events (#13587)
* fix(hub): stop shipping full transcripts inside broadcast hub events

Every session.updated (and session.created/detached/run.started) event
embedded the session's ENTIRE message transcript via readCoreSessionSnapshot,
even though no consumer reads snapshot.messages off an event — clients fetch
messages with the session.messages command. For a multi-megabyte transcript
this turns every status flip into megabytes per subscriber, floods the
durable event log, and (until the send-queue backpressure fix lands) lets a
slow subscriber balloon the hub process by one full transcript copy per
event — reported as a 25GB cline process on a 16GB Mac.

Strip snapshot.messages centrally in HubServerTransport.publish() so every
current and future event publisher is covered, the event log stores slim
envelopes, and cursor replay stays byte-identical with live fan-out. All
other snapshot fields (status, usage, model, workspace, checkpoint) are kept,
and command replies are untouched.

* fix(hub): never capture the transcript into event/reply snapshots

Replaces the publish-boundary strip with the real fix: don't build
message-bearing snapshots in the first place. emitSessionSnapshot no longer
re-reads the entire transcript from disk on every status flip, and
readCoreSessionSnapshot no longer reads it for any event or reply — a
snapshot is a state notification (status, usage, model, workspace,
checkpoint); the transcript is fetched via the session.messages command.
Checkpoint-restore snapshots (session-versioning-service) are untouched:
restore replies carry messages in their own dedicated field.
2026-08-26 02:07:28 -07:00
Saoud Rizwan 6ba9b9d7b4 chore(cli): release v3.0.59 2026-08-26 01:49:10 -07:00
Saoud Rizwan 4c8cd98351 chore(sdk): release v0.0.80 2026-08-26 01:30:02 -07:00
Saoud Rizwan ebee8ca912 chore(vscode): release v4.1.16 2026-08-26 01:18:34 -07:00
Saoud Rizwan c0d6301884 chore(desktop): release v0.0.18 2026-08-26 00:56:19 -07:00
Saoud RizwanandSaoud Rizwan d71f097656 fix(desktop): install marketplace plugins and MCP servers in-process instead of spawning a cline binary (#13585)
* fix: install marketplace plugins and MCP servers in-process instead of spawning a cline binary

The desktop app sidecar and cline-hub shelled out to 'cline plugin install'
and 'cline mcp install' for marketplace installs. Packaged GUI apps inherit
launchd's minimal PATH on macOS and most desktop users have no cline CLI
installed at all, so installs failed with a red
'Executable not found in $PATH: "cline"' error.

Install via @cline/core's installPlugin/installMcpServer in-process instead,
matching what the VS Code extension already does. Also fix
parseMcpInstallArgs in @cline/core to treat the marketplace catalog's '--'
separator as end-of-options; previously the separator itself became the
stdio command.

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

* refactor: drop test-injection plumbing from marketplace installers

Call @cline/core's installPlugin directly instead of threading an
installer option through the marketplace entry points; tests stub the
core module instead.

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

* revert: keep cline-hub marketplace installs CLI-backed

The hub dashboard is launched via 'cline dashboard', so a CLI is always
present and CLINE_WRAPPER_PATH resolves it; the PATH bug only affects
the desktop app, which does not ship a CLI.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 00:14:55 -07:00
Saoud Rizwan 6539f4deea feat(desktop): hover trash button on sidebar session rows (#13582)
Each session row shows a trash icon on the right while hovered (or
when the button itself is focused), opening the same delete
confirmation dialog the row's context menu uses. The row is a button
and buttons cannot nest, so the trash is an absolutely positioned
sibling inside a group/row wrapper, overlaid where the timestamp sits:
row hover hides the timestamp, shows the trash, and moves the row's
hover background to the wrapper group so it holds while the pointer is
on the trash itself.
2026-08-26 00:01:00 -07:00
Saoud RizwanandSaoud Rizwan 036fc75b1f fix(desktop): don't block the main thread on quit while stopping the sidecar (#13566)
Quitting the mac app beach-balled for ~5-7s. The shutdown POST was
built from the ws transport URL (appending /shutdown lands inside the
query string), so the sidecar was never told to exit, and stop() then
polled the child for up to 7s on the main thread - on macOS inside
applicationWillTerminate - before SIGKILLing it.

stop() now sends SIGTERM and returns immediately. The sidecar handles
SIGTERM with the same bounded (5s) graceful shutdown as the /shutdown
endpoint and exits itself, finishing session persistence as an orphan.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-25 23:40:07 -07:00
Saoud Rizwan 6fc40127a6 feat(desktop): scheduled sessions UX — unified details dialog, run-now handoff, hidden steering, stuck-thinking fix (#13573)
* feat(desktop): merge schedule details into one view and open run-now sessions

The schedule details dialog drops its Overview/Runs tabs: one scrollable
column with the meta grid, the configuration JSON (capped at max-h-64
with internal scroll so it cannot crowd out what follows), and a Runs
section beneath it showing the three most recent runs with a ghost
"Show all N runs" expander (collapsed again whenever a different
schedule's details open). The "Full configuration for this schedule"
subtext is gone; the dialog passes aria-describedby={undefined} so
Radix does not warn about the missing description.

Run now hands you into the session it starts. The trigger command
queues the run and returns before the runner attaches a session id, so
after the toast the handler polls the schedule overview once a second
for up to 15 seconds — which doubles as keeping the page's run status
fresh (refreshSchedules now returns the fetched overview to make that
single-stream) — until the triggered execution reports its session id,
then calls onOpenSession. Guarded so it never auto-navigates after the
user left the page.

* feat(desktop): hide runtime steering messages from transcripts

Scheduled/automation runs inject user-role steering messages each
iteration ("[SYSTEM] This run is not complete until you call
submit_and_exit...", plus a team-obligations variant). The chat view
rendered them as user bubbles, as if the person had typed them — in a
scheduled session the transcript was mostly [SYSTEM] noise.

They are machinery talking to the model, not something the person said
or needs to read, so the transcript now hides them entirely:
MessageBubble renders null for any [SYSTEM]-prefixed user message.
Grouping still treats them as working-row machinery via a single
isSystemSteeringMessage predicate — they collapse into the run's work
span, are never a turn boundary, can never be mistaken for a run's
answer, and never advance the run count even when metadata is missing —
so work-block folding and checkpoint/edit run numbering stay correct.
A finished scheduled session now reads as prompt, work summary, answer.

* fix(desktop): poll history while an attached session's event stream is dead

Opening a scheduled session while (or right after) it runs left the
view stuck on the thinking shimmer until the user switched away and
back. Root cause is in core: the hub daemon executes scheduled runs on
a private LocalRuntimeHost inside createLocalHubScheduleRuntimeHandlers,
while the hub server only projects live events from its own session
host — so session.attach succeeds but no assistant/tool/status events
ever flow. And since multiple hub daemons share cron.db, a run claimed
by a different daemon is invisible to this hub regardless. The proper
core rewiring is tracked as ENG-2474.

Client-side heal that covers every case: while an attached history
session reports a busy status and no chat_event chunk has arrived for
five seconds (and no assistant bubble is mid-stream), poll every three
seconds — re-read canonical history, merged through the same dedupe
path hydration uses, and the session record's status — so the
transcript and the thinking indicator settle in place. Locally driven
turns keep chunks flowing, so the quiet-window guard keeps the fallback
inert there.

* chore(desktop): format workspace selector components

Biome formatting drift that landed on main; picked up by a formatter
pass over components/views/chat.

* fix(desktop): keep stale-stream poll inert during locally driven turns

The fallback poll could fire between a local submit and the model's
first chunk (optimistic user bubble added, stream quiet past the
window, no assistant bubble yet). It then replaced the optimistic
bubble — raw prompt text — with its canonical history twin, which is
stored wrapped in a user_input envelope. The rekey handler that runs
when the stream starts looks for a trailing user bubble matching the
raw prompt, finds only the wrapped copy, and appends a second bubble:
duplicated messages in normal interactive chat.

The poll now stays inert while a local turn is in flight
(turnEpoch !== turnSettledEpoch, or outstanding optimistic user
messages), checked both before polling and again after the snapshot
returns. Hydration marks the turn settled — the mount defaults
(epoch 0, settled -1) otherwise read as an open turn and would keep
the fallback inert forever for the scheduled-session case it exists
for. Applying a polled snapshot also rebuilds the live tool routing
keys, same as hydration, so later tool events update canonical rows
in place instead of appending.

* fix(desktop): keep the working indicator alive for narrating scheduled runs

Watching a scheduled run live: the first tool row appeared, then the
thinking indicator vanished with nothing streaming, and the rest of
the run (final answer, submit_and_exit) only showed up seconds later
in one lump.

inferHydratedChatStatus treats a "running" session record whose
transcript ends on an assistant message as a session that died without
a status flip and reports "completed". That heuristic is right for
stale records, but scheduled/automation models narrate between tool
calls, so a polled snapshot can genuinely end on assistant text
mid-run — the completed flip hid the working indicator, folded the
run early, and disarmed the stale-stream poll (status left the busy
set), dead-ending live updates until an in-flight poll happened to
deliver the finished run.

The heuristic now only applies once the transcript has actually gone
quiet (newest message older than two minutes — comfortably past model
latency plus tool runs). A recently active transcript keeps the
record's "running" verdict, so the indicator stays up and polling
stays armed until the record itself settles.

* fix(desktop): stale-stream poll mirrors the session record instead of inferring

Replaces the previous fix for the vanishing working indicator (the
time-window guard added to inferHydratedChatStatus) with a version
that adds no inference at all: the heuristic is restored to exactly
its long-standing form, and the poll now maps the session record's
status verbatim (mapSessionRecordStatus).

The record is the right authority in the poll's context: the sessions
this fallback serves have a live host maintaining their record, and it
flips to a terminal status when the run ends. Transcript-shape
inference belongs only where it has always lived — hydrating sessions
whose records may be orphaned — and would misread a mid-run snapshot
ending on assistant narration as a finished session, hiding the
working indicator and disarming the poll.

* fix(desktop): address review findings on steering detection and run-now matching

Steering detection additionally requires the injected-message marker
(meta.userRunSpan === 0) beside the [SYSTEM] prefix, so a person's
genuine prompt that happens to start with "[SYSTEM]" stays visible
and turn-counted. The failure direction is deliberate: an unstamped
injected reminder would merely show as a user bubble, while the
content-only check could hide a real prompt.

Run-now only follows the execution id the trigger reply itself named;
the newest-execution-for-this-schedule fallback could open a previous
run's session when the trigger failed to enqueue one.

* fix(desktop): report a failed run-now instead of confirming a start

A trigger reply without an execution means no run was enqueued (the
schedule may have been disabled or deleted since the page loaded). The
handler previously toasted "Run started" regardless and then silently
skipped the session-open polling. It now shows a destructive
"Run not started" toast, refreshes the schedule list so the row
reflects reality, and skips the polling entirely.
2026-08-25 23:28:21 -07:00
Saoud Rizwan 110138b540 feat(desktop): sidebar time view, Customize/Marketplace split, and schedule page UX (#13570)
* feat(desktop): split Customize into Installed and Marketplace pages

The Customize hub previously embedded a Browse section inside every tab
that had a catalog. That inlining made each tab long and buried the
catalog. Customize is now the installed inventory only (skills, MCP,
plugins, rules, hooks, tools tabs pass marketplaceVariant="installed"
to the embedded MarketplaceView; McpServersContent grew the same prop),
with an outline Marketplace button in the header.

Browsing moved to a dedicated Marketplace settings section that renders
the previously dead "directory" variant of MarketplaceView: one list
across all catalog types with type-filter chips, wrapping tag chips,
and light rules separating the filter tiers from each other and from
the results. The Clear control now renders inline at the end of the tag
row only while a tag is active, the Updated date is gone, and the
header hosts an Installed button mirroring the one on the Customize
page. Directory subheader copy: "A curated set of plugins, MCP
servers, and skills from the Cline community."

Tag and type chips wrap to new lines instead of scrolling
horizontally.

* feat(desktop): sidebar time view with sections, sort toggle, and scheduled detection

Restores the time-sorted session list as the default sidebar view, with
collapsible Pinned / Scheduled / Tasks sections (headers appear only
once something is pinned or scheduled) and the page-fill effect that
grows the fetched history window until a Show-more click makes visible
progress. Project grouping stays as the alternate mode behind a
one-click sort toggle whose icon reflects the active mode — the old
dropdown cost an extra click for a two-option choice.

Scheduled sessions are detected two ways: the hub-schedule origin
trigger in session metadata, plus a fallback that asks the hub which
session ids belong to schedule executions (list_routine_schedules,
fetched on mount and every two minutes, merged into a rolling set).
The fallback matters because locally executed scheduled runs do not
reliably stamp the trigger into session metadata — a real scheduled
session created today carried only {mode:"user"} provenance. The
scheduled clock icon now leads the row, left of the title; pin and
timestamp stay on the right.

The initial visible page grows from 10 to 30 rows so a tall sidebar
fills instead of stranding a stub of rows over empty space (history
fetches already start at 50).

The expanded sidebar's Customize row now hosts indented Installed and
Marketplace sub-tabs while a customize section is open; the active
sub-tab carries the full selected background while the parent keeps a
subtler one so the two simultaneous highlights read differently.

Also fixes the hover-card flash on click (logo card and session-row
cards): Radix HoverCardContent sits on a DismissableLayer, so a click
on the trigger registers as a pointer-down outside the card and
dismisses it, and the trigger's focus event immediately reopens it.
onPointerDownOutside preventDefault suppresses the dismissal; cards
still close on pointer leave.

* feat(desktop): schedule page row, dialog, and details UX polish

Schedule cards are now click targets: clicking anywhere on a card
outside its controls opens the details dialog (guarded via
closest("button,...") since every inline control, including the Radix
switch, renders a button element), with Enter/Space keyboard support.
The redundant eye button is gone. The remaining edit / run / pause /
delete buttons grow from the 12px icon-sm size to 28px targets with
16px icons, sized consistently with the adjacent enable toggle — the
icons use explicit size-4 classes so the Button base svg rule cannot
shrink them back.

The new/edit dialog gains breathing room between field labels and their
inputs (space-y-2 per field wrapper).

The details dialog no longer scrolls as a whole when the schedule JSON
is long: the dialog is a flex column capped at 85vh, the JSON pre
shrinks to the remaining space (min-h-0) and scrolls internally, and
the Runs tab list scrolls inside the tab the same way.
2026-08-25 15:19:29 -07:00
3497391c5a feat(desktop): customize macOS DMG install window (#13563)
* feat(desktop): add Retina DMG background tooling

* feat(desktop): customize the macOS DMG layout

* ci(desktop): validate DMG background assets

* fix(desktop): adjust DMG Applications icon position

* ci(desktop): drop redundant DMG artwork validation from publish workflow

Tauri's beforeBuildCommand already runs dmg:background (with its own
validation) at the start of the build/sign/notarize step, and the
release/beta config overlays do not override the build section, so this
step duplicated work the publish job performs anyway. PR-time coverage
lives in desktop-test.yml.

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-25 14:30:59 -07:00
Mikołaj Kondratek 9154a54a0e fix: stop showing cost estimates for subscription-billed providers (#13552)
* fix(vscode): stop showing cost estimates for subscription-billed providers

Providers whose usage is covered by a flat-rate subscription (ChatGPT
Plus/Pro via openai-codex, ClinePass) are marked with
metadata.usageCostDisplay = "subscription" in the SDK, and the CLI
already suppresses dollar figures for them. The VS Code host collapsed
that value into "show" before it reached the webview, so the task
header and model pricing rows rendered API-rate cost estimates that
users read as real charges on top of their subscription.

Pass all three usageCostDisplay values ("show" | "hide" |
"subscription") through the catalog listing and render cost only when
the value is "show", matching the CLI's shouldShowCliUsageCost
policy.

* feat(llms): mark Claude Code as a subscription-billed provider

Claude Code is typically authenticated with a Claude Pro/Max
subscription, but its models reuse Anthropic API pricing metadata, so
Cline rendered per-token prices and API-rate cost estimates for usage
that is covered by the subscription. Set usageCostDisplay =
"subscription" on the provider (picked up by the CLI and the VS Code
webview) and suppress the price rows in the Claude Code settings card.

The Claude Code CLI can also run on API-key billing, where a real cost
exists; the provider cannot distinguish the two, so we prefer showing
no number over a misleading one.

* fix(vscode): suppress cost display until provider listings load

While the ListProviders request is in flight (or after it fails), the
usage-cost hook had no listing to consult and fell back to "show",
flashing the API-rate estimate at subscription users on every chat-view
mount — the exact display the previous commit removes. Return
"unknown" whenever listings are absent; consumers already render cost
only for "show", so they suppress it during that window with no
changes. Briefly hiding a real cost is harmless, briefly showing a fake
charge is not.
2026-08-25 19:17:14 +02:00
Tomás Barreiro 7d004f8dc7 Hide task costs on vscode when ClinePass is selected (#13515) 2026-08-25 18:11:41 +02:00
MaxandMax Paulus 🥪 432e00eaa6 fix(vscode): include rich workspace metadata in system prompt (#13518)
* capture richer workspace information for vs code extension

* fix(shared): redact credentials from workspace remotes

* fix(shared): avoid regex backtracking in remote redaction

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-08-25 09:02:49 -07:00
Mikołaj Kondratek 095385b985 fix(desktop): unblock sdk-test lint on the voice-input model picker (#13553)
The model picker renders a radiogroup of styled buttons with role=radio
and aria-checked; biome's useSemanticElements flags the role as an
error, which fails the sdk-test Quality Checks lint for every PR
touching sdk/ or apps/ paths. Suppress with a justification — switching
to input type=radio needs a restyle and belongs to the desktop settings
work.
2026-08-25 08:58:23 -07:00
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
Saoud Rizwan fb60f9e5fd chore(desktop): release v0.0.15 2026-08-20 22:42:08 -07:00
Saoud Rizwan 9e0015b78b chore(vscode): prepare 4.1.11 release 2026-08-20 22:07:44 -07:00
Saoud Rizwan 4a128d5f76 docs(cli): drop the tasks tool from the v3.0.56 notes, it is desktop-only 2026-08-20 21:38:16 -07:00
Saoud Rizwan b5cfe88846 chore(sdk): release v0.0.77 2026-08-20 21:38:02 -07:00
Bee 355ce11ae2 refactor: centralize client tool availability (#13451) 2026-08-20 21:34:38 -07:00
Haley Park 05da55857a feat(desktop): reskin first-run onboarding (#13441) 2026-08-20 20:48:34 -07:00
Haley Park 35583ee8bb feat(desktop): interactive welcome hero graphic (#13399)
* feat(desktop): add interactive welcome hero

* feat(desktop): support composable welcome hero variants
2026-08-20 20:47:52 -07:00
Saoud Rizwan a8d260f1bd docs(cli): scope the v3.0.56 release notes to CLI-visible changes 2026-08-20 19:52:20 -07:00
Saoud Rizwan 59ee1ea80e chore(cli): release v3.0.56 2026-08-20 19:39:45 -07:00
229 changed files with 19436 additions and 5704 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
+50
View File
@@ -0,0 +1,50 @@
name: desktop-test
on:
push:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
pull_request:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
dmg-background:
name: Test DMG background tooling
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/examples/desktop-app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
# The suite only uses Bun/Node built-ins and committed artwork, so it does
# not need a workspace dependency install or macOS runner.
- name: Test DMG background tooling
run: bun run test:dmg-background
@@ -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
+1
View File
@@ -88,6 +88,7 @@ apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
+94
View File
@@ -1,5 +1,99 @@
# Changelog
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
- New files are now created with your platform's native line endings.
- Fixed the codebase search tool crashing on files containing a single enormous line.
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
- The hub's event log can no longer grow until it fills your disk.
### Changed
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
## [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.
### Added
- Let models that support it generate images during a task. Generated images render inline in the conversation.
### Fixed
- Fix code actions failing with "command not found" on VS Code 1.134.
- Fix `@` file mentions breaking on paths that contain spaces.
- Show the diff edit view for multi-line edits in files with CRLF line endings.
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
- Honor the classic truncation range when migrating legacy tasks.
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
- Point provider signup links at each provider's API key page instead of a generic landing page.
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
- Stop offering image, voice, and other non-chat models in chat model pickers.
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
### Changed
- Show the billed cost for Cline gateway usage.
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
### Fixed (legacy bundle)
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
## [4.1.10]
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
+40
View File
@@ -1,5 +1,45 @@
# Cline CLI Changelog
## 3.0.59
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 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
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
- Fixed TUI dialog colors not following theme changes live
- Fixed the account dialog's selection chevron so it matches the other dialogs
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
- Fixed Gemini custom base URLs configured as a host root
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 3.0.55
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.55",
"version": "3.0.59",
"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>
+49 -4
View File
@@ -1,8 +1,17 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildUserInputMessage } from "./prompt";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
const workspaceDirectories: string[] = [];
afterEach(() => {
for (const directory of workspaceDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("buildUserInputMessage", () => {
it("extracts image mentions into userImages", async () => {
@@ -43,3 +52,39 @@ describe("buildUserInputMessage", () => {
expect(result.userFiles).toEqual([filePath]);
});
});
describe("resolveSystemPrompt workspace metadata", () => {
it("includes git remotes and the latest commit for Cline requests", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
execFileSync("git", ["init"], { cwd });
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
writeFileSync(join(cwd, "README.md"), "test\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync("git", ["commit", "-m", "initial"], { cwd });
execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd });
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
encoding: "utf8",
}).trim();
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
expect(prompt).toContain(commit);
});
it("includes parseable metadata outside a project", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("# Workspace Configuration");
expect(prompt).toContain(JSON.stringify(cwd));
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
expect(prompt).not.toContain("associatedRemoteUrls");
expect(prompt).not.toContain("latestGitCommitHash");
});
});
+1
View File
@@ -13,6 +13,7 @@ export function getToolCatalog(
): ToolCatalogEntry[] {
const modelToolSettings = resolveModelToolSettings();
return getCoreBuiltinToolCatalog({
clientType: "cli",
disabledToolIds: resolveDisabledToolNames(),
enabledModelToolIds: new Set(
Object.entries(modelToolSettings)
@@ -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 {
+71
View File
@@ -1,5 +1,76 @@
# Cline Desktop Changelog
## 0.0.19
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 0.0.18
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
- "Run now" now hands you into the session it starts
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
- Fixed quitting the app beach-balling for several seconds
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
- Fixed hover cards flashing closed and reopening when clicked
- The macOS DMG install window now has custom Cline artwork and layout
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
## 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
- Refreshed app icons and branding
- Reskinned the first-run onboarding, with an interactive welcome graphic
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
- Agents can now create and manage durable todos and one-time or recurring schedules
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
- Fixed "No sessions found" flashing while session history was still loading
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
- Fixed the settings gear keeping its hover state while the Account screen is open
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
- Fixed copying a user message bringing along its internal envelope
- Fixed multi-line code blocks collapsing onto a single line
- Image, voice, and other non-chat models are no longer offered in chat model pickers
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 0.0.14
- The app now posts native macOS notifications when a task finishes or needs your input, so you can leave Cline working in the background. Configure them under Settings → Notifications.
+32
View File
@@ -17,6 +17,38 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Customizing the macOS Install Window
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
`background.gen.tiff` Finder actually renders is a gitignored build artifact
regenerated from them on every build.
1. The current source artwork is `640x400`. Export `background.png` at 1x and
`background@2x.png` at 2x.
2. Currently the app icons are centered at `(140, 200)` and
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
and `applicationFolderPosition` to reposition the app icons.
3. Build with `bun run build:binary`. Before compiling, the build validates
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
expected 1x and 2x representations. Run `bun run dmg:background` to do just
that step, e.g. to sanity-check new artwork without a full build. The DMG
is written beneath `src-tauri/target/release/bundle/dmg/`.
Run `bun run test:dmg-background` for the cross-platform checks covering the
committed PNG dimensions and TIFF validation logic.
The configured `640x432` Finder window is intentionally 32 points taller than
the `640x400` background. That extra height matches the Finder chrome in the
currently verified packaged layout; re-check it after material macOS or Finder
changes. The project deliberately uses a multi-resolution TIFF even though
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
both the 1x and 2x representations from a single background file. Re-check the
packaged DMG after upgrading Tauri in case its background validation changes.
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.14",
"version": "0.0.19",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -15,6 +15,8 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"dmg:background": "bun run scripts/dmg-background.ts",
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
@@ -33,10 +35,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 +82,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",
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";
import path from "node:path";
import {
parseTiffInfo,
readPngDimensions,
validateTiffRepresentations,
} from "./dmg-background";
const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg");
const EXPECTED_REPRESENTATIONS = [
{ width: 640, height: 400, dpiX: 72, dpiY: 72 },
{ width: 1280, height: 800, dpiX: 144, dpiY: 144 },
];
const TIFF_INFO = `Directory at 0x1
Image Width: 640 Image Length: 400
Resolution: 72, 72
Resolution Unit: pixels/inch
Directory at 0x2
Image Width: 1280 Image Length: 800
Resolution: 144, 144
Resolution Unit: pixels/inch
`;
describe("parseTiffInfo", () => {
test("reads the dimensions and DPI of every TIFF representation", () => {
expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS);
});
test("rejects representations without pixel-per-inch resolution", () => {
expect(() =>
parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")),
).toThrow(/could not parse TIFF representation/);
});
});
describe("DMG source artwork", () => {
test("has the expected 1x and 2x dimensions", async () => {
const [dimensions1x, dimensions2x] = await Promise.all([
readPngDimensions(path.join(DMG_ROOT, "background.png")),
readPngDimensions(path.join(DMG_ROOT, "background@2x.png")),
]);
expect(dimensions1x).toEqual({ width: 640, height: 400 });
expect(dimensions2x).toEqual({ width: 1280, height: 800 });
});
});
describe("validateTiffRepresentations", () => {
test("accepts the expected representations", () => {
expect(() =>
validateTiffRepresentations(EXPECTED_REPRESENTATIONS),
).not.toThrow();
});
test("rejects the wrong number of representations", () => {
expect(() =>
validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)),
).toThrow(/exactly two image representations/);
});
test("rejects incorrect representation dimensions", () => {
expect(() =>
validateTiffRepresentations([
EXPECTED_REPRESENTATIONS[0],
{ ...EXPECTED_REPRESENTATIONS[1], width: 1279 },
]),
).toThrow(/must be 1280x800/);
});
test("rejects incorrect representation DPI", () => {
expect(() =>
validateTiffRepresentations([
{ ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 },
EXPECTED_REPRESENTATIONS[1],
]),
).toThrow(/must be 72x72 DPI/);
});
});
@@ -0,0 +1,175 @@
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { $ } from "bun";
type Dimensions = {
width: number;
height: number;
};
type TiffRepresentation = Dimensions & {
dpiX: number;
dpiY: number;
};
const APP_ROOT = path.resolve(import.meta.dir, "..");
const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg");
const BACKGROUND_1X = path.join(DMG_ROOT, "background.png");
const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png");
// Gitignored build artifact; only the PNG sources are committed.
const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff");
const EXPECTED_1X = { width: 640, height: 400 };
const EXPECTED_2X = { width: 1280, height: 800 };
const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [
{ ...EXPECTED_1X, dpiX: 72, dpiY: 72 },
{ ...EXPECTED_2X, dpiX: 144, dpiY: 144 },
];
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
// PNG stores its big-endian width and height in the fixed IHDR fields at
// byte offsets 16 and 20, so dimensions can be checked without an image library.
export const readPngDimensions = async (
filePath: string,
): Promise<Dimensions> => {
const contents = await readFile(filePath);
const hasPngSignature = PNG_SIGNATURE.every(
(byte, index) => contents[index] === byte,
);
if (
contents.length < 24 ||
!hasPngSignature ||
contents.toString("ascii", 12, 16) !== "IHDR"
) {
throw new Error(`${filePath} is not a valid PNG with an IHDR header`);
}
return {
width: contents.readUInt32BE(16),
height: contents.readUInt32BE(20),
};
};
const assertDimensions = (
label: string,
actual: Dimensions,
expected: Dimensions,
): void => {
if (actual.width !== expected.width || actual.height !== expected.height) {
throw new Error(
`${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`,
);
}
};
// tiffutil prints one "Directory at ..." block for each image representation
// embedded in the TIFF.
export const parseTiffInfo = (output: string): TiffRepresentation[] =>
output
.split(/(?=Directory at )/)
.filter((block) => block.startsWith("Directory at "))
.map((block) => {
const dimensions = block.match(
/Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/,
);
const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/);
if (
!dimensions ||
!resolution ||
!block.includes("Resolution Unit: pixels/inch")
) {
throw new Error(`could not parse TIFF representation:\n${block}`);
}
return {
width: Number(dimensions[1]),
height: Number(dimensions[2]),
dpiX: Number(resolution[1]),
dpiY: Number(resolution[2]),
};
});
export const validateTiffRepresentations = (
representations: TiffRepresentation[],
label = "TIFF",
): void => {
const sortedRepresentations = [...representations].sort(
(left, right) => left.width - right.width,
);
if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) {
throw new Error(
`${label} must contain exactly two image representations, got ${sortedRepresentations.length}`,
);
}
for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) {
const actual = sortedRepresentations[index];
assertDimensions(`${label} representation ${index + 1}`, actual, expected);
if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) {
throw new Error(
`${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`,
);
}
}
};
const assertTiffRepresentations = async (filePath: string): Promise<void> => {
const representations = parseTiffInfo(
await $`tiffutil -info ${filePath}`.quiet().text(),
);
validateTiffRepresentations(representations, filePath);
};
const assertSourceDimensions = async (): Promise<void> => {
const [dimensions1x, dimensions2x] = await Promise.all([
readPngDimensions(BACKGROUND_1X),
readPngDimensions(BACKGROUND_2X),
]);
assertDimensions("background.png", dimensions1x, EXPECTED_1X);
assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X);
};
const generateTiff = async (outputPath: string): Promise<void> => {
// Finder's .DS_Store references one background file. A multi-representation
// TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover
// a separate @2x companion beside that referenced file.
await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet();
await assertTiffRepresentations(outputPath);
};
const main = async (): Promise<void> => {
if (process.argv.length > 2) {
throw new Error("usage: bun run dmg:background");
}
if (process.platform !== "darwin") {
// Runs from beforeBuildCommand on every platform, but only macOS builds
// bundle a DMG and only macOS ships tiffutil.
console.log("Skipping DMG background generation on non-macOS host.");
return;
}
await assertSourceDimensions();
// Generate and validate in scratch space so the configured build artifact is
// replaced only after tiffutil has produced a complete, verified TIFF.
const scratchRoot = await mkdtemp(
path.join(tmpdir(), "cline-dmg-background-"),
);
const generatedTiff = path.join(scratchRoot, "background.tiff");
try {
await generateTiff(generatedTiff);
await copyFile(generatedTiff, BACKGROUND_TIFF);
console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`);
} finally {
await rm(scratchRoot, { force: true, recursive: true });
}
};
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
@@ -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" };
}
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlugin } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getOfficialPluginInstallPath,
@@ -9,6 +10,15 @@ import {
} from "./marketplace";
import type { JsonRecord } from "./types";
// Marketplace plugin installs run in-process through @cline/core (spawning a
// `cline` binary fails with 'Executable not found in $PATH: "cline"' in the
// packaged app). Stub only installPlugin; everything else stays real.
vi.mock(import("@cline/core"), async (importOriginal) => ({
...(await importOriginal()),
installPlugin: vi.fn(),
}));
const installPluginMock = vi.mocked(installPlugin);
const GOAL_ENTRY = {
id: "goal",
type: "plugin",
@@ -23,6 +33,13 @@ beforeEach(async () => {
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
previousClineDir = process.env.CLINE_DIR;
process.env.CLINE_DIR = tempClineDir;
installPluginMock.mockReset().mockImplementation(async (options) => ({
source: options.source,
installPath: goalInstallDir(),
entryPaths: [],
mcpSyncFailures: [],
mcpOAuthCandidates: [],
}));
});
afterEach(async () => {
@@ -43,57 +60,49 @@ function goalInstallDir(): string {
}
describe("official plugin install detection", () => {
it("does not treat a leftover empty install directory as installed", async () => {
// Regression: a failed or interrupted install can leave the directory
// behind with nothing in it. The next install attempt then returned
// "already installed" without running the CLI, so the UI flipped the
// entry to Uninstall with no error while nothing actually worked.
await mkdir(goalInstallDir(), { recursive: true });
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout: "",
stderr: "install exploded",
}));
await expect(
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
).rejects.toThrow(/Plugin install failed/);
expect(spawnCommand).toHaveBeenCalledTimes(1);
});
it("passes --force so a retry can reclaim the leftover directory", async () => {
// Without --force the CLI refuses to replace the existing path
// ("Plugin is already installed at ... Use --force to replace it."),
// so every retry from the UI would fail against the stale directory.
await mkdir(goalInstallDir(), { recursive: true });
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
const result = await installMarketplaceEntry(
{ entry: GOAL_ENTRY },
{ spawnCommand },
);
it("installs plugins in-process through @cline/core", async () => {
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Installed Goal.",
});
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
expect(installPluginMock).toHaveBeenCalledWith({
source: "goal",
force: false,
});
});
it("does not pass --force for a clean first install", async () => {
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
it("does not treat a leftover empty install directory as installed", async () => {
// Regression: a failed or interrupted install can leave the directory
// behind with nothing in it. The next install attempt then returned
// "already installed" without running the installer, so the UI flipped
// the entry to Uninstall with no error while nothing actually worked.
await mkdir(goalInstallDir(), { recursive: true });
installPluginMock.mockRejectedValueOnce(new Error("install exploded"));
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
await expect(
installMarketplaceEntry({ entry: GOAL_ENTRY }),
).rejects.toThrow(/install exploded/);
expect(installPluginMock).toHaveBeenCalledTimes(1);
});
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
it("passes force so a retry can reclaim the leftover directory", async () => {
// Without force the installer refuses to replace the existing path
// ("Plugin is already installed at ... Use --force to replace it."),
// so every retry from the UI would fail against the stale directory.
await mkdir(goalInstallDir(), { recursive: true });
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Installed Goal.",
});
expect(installPluginMock).toHaveBeenCalledWith({
source: "goal",
force: true,
});
});
it("still short-circuits when the directory contains a plugin module", async () => {
@@ -111,22 +120,51 @@ describe("official plugin install detection", () => {
join(installDir, "package", "index.ts"),
"export default {};",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
const result = await installMarketplaceEntry(
{ entry: GOAL_ENTRY },
{ spawnCommand },
);
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Goal is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
expect(installPluginMock).not.toHaveBeenCalled();
});
it("registers MCP servers in-process, honoring the -- args separator", async () => {
const settingsPath = join(tempClineDir, "cline_mcp_settings.json");
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
try {
const result = await installMarketplaceEntry({
entry: {
id: "aikido",
type: "mcp",
name: "Aikido",
install: {
args: ["aikido", "--", "npx", "-y", "@aikidosec/mcp@1.0.9"],
},
},
});
expect(result).toMatchObject({
status: "installed",
message: "Installed Aikido.",
});
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers: Record<string, { transport?: unknown }>;
};
expect(settings.mcpServers.aikido?.transport).toEqual({
type: "stdio",
command: "npx",
args: ["-y", "@aikidosec/mcp@1.0.9"],
});
} finally {
if (previousSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
}
}
});
it("excludes partial install directories from the installed entries list", async () => {
@@ -18,8 +18,11 @@ import {
resolve,
} from "node:path";
import {
installPlugin as installCorePlugin,
installMcpServer,
type MarketplaceActionResult,
type MarketplaceEntryInput,
parseMcpInstallArgs,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
@@ -457,18 +460,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
@@ -823,7 +814,6 @@ async function installSkill(
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
@@ -840,41 +830,36 @@ async function installPlugin(
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
// Reclaim a leftover directory from a failed or interrupted install:
// without --force the CLI refuses to replace the existing path and
// every retry from the UI would fail the same way. This is safe
// because the state check just confirmed the directory contains no
// loadable plugin module.
...(installState === "partial" ? ["--force"] : []),
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
// Install in-process instead of shelling out to a `cline` binary: the
// packaged desktop app cannot assume a CLI install exists on the user's
// PATH (GUI apps inherit launchd's minimal PATH on macOS), which surfaced
// as 'Executable not found in $PATH: "cline"' in the marketplace UI.
//
// force reclaims a leftover directory from a failed or interrupted
// install: without it the installer refuses to replace the existing path
// and every retry from the UI would fail the same way. This is safe
// because the state check just confirmed the directory contains no
// loadable plugin module.
const result = await installCorePlugin({
source: installArgs[0] ?? "",
force: installState === "partial",
});
const warnings = result.mcpSyncFailures.map(
(failure) =>
`Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
details: {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
} as JsonRecord,
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
};
}
@@ -885,45 +870,26 @@ export async function installMarketplaceEntry(
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
// Register the server in-process; this only writes MCP settings, so
// there is no reason to depend on a `cline` binary being on PATH.
const result = installMcpServer(
parseMcpInstallArgs(entry.install.args ?? []),
);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
details: result as unknown as JsonRecord,
output:
result.warnings.length > 0 ? result.warnings.join("\n") : undefined,
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
return installPlugin(entry);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+10 -73
View File
@@ -275,36 +275,18 @@ impl DesktopBackendState {
*guard = true;
}
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
if let Some(endpoint) = endpoint_guard.as_ref() {
request_desktop_backend_shutdown(endpoint);
}
}
if let Ok(mut process_guard) = self.process.lock() {
if let Some(child) = process_guard.as_mut() {
// The sidecar bounds its own graceful shutdown with
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
// itself; wait past that window before escalating to kill so
// an active session can finish persisting.
for _ in 0..70 {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(Duration::from_millis(100)),
Err(_) => break,
}
}
match child.try_wait() {
Ok(Some(_)) => {}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
}
}
// Quit runs this on the main thread (on macOS inside
// applicationWillTerminate:, where blocking beach-balls the
// app), so signal the sidecar and return without waiting.
// SIGTERM triggers its own bounded graceful shutdown
// (SHUTDOWN_TIMEOUT_MS in sidecar/index.ts), after which it
// exits itself, finishing session persistence as an orphan.
#[cfg(unix)]
let _ = Command::new("kill").arg(child.id().to_string()).status();
#[cfg(not(unix))]
let _ = child.kill();
}
*process_guard = None;
}
@@ -353,51 +335,6 @@ fn resolve_workspace_root(launch_cwd: &str) -> String {
}
}
fn request_desktop_backend_shutdown(endpoint: &str) {
let trimmed = endpoint.trim();
if trimmed.is_empty() {
return;
}
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
let url = format!("{base}/shutdown");
let timeout_seconds = "2";
#[cfg(target_os = "windows")]
{
let _ = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
url.replace('\'', "''"),
timeout_seconds
),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
{
let _ = Command::new("curl")
.args([
"-fsS",
"--connect-timeout",
timeout_seconds,
"--max-time",
timeout_seconds,
"-X",
"POST",
&url,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
let launch_cwd = PathBuf::from(&context.launch_cwd);
let candidates = [
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.14",
"version": "0.0.19",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
"devUrl": "http://localhost:3125",
"beforeBuildCommand": "bun run build",
"beforeBuildCommand": "bun run dmg:background && bun run build",
"frontendDist": "../webview/out"
},
"plugins": {
@@ -48,7 +48,22 @@
],
"macOS": {
"entitlements": "entitlements.plist",
"hardenedRuntime": true
"hardenedRuntime": true,
"dmg": {
"background": "dmg/background.gen.tiff",
"windowSize": {
"width": 640,
"height": 432
},
"appPosition": {
"x": 140,
"y": 200
},
"applicationFolderPosition": {
"x": 500,
"y": 200
}
}
}
}
}
+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();
});
});
File diff suppressed because it is too large Load Diff
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"
@@ -823,6 +823,107 @@ describe("ChatMessages tool disclosures", () => {
expect(writeText).toHaveBeenCalledWith("Original prompt");
});
it("hides runtime steering notes from the transcript", async () => {
await renderMessages([
{
id: "user-prompt",
sessionId: "session-1",
role: "user",
content: "tell me the current time",
createdAt: 1,
},
{
id: "steer-1",
sessionId: "session-1",
role: "user",
content:
"[SYSTEM] This run is not complete until you call one of these terminal completion tools: submit_and_exit.",
createdAt: 2,
meta: { userRunSpan: 0 },
},
]);
// Steering nudges are machinery talking to the model — not rendered
// at all, and never as a user bubble.
expect(container.textContent).toContain("tell me the current time");
expect(container.textContent).not.toContain("[SYSTEM]");
expect(container.textContent).not.toContain(
"This run is not complete until you call",
);
});
it("shows a genuine user prompt that happens to start with [SYSTEM]", async () => {
await renderMessages([
{
id: "user-prompt",
sessionId: "session-1",
role: "user",
content: "[SYSTEM] is a prefix I typed myself, explain it",
createdAt: 1,
},
]);
// Only injected reminders (userRunSpan 0) are steering; a person's
// own prompt stays visible.
expect(container.textContent).toContain(
"is a prefix I typed myself, explain it",
);
});
it("keeps steering notes hidden inside the expanded work block", async () => {
await renderMessages([
{
id: "user-prompt",
sessionId: "session-1",
role: "user",
content: "tell me the current time",
createdAt: 1,
},
{
id: "steer-1",
sessionId: "session-1",
role: "user",
content: "[SYSTEM] This run is not complete until you finish.",
createdAt: 2,
meta: { userRunSpan: 0 },
},
{
id: "tool-1",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "run_commands",
input: {},
result: {},
}),
createdAt: 3,
},
{
id: "answer",
sessionId: "session-1",
role: "assistant",
content: "It is 12:28 PM PT.",
createdAt: 4,
},
]);
// The steering note is working-rows machinery grouped with the run,
// and stays hidden even when the work block is expanded.
expect(container.textContent).toContain("It is 12:28 PM PT.");
expect(container.textContent).not.toContain(
"This run is not complete until you finish.",
);
const trigger = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((button) => button.textContent?.includes("Worked"));
expect(trigger).toBeDefined();
await act(async () => trigger?.click());
expect(container.textContent).not.toContain(
"This run is not complete until you finish.",
);
});
it("counts folded system-displayed runs before an editable user message", async () => {
const onEditMessage = vi.fn(async () => undefined);
await renderMessages(
@@ -1641,17 +1742,18 @@ describe("ChatMessages work collapse", () => {
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
});
it.each(["cancelled", "failed", "error"] as const)(
"keeps an interrupted run's rows visible even with partial trailing text (%s)",
async (status) => {
// Stop can land mid-answer, leaving partial assistant text after the
// tool calls; the run still must not fold into a summary.
await renderMessages(completedRun, { status });
it.each([
"cancelled",
"failed",
"error",
] as const)("keeps an interrupted run's rows visible even with partial trailing text (%s)", async (status) => {
// Stop can land mid-answer, leaving partial assistant text after the
// tool calls; the run still must not fold into a summary.
await renderMessages(completedRun, { status });
expect(container.querySelector(".cline-chat-work")).toBeNull();
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
},
);
expect(container.querySelector(".cline-chat-work")).toBeNull();
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
});
});
describe("ChatMessages thinking indicator", () => {
@@ -29,6 +29,24 @@ export type ChatRenderItem =
items: ChatRenderItem[];
};
/**
* Runtime steering notes injected into the conversation as user-role
* messages (completion-tool reminders, team-obligation nudges). They are
* machinery talking to the model, not the person talking, so the transcript
* renders them as subtle system rows and folds them into the run's working
* span instead of showing user bubbles.
*/
export function isSystemSteeringMessage(message: ChatMessage): boolean {
return (
message.role === "user" &&
// Injected reminders carry userRunSpan 0 (they are not user turns);
// requiring it keeps a person's genuine prompt that happens to start
// with "[SYSTEM]" visible and turn-counted.
message.meta?.userRunSpan === 0 &&
message.content.trimStart().startsWith("[SYSTEM]")
);
}
export function hasMessageReasoning(message: ChatMessage): boolean {
return Boolean(message.reasoning?.trim() || message.reasoningRedacted);
}
@@ -66,7 +84,8 @@ export function buildUserRunCountMap(
for (const message of messages) {
const userRunSpan =
message.meta?.userRunSpan ?? (message.role === "user" ? 1 : 0);
message.meta?.userRunSpan ??
(message.role === "user" && !isSystemSteeringMessage(message) ? 1 : 0);
const storedRunCount =
message.meta?.runCount ?? message.meta?.checkpoint?.runCount;
if (storedRunCount !== undefined) {
@@ -119,8 +138,9 @@ export type CollapseWorkOptions = {
*/
function isCollapsibleWorkItem(item: ChatRenderItem): boolean {
if (item.type === "tools") return true;
if (item.type !== "message") return false;
if (isSystemSteeringMessage(item.message)) return true;
return (
item.type === "message" &&
item.message.role === "assistant" &&
!item.message.images?.length &&
!item.message.media?.length
@@ -177,7 +197,11 @@ export function collapseCompletedWork(
let lastUserIndex = -1;
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index];
if (item.type === "message" && item.message.role === "user") {
if (
item.type === "message" &&
item.message.role === "user" &&
!isSystemSteeringMessage(item.message)
) {
lastUserIndex = index;
break;
}
@@ -195,7 +219,9 @@ export function collapseCompletedWork(
// message is the run's answer and stays visible below the summary.
const last = span.at(-1);
const answer =
last?.type === "message" && last.message.content.trim()
last?.type === "message" &&
last.message.role === "assistant" &&
last.message.content.trim()
? last
: undefined;
// A span is settled once a later user message exists. The trailing span
@@ -27,6 +27,7 @@ import type {
import { cn } from "@/lib/utils";
import { MemoizedMarkdown } from "../../../ui/markdown";
import { formatChatMessageContent } from "../message-content";
import { isSystemSteeringMessage } from "./group-messages";
import { ReasoningBlock } from "./reasoning-block";
function AssistantImageCarousel({
@@ -217,6 +218,14 @@ export const MessageBubble = memo(function MessageBubble({
const isUser = message.role === "user";
const isError = message.role === "error";
const checkpoint = message.meta?.checkpoint;
// Runtime steering notes (completion nudges in scheduled/automation runs,
// team-obligation reminders) are user-role messages the machinery sends to
// the model, not something the person said or needs to read — hide them
// from the transcript entirely. Grouping still treats them as working-row
// machinery (never a turn boundary, an answer, or a run-count increment).
if (isSystemSteeringMessage(message)) {
return null;
}
const displayContent = formatChatMessageContent(
message.role,
message.content,
@@ -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;
@@ -208,12 +212,10 @@ describe("WelcomeScreen", () => {
workspaces,
});
expect(
container.querySelectorAll(".cline-ui-agent-aurora__star"),
).toHaveLength(32);
expect(
container.querySelector(".cline-ui-agent-hero-heading"),
).not.toBeNull();
const heading = container.querySelector("h1");
expect(heading?.textContent).toBe("What would you like to build?");
expect(heading?.classList.contains("sr-only")).toBe(true);
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
await clickButton("project-1");
for (let index = 1; index <= workspaces.length; index += 1) {
@@ -1,19 +1,16 @@
"use client";
import type { AgendaTaskRecord } from "@cline/shared";
import {
AgentAurora,
AgentHeroHeading,
type AgentQuickAction,
AgentQuickActions,
} from "@cline/ui";
import { type AgentQuickAction, AgentQuickActions } from "@cline/ui";
import type { ReactNode } from "react";
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";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
export function WelcomeScreen({
@@ -53,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);
@@ -117,7 +114,6 @@ export function WelcomeScreen({
: "contents",
)}
>
{active ? <AgentAurora /> : null}
<div
className={cn(
active
@@ -134,7 +130,8 @@ export function WelcomeScreen({
>
{active ? (
<div className="cline-view-enter">
<AgentHeroHeading />
<h1 className="sr-only">What would you like to build?</h1>
<WelcomeHero />
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
@@ -172,7 +169,7 @@ export function WelcomeScreen({
{active ? composer : <SessionContent>{composer}</SessionContent>}
</div>
{active ? (
{active && AGENDA_UI_ENABLED ? (
<>
<AgentQuickActions
actions={actions}
@@ -0,0 +1,16 @@
/**
* Values the pointer calculation needs when layout bounds are unavailable.
*
* Visual geometry belongs to welcome-hero.module.css. These two fallback sizes
* mirror its default layout because jsdom and older webviews may not expose the
* rendered grid bounds used by the normal pointer path.
*/
export const WELCOME_HERO_POINTER_CONFIG = {
defaultFrameHeight: 220,
defaultGridHeight: 520,
eyes: {
travel: 6, // Maximum distance the eyes move toward the pointer.
falloffDistance: 200, // Pointer distance needed to reach maximum travel.
smoothing: 0.22, // Fraction of the remaining distance moved per frame.
},
} as const;
@@ -0,0 +1 @@
export { WelcomeHero, type WelcomeHeroProps } from "./welcome-hero";
@@ -0,0 +1,210 @@
import { type RefObject, useEffect } from "react";
import { WELCOME_HERO_POINTER_CONFIG } from "./hero-config";
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
interface WelcomeHeroPointerState {
eyeX: number;
eyeY: number;
gridX: string;
gridY: string;
}
const WELCOME_HERO_POINTER_PROPERTIES = [
"--welcome-grid-x",
"--welcome-grid-y",
"--welcome-eye-x",
"--welcome-eye-y",
] as const;
function resetWelcomeHeroPointerStyles(element: HTMLDivElement | null): void {
if (!element) return;
for (const property of WELCOME_HERO_POINTER_PROPERTIES) {
element.style.removeProperty(property);
}
}
function getWelcomeHeroPointerState(
element: HTMLDivElement,
clientX: number,
clientY: number,
): WelcomeHeroPointerState | null {
const bounds = element.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return null;
const { defaultFrameHeight, defaultGridHeight, eyes } =
WELCOME_HERO_POINTER_CONFIG;
const pointerX = clientX - bounds.left;
const pointerY = clientY - bounds.top;
const gridElement = element.querySelector<HTMLElement>(
'[data-welcome-hero-layer="grid"]',
);
const gridBounds = gridElement?.getBoundingClientRect();
const hasRenderedGridBounds =
gridBounds !== undefined && gridBounds.width > 0 && gridBounds.height > 0;
const gridX = hasRenderedGridBounds
? `${(
(clamp(clientX - gridBounds.left, 0, gridBounds.width) /
gridBounds.width) *
100
).toFixed(2)}%`
: `${((clamp(pointerX, 0, bounds.width) / bounds.width) * 100).toFixed(2)}%`;
const gridY = hasRenderedGridBounds
? `${(
(clamp(clientY - gridBounds.top, 0, gridBounds.height) /
gridBounds.height) *
100
).toFixed(2)}%`
: `${(
clamp(pointerY, 0, defaultFrameHeight) +
(defaultGridHeight - defaultFrameHeight) / 2
).toFixed(2)}px`;
const deltaX = pointerX - bounds.width / 2;
const deltaY = pointerY - defaultFrameHeight / 2;
const distance = Math.hypot(deltaX, deltaY);
const eyeStrength =
Math.min(distance / eyes.falloffDistance, 1) * eyes.travel;
const eyeX = distance === 0 ? 0 : (deltaX / distance) * eyeStrength;
const eyeY = distance === 0 ? 0 : (deltaY / distance) * eyeStrength;
return {
eyeX,
eyeY,
gridX,
gridY,
};
}
function applyWelcomeHeroEyeState(
element: HTMLDivElement,
eyeX: number,
eyeY: number,
): void {
element.style.setProperty("--welcome-eye-x", `${eyeX.toFixed(2)}px`);
element.style.setProperty("--welcome-eye-y", `${eyeY.toFixed(2)}px`);
}
/** Tracks the full page while keeping the visual response clamped to the hero. */
export function useWelcomeHeroPointer(
heroRef: RefObject<HTMLDivElement | null>,
enabled = true,
): void {
useEffect(() => {
if (!enabled) return;
let animationFrame: number | null = null;
let currentEyeX = 0;
let currentEyeY = 0;
let targetEyeX = 0;
let targetEyeY = 0;
let latestClientX = 0;
let latestClientY = 0;
let pointerDirty = false;
let trackingPointer = false;
const updatePointerTarget = (hero: HTMLDivElement): boolean => {
if (!pointerDirty) return false;
const target = getWelcomeHeroPointerState(
hero,
latestClientX,
latestClientY,
);
pointerDirty = false;
if (!target) return false;
hero.style.setProperty("--welcome-grid-x", target.gridX);
hero.style.setProperty("--welcome-grid-y", target.gridY);
targetEyeX = target.eyeX;
targetEyeY = target.eyeY;
return true;
};
const drawFrame = () => {
animationFrame = null;
const hero = heroRef.current;
if (!hero) return;
updatePointerTarget(hero);
const { smoothing } = WELCOME_HERO_POINTER_CONFIG.eyes;
currentEyeX += (targetEyeX - currentEyeX) * smoothing;
currentEyeY += (targetEyeY - currentEyeY) * smoothing;
const settledX = Math.abs(targetEyeX - currentEyeX) < 0.01;
const settledY = Math.abs(targetEyeY - currentEyeY) < 0.01;
if (settledX) currentEyeX = targetEyeX;
if (settledY) currentEyeY = targetEyeY;
applyWelcomeHeroEyeState(hero, currentEyeX, currentEyeY);
if (!settledX || !settledY) {
animationFrame = window.requestAnimationFrame(drawFrame);
}
};
const handlePointerMove = (event: PointerEvent) => {
const hero = heroRef.current;
if (!hero) return;
latestClientX = event.clientX;
latestClientY = event.clientY;
pointerDirty = true;
// jsdom and older webviews may not expose requestAnimationFrame.
if (typeof window.requestAnimationFrame !== "function") {
if (updatePointerTarget(hero)) {
currentEyeX = targetEyeX;
currentEyeY = targetEyeY;
applyWelcomeHeroEyeState(hero, currentEyeX, currentEyeY);
}
return;
}
if (animationFrame === null) {
animationFrame = window.requestAnimationFrame(drawFrame);
}
};
const startPointerTracking = () => {
if (trackingPointer) return;
window.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
trackingPointer = true;
};
const stopPointerTracking = () => {
if (trackingPointer) {
window.removeEventListener("pointermove", handlePointerMove);
trackingPointer = false;
}
if (animationFrame !== null) {
window.cancelAnimationFrame(animationFrame);
animationFrame = null;
}
pointerDirty = false;
currentEyeX = 0;
currentEyeY = 0;
targetEyeX = 0;
targetEyeY = 0;
resetWelcomeHeroPointerStyles(heroRef.current);
};
const motionPreference = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
);
const syncMotionPreference = () => {
if (motionPreference?.matches) stopPointerTracking();
else startPointerTracking();
};
syncMotionPreference();
motionPreference?.addEventListener("change", syncMotionPreference);
return () => {
motionPreference?.removeEventListener("change", syncMotionPreference);
stopPointerTracking();
};
}, [enabled, heroRef]);
}
@@ -0,0 +1,208 @@
.root {
/* Default inline layout. Alternate compositions override only this set. */
--welcome-hero-width: 1200px;
--welcome-hero-height: 220px;
--welcome-hero-viewport-gutter: 16px;
--welcome-grid-height: 520px;
--welcome-grid-initial-x: 50%;
--welcome-grid-initial-y: 280px;
--welcome-grid-x: var(--welcome-grid-initial-x);
--welcome-grid-y: var(--welcome-grid-initial-y);
--welcome-grid-layer-mask: radial-gradient(
ellipse 44.7% 45% at 44.7% 50%,
black 50%,
transparent 100%
);
--welcome-grid-mask: url("/welcome-hero/grid-tile.svg");
--welcome-grid-tile-size: 400px;
--welcome-inner-mask: url("/welcome-hero/inner-mask.svg");
--welcome-bot-top: -2px;
--welcome-bot-offset-x: -100px;
--welcome-bot-width: 204px;
--welcome-bot-height: 192px;
--welcome-bot-fill-mask: url("/welcome-hero/bot-fill-mask.svg");
--welcome-bot-outline-mask: url("/welcome-hero/bot-outline-mask.svg");
--welcome-eye-top: 83px;
--welcome-eye-width: 18px;
--welcome-eye-height: 52px;
--welcome-eye-left-offset-x: -37px;
--welcome-eye-right-offset-x: 19.5px;
--welcome-eye-x: 0px;
--welcome-eye-y: 0px;
/* Appearance controls. */
--welcome-hero-color: oklch(from var(--primary) 0.68 calc(c * 0.8) h);
--welcome-grid-color: oklch(from var(--welcome-hero-color) l calc(c * 0.4) h);
--welcome-grid-opacity: 0.2;
--welcome-inner-opacity: 2%;
--welcome-bot-fill-opacity: 13%;
--welcome-bot-stroke-opacity: 12%;
position: relative;
left: 50%;
isolation: isolate;
width: min(
calc(
100vw -
var(--welcome-hero-viewport-gutter) -
var(--welcome-hero-viewport-gutter)
),
var(--welcome-hero-width)
);
max-width: none;
height: var(--welcome-hero-height);
transform: translateX(-50%);
}
.root[data-welcome-hero-layout="full-bleed"] {
--welcome-hero-width: 100vw;
--welcome-hero-height: 100%;
--welcome-hero-viewport-gutter: 0px;
--welcome-grid-height: 100%;
--welcome-grid-initial-y: 50%;
--welcome-grid-layer-mask: none;
}
.root[data-welcome-hero-layout="wide-grid"] {
--welcome-hero-width: 1600px;
--welcome-grid-height: 960px;
--welcome-grid-initial-y: 370px;
}
.grid {
position: absolute;
z-index: 0;
top: 50%;
left: 0;
width: 100%;
height: var(--welcome-grid-height);
pointer-events: none;
-webkit-mask-image: var(--welcome-grid-layer-mask);
mask-image: var(--welcome-grid-layer-mask);
transform: translateY(-50%);
}
.grid::before {
position: absolute;
inset: 0;
content: "";
background: radial-gradient(
ellipse 44% 41.25% at var(--welcome-grid-x) var(--welcome-grid-y),
var(--welcome-grid-color) 0%,
color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
);
-webkit-mask-image: var(--welcome-grid-mask);
mask-image: var(--welcome-grid-mask);
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: repeat;
mask-repeat: repeat;
-webkit-mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
opacity: var(--welcome-grid-opacity);
}
.inner,
.botFill,
.botOutline {
position: absolute;
display: block;
top: var(--welcome-bot-top);
left: calc(50% + var(--welcome-bot-offset-x));
width: var(--welcome-bot-width);
height: var(--welcome-bot-height);
max-width: none;
pointer-events: none;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.inner {
z-index: 1;
background: color-mix(
in srgb,
var(--welcome-hero-color) var(--welcome-inner-opacity),
transparent
);
-webkit-mask-image: var(--welcome-inner-mask);
mask-image: var(--welcome-inner-mask);
}
.botFill {
z-index: 2;
background: color-mix(
in srgb,
var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
transparent
);
-webkit-mask-image: var(--welcome-bot-fill-mask);
mask-image: var(--welcome-bot-fill-mask);
}
.botOutline {
z-index: 3;
background: color-mix(
in srgb,
var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
transparent
);
-webkit-mask-image: var(--welcome-bot-outline-mask);
mask-image: var(--welcome-bot-outline-mask);
}
.eye {
position: absolute;
z-index: 4;
top: var(--welcome-eye-top);
width: var(--welcome-eye-width);
height: var(--welcome-eye-height);
border: 1px solid
color-mix(
in srgb,
var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
transparent
);
border-radius: 999px;
pointer-events: none;
background: color-mix(
in srgb,
var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
transparent
);
backface-visibility: hidden;
transform: translate3d(var(--welcome-eye-x), var(--welcome-eye-y), 0);
will-change: transform;
}
.eyeLeft {
left: calc(50% + var(--welcome-eye-left-offset-x));
}
.eyeRight {
left: calc(50% + var(--welcome-eye-right-offset-x));
}
[class~="dark"] .root {
--welcome-hero-color: oklch(from var(--primary) 0.9 calc(c * 0.8) h);
--welcome-grid-opacity: 0.2;
--welcome-inner-opacity: 6%;
}
@media (prefers-reduced-motion: reduce) {
.grid::before {
background: radial-gradient(
ellipse 44% 41.25% at var(--welcome-grid-initial-x)
var(--welcome-grid-initial-y),
var(--welcome-grid-color) 0%,
color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
);
}
.eye {
transform: none;
}
}
@@ -0,0 +1,260 @@
// @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 { WELCOME_HERO_POINTER_CONFIG } from "./hero-config";
import { WelcomeHero, type WelcomeHeroProps } from "./welcome-hero";
let container: HTMLDivElement;
let root: Root;
let animationFrames: FrameRequestCallback[];
let reducedMotion = false;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
animationFrames = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: reducedMotion,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
})),
);
reducedMotion = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function renderHero(
props: WelcomeHeroProps = {},
): Promise<HTMLDivElement> {
await act(async () => root.render(<WelcomeHero {...props} />));
const hero = container.querySelector<HTMLDivElement>("[data-welcome-hero]");
expect(hero).not.toBeNull();
return hero as HTMLDivElement;
}
describe("WelcomeHero", () => {
it("renders independently tunable illustration layers", async () => {
const hero = await renderHero();
expect(hero.getAttribute("aria-hidden")).toBe("true");
expect(hero.dataset.welcomeHeroInteractive).toBe("true");
expect(hero.dataset.welcomeHeroLayout).toBe("default");
expect(hero.dataset.welcomeHeroVariant).toBe("full");
expect(
hero.querySelector('[data-welcome-hero-layer="grid"]'),
).not.toBeNull();
expect(
hero.querySelector('[data-welcome-hero-layer="inner"]'),
).not.toBeNull();
expect(
hero.querySelector('[data-welcome-hero-layer="bot-fill"]'),
).not.toBeNull();
expect(
hero.querySelector('[data-welcome-hero-layer="bot-outline"]'),
).not.toBeNull();
expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(2);
});
it("renders a static grid without bot layers", async () => {
const hero = await renderHero({ variant: "grid-only" });
const getBoundingClientRect = vi.spyOn(hero, "getBoundingClientRect");
expect(hero.dataset.welcomeHeroVariant).toBe("grid-only");
expect(hero.dataset.welcomeHeroInteractive).toBe("false");
expect(
hero.querySelector('[data-welcome-hero-layer="grid"]'),
).not.toBeNull();
expect(hero.querySelector('[data-welcome-hero-layer="inner"]')).toBeNull();
expect(
hero.querySelector('[data-welcome-hero-layer="bot-fill"]'),
).toBeNull();
expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(0);
await act(async () => {
window.dispatchEvent(
new MouseEvent("pointermove", { clientX: 100, clientY: 100 }),
);
});
expect(getBoundingClientRect).not.toHaveBeenCalled();
expect(animationFrames).toHaveLength(0);
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("");
});
it("renders bot layers without the inline grid", async () => {
const hero = await renderHero({ variant: "bot-only" });
expect(hero.dataset.welcomeHeroVariant).toBe("bot-only");
expect(hero.dataset.welcomeHeroInteractive).toBe("true");
expect(hero.querySelector('[data-welcome-hero-layer="grid"]')).toBeNull();
expect(
hero.querySelector('[data-welcome-hero-layer="bot-fill"]'),
).not.toBeNull();
expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(2);
});
it("tracks the pointer for an interactive grid without rendering the bot", async () => {
const hero = await renderHero({ interactive: true, variant: "grid-only" });
const grid = hero.querySelector<HTMLElement>(
'[data-welcome-hero-layer="grid"]',
);
hero.getBoundingClientRect = vi.fn(
() =>
({
bottom: 240,
height: 220,
left: 10,
right: 940,
top: 20,
width: 930,
x: 10,
y: 20,
toJSON: () => ({}),
}) as DOMRect,
);
if (!grid) throw new Error("Expected the grid layer to render");
grid.getBoundingClientRect = vi.fn(
() =>
({
bottom: 720,
height: 700,
left: 10,
right: 940,
top: 20,
width: 930,
x: 10,
y: 20,
toJSON: () => ({}),
}) as DOMRect,
);
expect(hero.dataset.welcomeHeroInteractive).toBe("true");
expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(0);
await act(async () => {
window.dispatchEvent(
new MouseEvent("pointermove", { clientX: -100, clientY: 500 }),
);
animationFrames.shift()?.(0);
});
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("0.00%");
expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe("68.57%");
});
it("tracks the page pointer and holds the reveal at the nearest edge", async () => {
const hero = await renderHero();
const getBoundingClientRect = vi.fn(
() =>
({
bottom: 240,
height: 220,
left: 10,
right: 940,
top: 20,
width: 930,
x: 10,
y: 20,
toJSON: () => ({}),
}) as DOMRect,
);
hero.getBoundingClientRect = getBoundingClientRect;
await act(async () => {
window.dispatchEvent(
new MouseEvent("pointermove", {
clientX: -100,
clientY: 500,
}),
);
animationFrames.shift()?.(0);
});
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("0.00%");
expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe(
`${(
WELCOME_HERO_POINTER_CONFIG.defaultFrameHeight +
(WELCOME_HERO_POINTER_CONFIG.defaultGridHeight -
WELCOME_HERO_POINTER_CONFIG.defaultFrameHeight) /
2
).toFixed(2)}px`,
);
expect(hero.style.getPropertyValue("--welcome-eye-x")).toMatch(/^-/);
expect(hero.style.getPropertyValue("--welcome-eye-y")).toMatch(/^\d/);
expect(getBoundingClientRect).toHaveBeenCalledOnce();
});
it("clears pointer styles when interaction is disabled", async () => {
const hero = await renderHero();
hero.getBoundingClientRect = vi.fn(
() =>
({
bottom: 240,
height: 220,
left: 10,
right: 940,
top: 20,
width: 930,
x: 10,
y: 20,
toJSON: () => ({}),
}) as DOMRect,
);
await act(async () => {
window.dispatchEvent(
new MouseEvent("pointermove", { clientX: 220, clientY: 120 }),
);
animationFrames.shift()?.(0);
});
expect(hero.style.getPropertyValue("--welcome-grid-x")).not.toBe("");
expect(hero.style.getPropertyValue("--welcome-eye-x")).not.toBe("");
const staticHero = await renderHero({ interactive: false });
expect(staticHero).toBe(hero);
expect(staticHero.style.getPropertyValue("--welcome-grid-x")).toBe("");
expect(staticHero.style.getPropertyValue("--welcome-grid-y")).toBe("");
expect(staticHero.style.getPropertyValue("--welcome-eye-x")).toBe("");
expect(staticHero.style.getPropertyValue("--welcome-eye-y")).toBe("");
});
it("does not track the pointer when reduced motion is requested", async () => {
reducedMotion = true;
const hero = await renderHero();
const getBoundingClientRect = vi.spyOn(hero, "getBoundingClientRect");
await act(async () => {
window.dispatchEvent(
new MouseEvent("pointermove", { clientX: 100, clientY: 100 }),
);
});
expect(getBoundingClientRect).not.toHaveBeenCalled();
expect(animationFrames).toHaveLength(0);
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("");
expect(hero.style.getPropertyValue("--welcome-eye-x")).toBe("");
});
});
@@ -0,0 +1,63 @@
"use client";
import { clsx } from "clsx";
import { useRef } from "react";
import { useWelcomeHeroPointer } from "./use-welcome-hero-pointer";
import styles from "./welcome-hero.module.css";
export type WelcomeHeroLayout = "default" | "full-bleed" | "wide-grid";
export interface WelcomeHeroProps {
className?: string;
interactive?: boolean;
layout?: WelcomeHeroLayout;
variant?: "full" | "grid-only" | "bot-only";
}
/** Composable welcome bot illustration and grid backdrop. */
export function WelcomeHero({
className,
interactive,
layout = "default",
variant = "full",
}: WelcomeHeroProps) {
const heroRef = useRef<HTMLDivElement>(null);
const showsGrid = variant !== "bot-only";
const showsBot = variant !== "grid-only";
const tracksPointer = interactive ?? showsBot;
useWelcomeHeroPointer(heroRef, tracksPointer);
return (
<div
aria-hidden="true"
className={clsx(styles.root, className)}
data-welcome-hero
data-welcome-hero-interactive={tracksPointer}
data-welcome-hero-layout={layout}
data-welcome-hero-variant={variant}
ref={heroRef}
>
{showsGrid ? (
<div className={styles.grid} data-welcome-hero-layer="grid" />
) : null}
{showsBot ? (
<>
<span className={styles.inner} data-welcome-hero-layer="inner" />
<span className={styles.botFill} data-welcome-hero-layer="bot-fill" />
<span
className={styles.botOutline}
data-welcome-hero-layer="bot-outline"
/>
<span
className={`${styles.eye} ${styles.eyeLeft}`}
data-welcome-hero-eye="left"
/>
<span
className={`${styles.eye} ${styles.eyeRight}`}
data-welcome-hero-eye="right"
/>
</>
) : null}
</div>
);
}
@@ -12,8 +12,8 @@ import {
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option";
import { cn } from "@/lib/utils";
import {
looksLikeFolderPath,
normalizeWorkspacePath,
@@ -399,9 +399,9 @@ function BranchPicker({
</div>
) : (
<div
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
ref={branchListRef}
>
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
ref={branchListRef}
>
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
@@ -358,7 +358,10 @@ export function WorkspaceSelector({
</span>
</Button>
)}
<div ref={workspaceListRef} className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
<div
ref={workspaceListRef}
className="flex flex-col gap-0.5 max-h-28 overflow-y-auto"
>
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{looksLikeFolderPath(search)
@@ -459,7 +462,10 @@ export function WorkspaceSelector({
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Branches
</div>
<div ref={branchListRef} className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
<div
ref={branchListRef}
className="flex flex-col gap-0.5 max-h-36 overflow-y-auto"
>
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
@@ -1,4 +1,5 @@
import {
Blocks,
ChevronRight,
ExternalLink,
Puzzle,
@@ -7,6 +8,7 @@ import {
Star,
Store,
Trash2,
X,
Zap,
} from "lucide-react";
import {
@@ -107,7 +109,7 @@ const primitivePageDetails = {
const directoryPageDetails: MarketplacePageDetails = {
title: "Marketplace",
description:
"Browse and install plugins, MCP servers, and skills from the Cline marketplace.",
"A curated set of plugins, MCP servers, and skills from the Cline community.",
emptyInstalled: "Nothing installed yet.",
emptyCatalog: "No marketplace entries match the current filters.",
icon: Store,
@@ -643,6 +645,7 @@ export function MarketplaceView({
defaultTypeFilter,
installedItems,
onInstalledItemsChanged,
onOpenInstalled,
primitive,
variant = "full",
}: {
@@ -651,6 +654,8 @@ export function MarketplaceView({
defaultTypeFilter?: MarketplacePrimitiveType;
installedItems?: MarketplaceLocalInstalledItem[];
onInstalledItemsChanged?: () => void | Promise<void>;
/** Renders an Installed button in the directory page header. */
onOpenInstalled?: () => void;
/** When omitted, the view spans every catalog type (directory variant). */
primitive?: MarketplacePrimitiveType;
variant?: MarketplaceViewVariant;
@@ -956,7 +961,7 @@ export function MarketplaceView({
const typeFilterChips =
variant === "directory" && !primitive ? (
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
<div className="flex min-w-0 flex-wrap gap-2">
<Button
aria-pressed={typeFilter === null}
onClick={() => setTypeFilter(null)}
@@ -991,38 +996,32 @@ export function MarketplaceView({
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</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>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) => (current === tag.id ? null : tag.id))
}
tag={tag}
/>
))}
{/* Clearing belongs with what it clears: the control appears at
the end of the chip row only while a tag is active. */}
{selectedTag ? (
<Button
className="text-muted-foreground"
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
<X className="size-3.5" />
Clear
</Button>
) : null}
</div>
) : null;
@@ -1128,16 +1127,17 @@ export function MarketplaceView({
) : undefined
}
actions={
catalog?.generatedAt ? (
<p className="text-xs text-muted-foreground">
Updated{" "}
{new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(catalog.generatedAt))}
</p>
) : null
onOpenInstalled ? (
<Button
onClick={onOpenInstalled}
size="sm"
type="button"
variant="outline"
>
<Blocks className="size-4" />
Installed
</Button>
) : undefined
}
/>
) : null}
@@ -1207,10 +1207,28 @@ export function MarketplaceView({
expandedEntryKey={expandedEntryKey}
headerContent={
typeFilterChips || marketplaceTagFilters ? (
<div className="grid min-w-0 gap-2">
{typeFilterChips}
{marketplaceTagFilters}
</div>
variant === "directory" ? (
// Light rules separate the filter tiers from each
// other and from the results below.
<div className="grid min-w-0 gap-3">
{typeFilterChips}
{marketplaceTagFilters ? (
<>
<div
aria-hidden="true"
className="h-px bg-border/70"
/>
{marketplaceTagFilters}
</>
) : null}
<div aria-hidden="true" className="h-px bg-border/70" />
</div>
) : (
<div className="grid min-w-0 gap-2">
{typeFilterChips}
{marketplaceTagFilters}
</div>
)
) : null
}
installedEntryKeys={installedEntryKeys}
@@ -1219,7 +1237,7 @@ export function MarketplaceView({
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
tagLabels={tagLabels}
title={variant === "directory" ? undefined : "Marketplace"}
title={variant === "directory" ? undefined : "Browse"}
/>
) : null}
</div>
@@ -17,6 +17,28 @@ vi.mock("@/lib/desktop-client", () => ({
openExternalUrl: vi.fn(),
}));
class StorageStub implements Storage {
readonly #values = new Map<string, string>();
get length() {
return this.#values.size;
}
clear() {
this.#values.clear();
}
getItem(key: string) {
return this.#values.get(key) ?? null;
}
key(index: number) {
return [...this.#values.keys()][index] ?? null;
}
removeItem(key: string) {
this.#values.delete(key);
}
setItem(key: string, value: string) {
this.#values.set(key, value);
}
}
function makeProvider(overrides: Partial<Provider> = {}): Provider {
return {
id: "anthropic",
@@ -97,6 +119,10 @@ describe("OnboardingView", () => {
let root: Root;
beforeEach(() => {
Object.defineProperty(window, "localStorage", {
configurable: true,
value: new StorageStub(),
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
@@ -150,14 +176,179 @@ describe("OnboardingView", () => {
it("walks from welcome to the connect step", async () => {
await render();
// The welcome step layers the standalone bot over a separate interactive
// full-bleed grid. These data attributes protect that visual composition
// without coupling the test to generated SVG markup.
expect(container.textContent).toContain("Build software your way");
const welcomeBot = container.querySelector(
'[data-welcome-hero-variant="bot-only"]',
);
expect(welcomeBot).not.toBeNull();
expect(
welcomeBot?.querySelector('[data-welcome-hero-layer="grid"]'),
).toBeNull();
const welcomeGrid = container.querySelector(
'[data-onboarding-grid="welcome"] [data-welcome-hero-variant="grid-only"]',
);
expect(welcomeGrid).not.toBeNull();
expect((welcomeGrid as HTMLElement).dataset.welcomeHeroLayout).toBe(
"full-bleed",
);
expect((welcomeGrid as HTMLElement).dataset.welcomeHeroInteractive).toBe(
"true",
);
await act(async () => {
buttonByText("Get started").click();
});
// Cline is selected by default. The inactive API-key card is inert so its
// controls cannot receive pointer or keyboard input through the overlay.
expect(container.textContent).toContain("Set up Cline");
expect(container.textContent).toContain("Sign in with Cline");
expect(container.textContent).toContain("Use your own API key");
const clineOption = container.querySelector(
'[data-onboarding-option="cline"]',
);
const apiKeyOption = container.querySelector(
'[data-onboarding-option="api-key"]',
);
const recommendedBadge = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
).find((badge) => badge.textContent === "Recommended");
expect(clineOption?.getAttribute("data-selected")).toBe("true");
expect(apiKeyOption?.getAttribute("data-selected")).toBe("false");
expect(recommendedBadge).not.toBeNull();
// Preserve the appearance of the desktop-local Badge after decoupling
// onboarding from the shared UI package's badge migration.
for (const className of [
"border-primary/30",
"bg-primary/10",
"text-primary-emphasis",
"rounded-sm",
"!pt-[0.3rem]",
"!pb-[0.2rem]",
]) {
expect(recommendedBadge?.className).toContain(className);
}
expect(
clineOption
?.querySelector("[data-onboarding-option-content]")
?.hasAttribute("inert"),
).toBe(false);
expect(
apiKeyOption
?.querySelector("[data-onboarding-option-content]")
?.hasAttribute("inert"),
).toBe(true);
expect(
container.querySelector('[data-onboarding-content="panel"]'),
).not.toBeNull();
const connectGrid = container.querySelector(
'[data-welcome-hero-variant="grid-only"]',
);
expect(connectGrid).not.toBeNull();
expect((connectGrid as HTMLElement).dataset.welcomeHeroLayout).toBe(
"full-bleed",
);
});
it("moves the accent selected state to the chosen setup option", async () => {
await render();
await act(async () => {
buttonByText("Get started").click();
});
const clineOption = container.querySelector(
'[data-onboarding-option="cline"]',
);
const apiKeyOption = container.querySelector(
'[data-onboarding-option="api-key"]',
);
const apiKeyForm = container.querySelector(
"[data-onboarding-api-key-form]",
);
const apiKeyCardAction = container.querySelector<HTMLButtonElement>(
'button[aria-label="Use your own API key"]',
);
expect(apiKeyCardAction).not.toBeNull();
await act(async () => {
apiKeyCardAction?.click();
});
// Selecting a card moves both the visual state and the accessibility
// boundary, expands its form, and focuses the first usable control.
expect(clineOption?.getAttribute("data-selected")).toBe("false");
expect(apiKeyOption?.getAttribute("data-selected")).toBe("true");
expect(
clineOption
?.querySelector("[data-onboarding-option-content]")
?.hasAttribute("inert"),
).toBe(true);
expect(
apiKeyOption
?.querySelector("[data-onboarding-option-content]")
?.hasAttribute("inert"),
).toBe(false);
expect(apiKeyForm?.getAttribute("aria-hidden")).toBe("false");
expect(document.activeElement?.getAttribute("aria-label")).toBe("Provider");
expect(
container.querySelector('button[aria-label="Use your own API key"]'),
).toBeNull();
const clineCardAction = container.querySelector<HTMLButtonElement>(
'button[aria-label="Sign in with Cline"]',
);
expect(clineCardAction).not.toBeNull();
await act(async () => {
clineCardAction?.click();
});
// Switching back performs the inverse transition and restores focus to
// the primary Cline action.
expect(clineOption?.getAttribute("data-selected")).toBe("true");
expect(apiKeyOption?.getAttribute("data-selected")).toBe("false");
expect(apiKeyForm?.getAttribute("aria-hidden")).toBe("true");
expect(document.activeElement?.textContent?.trim()).toBe("Sign in");
expect(
container.querySelector('button[aria-label="Use your own API key"]'),
).not.toBeNull();
});
it("keeps the Cline API key form chevron static while toggling the panel", async () => {
await render();
await act(async () => {
buttonByText("Get started").click();
});
// The design uses the chevron as a disclosure affordance without rotating
// it; aria-expanded and panel visibility carry the actual state.
const trigger = buttonByText("Use a Cline API key");
const chevron = trigger.querySelector("svg");
const chevronClassName = chevron?.getAttribute("class");
const panel = container.querySelector("#onboarding-cline-key-form");
expect(trigger.getAttribute("aria-controls")).toBe(
"onboarding-cline-key-form",
);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(chevronClassName).toBeTruthy();
expect(panel?.getAttribute("aria-hidden")).toBe("true");
await act(async () => {
trigger.click();
});
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(chevron?.getAttribute("class")).toBe(chevronClassName);
expect(panel?.getAttribute("aria-hidden")).toBe("false");
await act(async () => {
trigger.click();
});
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(chevron?.getAttribute("class")).toBe(chevronClassName);
expect(panel?.getAttribute("aria-hidden")).toBe("true");
});
it("completes without connecting when skipped", async () => {
@@ -166,7 +357,7 @@ describe("OnboardingView", () => {
buttonByText("Get started").click();
});
await act(async () => {
buttonByText("Skip for now").click();
buttonByText("Skip").click();
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
@@ -195,7 +386,17 @@ describe("OnboardingView", () => {
await act(async () => {
buttonByText("Continue").click();
});
// The redesigned completion step places transparent content over a static,
// wide version of the hero grid.
expect(container.textContent).toContain("You're all set");
const doneGrid = container.querySelector<HTMLElement>(
'[data-welcome-hero-variant="grid-only"]',
);
expect(doneGrid?.dataset.welcomeHeroInteractive).toBe("false");
expect(doneGrid?.dataset.welcomeHeroLayout).toBe("wide-grid");
expect(
container.querySelector('[data-welcome-hero-layer="bot-fill"]'),
).toBeNull();
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
@@ -246,7 +447,7 @@ describe("OnboardingView", () => {
});
await act(async () => {
buttonByText("Use a Cline API key instead").click();
buttonByText("Use a Cline API key").click();
});
const keyInput = container.querySelector<HTMLInputElement>(
'input[aria-label="Cline API key"]',
@@ -301,7 +502,7 @@ describe("OnboardingView", () => {
buttonByText("Get started").click();
});
await act(async () => {
buttonByText("Use a Cline API key instead").click();
buttonByText("Use a Cline API key").click();
});
const keyInput = container.querySelector<HTMLInputElement>(
'input[aria-label="Cline API key"]',
@@ -342,15 +543,15 @@ describe("OnboardingView", () => {
expect(savedKeys).toEqual(["bad_key", ""]);
});
it("saves an API key provider and remembers the selection", async () => {
it("keeps Cline sign-in available while API-key setup is expanded", async () => {
const onComplete = await render();
await act(async () => {
buttonByText("Get started").click();
});
// Expand the bring-your-own-key form; drive state through the select's
// props via the API key path (jsdom cannot open the radix listbox).
const expandButton = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.includes("Use your own API key"),
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Use your own API key"]',
);
expect(expandButton).toBeDefined();
await act(async () => {
@@ -358,7 +559,15 @@ describe("OnboardingView", () => {
});
expect(container.textContent).toContain("Choose a provider");
// Sign-in path still available alongside the expanded form.
// Expanding bring-your-own-key changes the selected card, but the user can
// still switch back and finish through the Cline OAuth path.
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Sign in with Cline"]',
)
?.click();
});
invoke.mockImplementation(async (command: string) => {
if (command === "run_provider_oauth_login") {
return { provider: "cline", accessToken: "token" };
@@ -1,17 +1,17 @@
"use client";
import { AgentAurora } from "@cline/ui";
import { Button, IconButton } from "@cline/ui";
import {
ArrowLeft,
CheckCircle2,
ChevronDown,
ExternalLink,
KeyRound,
Loader2,
LogIn,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ClineLogo } from "@/components/cline-logo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -20,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WelcomeHero } from "@/components/views/chat/welcome-hero";
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
@@ -37,6 +38,7 @@ import {
invalidateProviderCatalogCache,
} from "@/lib/provider-model-catalog";
import type { Provider } from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
@@ -46,6 +48,8 @@ type OnboardingConnection =
| { kind: "cline" }
| { kind: "provider"; providerName: string };
type SetupMethod = "cline" | "api-key";
/**
* Providers surfaced first in the bring-your-own-key picker. Everything else
* from the catalog follows alphabetically.
@@ -122,62 +126,172 @@ function rememberProviderSelection(provider: {
});
}
function OnboardingCard({
function OnboardingContent({
children,
wide = false,
surface = "plain",
}: {
children: React.ReactNode;
wide?: boolean;
surface?: "panel" | "plain" | "transparent";
}) {
return (
<div
className={
wide
? "relative z-10 w-full max-w-130 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
: "relative z-10 w-full max-w-105 rounded-3xl border border-border/50 bg-background/80 p-8 shadow-2xl backdrop-blur-2xl max-[720px]:p-6"
}
className={cn(
"relative z-10 w-full max-w-148 rounded-2xl p-8 pb-6 max-[720px]:p-5",
surface === "panel" && "border border-border bg-background",
surface === "plain" && "bg-background",
surface === "transparent" && "bg-transparent",
)}
data-onboarding-content={surface}
>
{children}
</div>
);
}
function SetupOptionCard({
children,
id,
onSelect,
selectLabel,
selected,
}: {
children: React.ReactNode;
id: SetupMethod;
onSelect: () => void;
selectLabel: string;
selected: boolean;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const wasSelectedRef = useRef(selected);
useEffect(() => {
if (selected && !wasSelectedRef.current) {
contentRef.current
?.querySelector<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
?.focus();
}
wasSelectedRef.current = selected;
}, [selected]);
return (
<div
className={cn(
"relative rounded-xl border p-6 pb-8",
selected
? "border-primary/20 bg-primary/4 ring-1 ring-primary/20 ring-inset hover:bg-primary/8"
: "border-border/70 hover:bg-surface-hover-lighter/60",
)}
data-onboarding-option={id}
data-selected={selected}
>
{!selected ? (
<button
aria-label={selectLabel}
className="absolute inset-0 z-10 cursor-pointer rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed"
onClick={onSelect}
type="button"
/>
) : null}
<div
data-onboarding-option-content
inert={!selected ? true : undefined}
ref={contentRef}
>
{children}
</div>
</div>
);
}
function SetupOptionHeader({
accessory,
description,
icon,
title,
}: {
accessory?: React.ReactNode;
description: string;
icon: React.ReactNode;
title: string;
}) {
return (
<div className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-4 text-left max-[720px]:gap-x-3 max-[720px]:gap-y-3">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-foreground/4 text-muted-foreground max-[720px]:mt-0">
{icon}
</span>
<div className="min-w-0 mt-1 max-[720px]:col-span-3 max-[720px]:col-start-1 max-[720px]:row-start-2 max-[720px]:mt-0">
<h4 className="text-lg font-semibold text-foreground">{title}</h4>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
</div>
{accessory ? (
<div className="mt-1 max-[720px]:col-start-3 max-[720px]:row-start-1 max-[720px]:mt-0">
{accessory}
</div>
) : null}
</div>
);
}
function ExpandablePanel({
children,
className,
expanded,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
expanded: boolean;
}) {
return (
<div
{...props}
aria-hidden={!expanded}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-120 ease-in-out motion-reduce:transition-none",
expanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
className,
)}
data-expanded={expanded}
inert={!expanded ? true : undefined}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
);
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function WelcomeStep({ onContinue }: { onContinue: () => void }) {
return (
<OnboardingCard>
<OnboardingContent surface="transparent">
<div className="flex flex-col items-center py-4 text-center">
<img
alt=""
aria-hidden="true"
className="h-28 w-auto drop-shadow-[0_16px_32px_color-mix(in_oklab,var(--brand-violet)_35%,transparent)]"
draggable={false}
height={477}
src="/cline-logo-glass.png"
width={486}
/>
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-foreground">
Cline
</h1>
<p className="mt-2 text-base text-muted-foreground">
Build software your way
</p>
<p className="mt-3 max-w-xs text-sm leading-relaxed text-muted-foreground">
<div className="w-full">
<WelcomeHero variant="bot-only" />
</div>
<h1 className="mt-5 text-4xl font-semibold text-foreground">Cline</h1>
<p className="mt-2 text-lg text-foreground">Build software your way</p>
<p className="mt-6 text-md text-muted-foreground">
Cline is an AI coding agent. It reads your code, edits files, runs
commands, and works through tasks with you in any project on your
machine.
</p>
<Button
className="mt-9 h-11 w-full rounded-full text-base"
className="mt-8 w-full max-w-64"
onClick={onContinue}
size="lg"
tone="accent"
type="button"
variant="fill"
>
Get started
</Button>
<p className="mt-4 text-xs text-muted-foreground">
<p className="mt-8 text-xs text-muted-foreground">
Takes less than a minute. Everything can be changed later in Settings.
</p>
</div>
</OnboardingCard>
</OnboardingContent>
);
}
@@ -193,50 +307,10 @@ function ConnectStep({
const { user, refreshAccount } = useAccount();
const [signingIn, setSigningIn] = useState(false);
const [signInError, setSignInError] = useState<string | null>(null);
const [showClineKeyForm, setShowClineKeyForm] = useState(false);
const [clineApiKey, setClineApiKey] = useState("");
const [clineKeySaving, setClineKeySaving] = useState(false);
const [clineKeyError, setClineKeyError] = useState<string | null>(null);
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
const [providers, setProviders] = useState<Provider[]>([]);
const [providersLoading, setProvidersLoading] = useState(true);
const [providersError, setProvidersError] = useState<string | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadProviders() {
try {
const payload = await fetchProviderCatalog();
if (cancelled) {
return;
}
setProviders(sortProvidersForApiKeySetup(payload.providers ?? []));
setProvidersError(null);
} catch (error) {
if (cancelled) {
return;
}
setProvidersError(
error instanceof Error ? error.message : String(error),
);
} finally {
if (!cancelled) {
setProvidersLoading(false);
}
}
}
void loadProviders();
return () => {
cancelled = true;
};
}, []);
// Increments whenever the user cancels a pending browser sign-in so a
// stale OAuth round-trip (which can dangle until the transport timeout)
// cannot advance or error the UI after the user has moved on.
@@ -266,7 +340,7 @@ function ConnectStep({
if (signInAttemptRef.current !== attempt) {
return;
}
setSignInError(error instanceof Error ? error.message : String(error));
setSignInError(getErrorMessage(error));
} finally {
// The login may have persisted credentials; drop the short-lived
// catalog cache so the app reloads them instead of a pre-save copy.
@@ -335,17 +409,15 @@ function ConnectStep({
api_key: "",
})
.catch(() => undefined);
const message =
verifyError instanceof Error
? verifyError.message
: String(verifyError);
throw new Error(`the key could not be verified (${message})`);
throw new Error(
`the key could not be verified (${getErrorMessage(verifyError)})`,
);
}
rememberProviderSelection({ id: "cline" });
await refreshAccount();
onConnected({ kind: "cline" });
} catch (error) {
setClineKeyError(error instanceof Error ? error.message : String(error));
setClineKeyError(getErrorMessage(error));
} finally {
// Credentials may have been saved (or rolled back); drop the
// short-lived catalog cache so consumers reload the persisted state.
@@ -354,6 +426,43 @@ function ConnectStep({
}
}, [clineApiKey, onConnected, refreshAccount]);
const clineBusy = signingIn || clineKeySaving;
const [providers, setProviders] = useState<Provider[]>([]);
const [providersLoading, setProvidersLoading] = useState(true);
const [providersError, setProvidersError] = useState<string | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadProviders() {
try {
const payload = await fetchProviderCatalog();
if (cancelled) {
return;
}
setProviders(sortProvidersForApiKeySetup(payload.providers ?? []));
setProvidersError(null);
} catch (error) {
if (cancelled) {
return;
}
setProvidersError(getErrorMessage(error));
} finally {
if (!cancelled) {
setProvidersLoading(false);
}
}
}
void loadProviders();
return () => {
cancelled = true;
};
}, []);
const selectedProvider =
providers.find((provider) => provider.id === selectedProviderId) ?? null;
const selectedProviderKeyUrl = selectedProvider
@@ -372,13 +481,16 @@ function ConnectStep({
enabled: true,
api_key: apiKey.trim(),
});
rememberProviderSelection(selectedProvider);
rememberProviderSelection({
id: selectedProviderId,
defaultModelId: selectedProvider.defaultModelId,
});
onConnected({
kind: "provider",
providerName: selectedProvider.name,
});
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
setSaveError(getErrorMessage(error));
} finally {
// Onboarding completion remounts the chat pane to reload provider
// credentials; drop the short-lived catalog cache so that reload
@@ -386,121 +498,149 @@ function ConnectStep({
invalidateProviderCatalogCache();
setSaving(false);
}
}, [apiKey, onConnected, selectedProvider]);
}, [apiKey, onConnected, selectedProvider, selectedProviderId]);
const [selectedMethod, setSelectedMethod] = useState<SetupMethod>("cline");
const [clineKeyFormExpanded, setClineKeyFormExpanded] = useState(false);
return (
<OnboardingCard wide>
<div className="flex items-center gap-2">
<Button
<OnboardingContent surface="panel">
<div className="flex flex-col">
<IconButton
aria-label="Back"
className="-ml-2 size-8 rounded-full p-0 text-muted-foreground"
className="-ml-2"
onClick={onBack}
size="md"
tone="neutral"
type="button"
variant="ghost"
>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
</IconButton>
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-foreground">
Set up Cline
</h1>
<p className="mt-4 text-sm text-muted-foreground">
Choose how Cline connects to models. You can add more providers
anytime in Settings.
</p>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Choose how Cline connects to a model. You can add more providers anytime
in Settings.
</p>
<div className="mt-6 flex flex-col gap-3">
{/* Cline account */}
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<p className="text-base font-semibold text-foreground">
Sign in with Cline
</p>
<Badge className="bg-primary/15 text-primary" variant="secondary">
Recommended
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Latest models with regular free promos. No API keys needed.
</p>
<div className="mt-8 flex flex-col gap-3">
<SetupOptionCard
id="cline"
onSelect={() => setSelectedMethod("cline")}
selectLabel="Sign in with Cline"
selected={selectedMethod === "cline"}
>
<SetupOptionHeader
accessory={
<Badge
className="-mt-2 rounded-sm border-primary/30 bg-primary/10 px-1.5 !pt-[0.3rem] !pb-[0.2rem] text-primary-emphasis"
variant="outline"
>
Recommended
</Badge>
}
description="Latest models with regular free promos. No API keys needed."
icon={<ClineLogo className="size-5" />}
title="Sign in with Cline"
/>
{user ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-foreground">
<div className="mt-6 flex flex-wrap items-center justify-end gap-6">
<p className="text-sm text-muted-foreground">
Signed in as{" "}
<span className="font-medium">
{user.displayName || user.email}
</span>
</p>
<Button
className="rounded-full"
onClick={() => {
rememberProviderSelection({ id: "cline" });
onConnected({ kind: "cline" });
}}
size="md"
tone="accent"
type="button"
variant="fill"
>
Continue
</Button>
</div>
) : (
<div className="mt-3 flex flex-wrap items-center gap-3">
<div className="mt-8 ml-12 flex flex-wrap items-center gap-1 max-[720px]:ml-0">
<Button
className="rounded-full"
disabled={signingIn}
disabled={clineBusy}
onClick={() => void signInWithCline()}
size="md"
tone="accent"
type="button"
variant="fill"
>
{signingIn ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogIn className="size-4" />
)}
{signingIn && <Loader2 className="size-4 animate-spin" />}
{signingIn ? "Waiting for browser..." : "Sign in"}
</Button>
{signingIn ? (
<button
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
<Button
onClick={cancelSignInWithCline}
size="md"
tone="neutral"
type="button"
variant="ghost"
>
Cancel
</button>
</Button>
) : (
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
<Button
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
size="md"
tone="neutral"
type="button"
variant="ghost"
>
Create account
<ExternalLink className="size-3.5" />
</button>
Sign up
</Button>
)}
</div>
)}
{signInError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
<p
className="mt-6 ml-12 text-xs text-destructive max-[720px]:ml-0"
role="alert"
>
Sign in failed: {signInError}
</p>
) : null}
{!user ? (
<div className="mt-3">
<button
aria-expanded={showClineKeyForm}
className="text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onClick={() => setShowClineKeyForm((current) => !current)}
<div className="mt-6 ml-10 -mb-2 max-[720px]:ml-0">
<Button
aria-controls="onboarding-cline-key-form"
aria-expanded={clineKeyFormExpanded}
disabled={clineBusy}
onClick={() => {
setSelectedMethod("cline");
setClineKeyFormExpanded(!clineKeyFormExpanded);
}}
size="xs"
tone="neutral"
type="button"
variant="ghost"
>
{showClineKeyForm
? "Hide Cline API key"
: "Use a Cline API key instead"}
</button>
{showClineKeyForm ? (
<div className="mt-3 flex flex-col gap-2">
Use a Cline API key
<ChevronDown aria-hidden="true" className="size-3.5" />
</Button>
<ExpandablePanel
data-onboarding-cline-key-form
expanded={clineKeyFormExpanded}
id="onboarding-cline-key-form"
>
<div className="flex flex-col gap-2 pt-3 ml-2 max-[720px]:ml-0">
<div className="flex flex-wrap items-center gap-2">
<Input
aria-label="Cline API key"
autoComplete="off"
className="min-w-52 flex-1 bg-background"
disabled={clineKeySaving}
onChange={(event) => {
setClineApiKey(event.target.value);
setClineKeyError(null);
@@ -519,10 +659,12 @@ function ConnectStep({
value={clineApiKey}
/>
<Button
className="rounded-full"
disabled={!clineApiKey.trim() || clineKeySaving}
onClick={() => void connectWithClineApiKey()}
size="md"
tone="accent"
type="button"
variant="fill"
>
{clineKeySaving ? (
<Loader2 className="size-4 animate-spin" />
@@ -530,53 +672,54 @@ function ConnectStep({
{clineKeySaving ? "Connecting..." : "Connect"}
</Button>
</div>
<button
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
<Button
className="self-start -ml-1 mt-1"
disabled={clineKeySaving}
onClick={() => void openExternalUrl(CLINE_DASHBOARD_URL)}
size="xs"
tone="neutral"
type="button"
variant="ghost"
>
Find your key in the Cline dashboard under Account
Find your key
<ExternalLink className="size-3" />
</button>
</Button>
{clineKeyError ? (
<p className="text-xs text-destructive" role="alert">
Failed to save API key: {clineKeyError}
</p>
) : null}
</div>
) : null}
</ExpandablePanel>
</div>
) : null}
</div>
{/* Bring your own key */}
<div className="rounded-2xl border border-border/70 bg-background/60 p-4">
<button
aria-expanded={showApiKeyForm}
className="flex w-full items-start gap-3 text-left"
onClick={() => setShowApiKeyForm((current) => !current)}
type="button"
</SetupOptionCard>
<SetupOptionCard
id="api-key"
onSelect={() => {
setSelectedMethod("api-key");
setClineKeyFormExpanded(false);
}}
selectLabel="Use your own API key"
selected={selectedMethod === "api-key"}
>
<SetupOptionHeader
description="Anthropic, OpenAI, OpenRouter, and more."
icon={<KeyRound className="size-4" />}
title="Use your own API key"
/>
<ExpandablePanel
data-onboarding-api-key-form
expanded={selectedMethod === "api-key"}
>
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
<KeyRound className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-base font-semibold text-foreground">
Use your own API key
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
Anthropic, OpenAI, OpenRouter, and more.
</span>
</span>
</button>
{showApiKeyForm ? (
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-3 pt-6">
{providersError ? (
<p className="text-xs text-destructive" role="alert">
Failed to load providers: {providersError}
</p>
) : (
<Select
disabled={saving}
onValueChange={(value) => {
setSelectedProviderId(value);
setSaveError(null);
@@ -608,6 +751,7 @@ function ConnectStep({
aria-label="API key"
autoComplete="off"
className="bg-background"
disabled={saving}
onChange={(event) => {
setApiKey(event.target.value);
setSaveError(null);
@@ -620,25 +764,29 @@ function ConnectStep({
type="password"
value={apiKey}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
{selectedProviderKeyUrl ? (
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedProvider && selectedProviderKeyUrl ? (
<Button
className="mr-auto"
disabled={saving}
onClick={() => void openExternalUrl(selectedProviderKeyUrl)}
size="xs"
tone="neutral"
type="button"
variant="ghost"
>
{selectedProvider?.docLabel ||
`Get ${selectedProvider ? `a ${selectedProvider.name}` : "an"} API key`}
{selectedProvider.docLabel ||
`Get a ${selectedProvider.name} API key`}
<ExternalLink className="size-3.5" />
</button>
) : (
<span />
)}
</Button>
) : null}
<Button
className="rounded-full"
disabled={!selectedProvider || !apiKey.trim() || saving}
onClick={() => void connectProvider()}
size="md"
tone="accent"
type="button"
variant="fill"
>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{saving ? "Connecting..." : "Connect"}
@@ -650,20 +798,22 @@ function ConnectStep({
</p>
) : null}
</div>
) : null}
</div>
</ExpandablePanel>
</SetupOptionCard>
</div>
<div className="mt-5 flex justify-center">
<button
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
<Button
onClick={onSkip}
size="sm"
tone="neutral"
type="button"
variant="ghost"
>
Skip for now
</button>
Skip
</Button>
</div>
</OnboardingCard>
</OnboardingContent>
);
}
@@ -675,26 +825,29 @@ function DoneStep({
onFinish: () => void;
}) {
return (
<OnboardingCard>
<OnboardingContent surface="transparent">
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 aria-hidden="true" className="size-10 text-primary" />
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-foreground">
You&apos;re all set
</h1>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-3 text-md text-muted-foreground">
{connection?.kind === "provider"
? `${connection.providerName} is connected. Pick a project and start your first session.`
: "Your Cline account is connected. Pick a project and start your first session."}
? `${connection.providerName} is connected.`
: "Your Cline account is connected."}
</p>
<Button
className="mt-8 h-11 w-full rounded-full text-base"
className="mt-8 w-full max-w-64"
onClick={onFinish}
size="lg"
tone="accent"
type="button"
variant="fill"
>
Start building
</Button>
</div>
</OnboardingCard>
</OnboardingContent>
);
}
@@ -717,22 +870,37 @@ export function OnboardingView({
);
return (
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-background p-6">
<AgentAurora />
{step === "welcome" ? (
<WelcomeStep onContinue={() => setStep("connect")} />
) : step === "connect" ? (
<ConnectStep
onBack={() => setStep("welcome")}
onConnected={(nextConnection) => {
setConnection(nextConnection);
setStep("done");
}}
onSkip={onComplete}
/>
) : (
<DoneStep connection={connection} onFinish={onComplete} />
)}
<div className="relative h-full w-full overflow-y-auto bg-background">
<div className="relative flex min-h-full w-full items-center justify-center overflow-hidden p-6">
<div
className={
step === "done"
? "pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2"
: "pointer-events-none absolute inset-0"
}
data-onboarding-grid={step}
>
<WelcomeHero
interactive={step !== "done"}
layout={step === "done" ? "wide-grid" : "full-bleed"}
variant="grid-only"
/>
</div>
{step === "welcome" ? (
<WelcomeStep onContinue={() => setStep("connect")} />
) : step === "connect" ? (
<ConnectStep
onBack={() => setStep("welcome")}
onConnected={(nextConnection) => {
setConnection(nextConnection);
setStep("done");
}}
onSkip={onComplete}
/>
) : (
<DoneStep connection={connection} onFinish={onComplete} />
)}
</div>
</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>
);
}
@@ -10,25 +10,32 @@ 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: the installed inventory of everything that extends
* Cline skills, MCP servers, plugins, rules, hooks, and tools as sub-tabs
* with live counts. Browsing happens on the dedicated Marketplace page,
* reached from the sidebar or the header button here.
*/
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,13 @@ function asCount(value: unknown): number {
return Array.isArray(value) ? value.length : 0;
}
export function PluginsHubView({
export function CustomizeView({
onOpenMarketplace,
}: {
onOpenMarketplace?: () => void;
}) {
const [tab, setTab] = useState<PluginsHubTab>("plugins");
const [counts, setCounts] = useState<HubCounts>({});
const [tab, setTab] = useState<CustomizeTab>("skills");
const [counts, setCounts] = useState<TabCounts>({});
const refreshCounts = useCallback(async () => {
const inventory = await desktopClient
@@ -52,9 +59,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 +82,27 @@ 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">
<Button
onClick={onOpenMarketplace}
size="sm"
type="button"
variant="outline"
>
<Store className="size-4" />
Browse Marketplace
Marketplace
</Button>
) : undefined
}
description="Extend what Cline can do and change how it works. Manage what's installed, or 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 +112,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,20 +138,7 @@ export function PluginsHubView({
})}
</div>
{tab === "plugins" ? (
<CustomizationSectionView
catalogPrimitive="plugin"
chrome="embedded"
marketplaceVariant="installed"
onInventoryChanged={handleInventoryChanged}
section="Plugins"
/>
) : tab === "mcp" ? (
<McpServersContent
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
/>
) : (
{tab === "skills" ? (
<CustomizationSectionView
catalogPrimitive="skill"
chrome="embedded"
@@ -144,6 +146,38 @@ export function PluginsHubView({
onInventoryChanged={handleInventoryChanged}
section="Skills"
/>
) : tab === "mcp" ? (
<McpServersContent
chrome="embedded"
marketplaceVariant="installed"
onInventoryChanged={handleInventoryChanged}
/>
) : tab === "plugins" ? (
<CustomizationSectionView
catalogPrimitive="plugin"
chrome="embedded"
marketplaceVariant="installed"
onInventoryChanged={handleInventoryChanged}
section="Plugins"
/>
) : tab === "rules" ? (
<CustomizationSectionView
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
section="Rules"
/>
) : tab === "hooks" ? (
<CustomizationSectionView
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
section="Hooks"
/>
) : (
<CustomizationSectionView
chrome="embedded"
onInventoryChanged={handleInventoryChanged}
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,
@@ -208,10 +209,13 @@ function createServerFormState(existing?: McpServer): McpServerFormState {
export function McpServersContent({
chrome = "page",
marketplaceVariant = "full",
onInventoryChanged,
}: {
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
chrome?: "page" | "embedded";
/** Which marketplace sections the embedded MarketplaceView shows. */
marketplaceVariant?: "full" | "installed";
/** Invoked whenever the server list is (re)loaded or mutated. */
onInventoryChanged?: () => void;
} = {}) {
@@ -623,9 +627,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 +638,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 +817,7 @@ export function McpServersContent({
installedItems={installedItems}
onInstalledItemsChanged={() => refreshServers()}
primitive="mcp"
variant={chrome === "embedded" ? "installed" : "full"}
variant={marketplaceVariant}
/>
<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",
},
];
@@ -7,10 +7,10 @@ import {
} from "@cline/shared/browser";
import {
CheckCircle2,
ChevronDown,
Circle,
Clock3,
ExternalLink,
Eye,
Pause,
Pencil,
Play,
@@ -64,7 +64,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
@@ -86,6 +85,7 @@ import {
PageFrame,
PageHeader,
} from "../page-layout";
import { ROUTINE_TEMPLATES, type RoutineTemplate } from "./routine-templates";
type DateTimeValue = number | string;
@@ -527,6 +527,15 @@ export function RoutineSchedulesContent({
// rejected synchronously — two rapid clicks can both fire before React
// re-renders the disabled state, and state alone can't distinguish them.
const busyScheduleIdsRef = useRef<Set<string>>(new Set());
// Guards the run-now follow-up: an auto-navigation into the started
// session should not fire from a page the user already left.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const beginScheduleAction = (scheduleId: string): boolean => {
if (busyScheduleIdsRef.current.has(scheduleId)) {
return false;
@@ -550,6 +559,7 @@ export function RoutineSchedulesContent({
return next;
});
};
const [showAllViewingRuns, setShowAllViewingRuns] = useState(false);
const [viewingSchedule, setViewingSchedule] =
useState<RoutineSchedule | null>(null);
const [schedulePendingDelete, setSchedulePendingDelete] =
@@ -773,6 +783,7 @@ export function RoutineSchedulesContent({
lastExecutions,
fetchedAt: now,
};
return response;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
@@ -824,17 +835,62 @@ export function RoutineSchedulesContent({
setScheduleTriggering(schedule.scheduleId, true);
setErrorMessage(null);
try {
await desktopClient.invoke("trigger_routine_schedule", {
const reply = await desktopClient.invoke<{
execution?: RoutineExecution | null;
}>("trigger_routine_schedule", {
schedule_id: schedule.scheduleId,
});
// A reply without an execution means no run was enqueued (the
// schedule may have been disabled or deleted since the page
// loaded) — say so instead of confirming a start.
if (!reply?.execution) {
toast({
title: "Run not started",
description: `"${schedule.name}" did not queue a run — the schedule may be disabled or deleted.`,
variant: "destructive",
});
await refreshSchedules({ force: true, showLoading: false });
return;
}
toast({
title: "Run started",
description: `"${schedule.name}" was queued to run now.`,
});
await refreshSchedules({ force: true, showLoading: false });
window.setTimeout(() => {
void refreshSchedules({ force: true, showLoading: false });
}, 1_000);
// The trigger queues the run and returns before the runner starts
// the agent session, so the session id usually is not attached
// yet. Poll the overview (which also keeps the page's run status
// fresh) until it appears, then jump into the session.
// Only ever follow the execution the trigger itself named; matching
// "the schedule's newest execution" could open a previous run's
// session when the trigger failed to enqueue one.
const executionId = reply.execution.executionId ?? null;
let sessionId = reply.execution.sessionId?.trim() || null;
const deadline = Date.now() + 15_000;
while (
!sessionId &&
executionId &&
mountedRef.current &&
Date.now() < deadline
) {
const overview = await refreshSchedules({
force: true,
showLoading: false,
});
const executions = [
...(overview?.activeExecutions ?? []),
...(overview?.lastExecutions ?? []),
];
const match = executions.find(
(execution) => execution.executionId === executionId,
);
sessionId = match?.sessionId?.trim() || null;
if (!sessionId) {
await new Promise((resolve) => window.setTimeout(resolve, 1_000));
}
}
if (sessionId && mountedRef.current) {
await onOpenSession?.(sessionId);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
@@ -868,7 +924,7 @@ export function RoutineSchedulesContent({
}
};
const openCreateDialog = async () => {
const openCreateDialog = async (template?: RoutineTemplate) => {
setEditingSchedule(null);
setErrorMessage(null);
setCreateFormError(null);
@@ -893,13 +949,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 +1138,23 @@ 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]);
// Collapse the runs list back to the recent-three preview whenever a
// different schedule's details are opened.
const viewingScheduleId = viewingSchedule?.scheduleId ?? null;
// biome-ignore lint/correctness/useExhaustiveDependencies: viewingScheduleId is the reset trigger, not a value the effect reads
useEffect(() => {
setShowAllViewingRuns(false);
}, [viewingScheduleId]);
const viewingExecutions = useMemo(() => {
if (!viewingSchedule) {
return [];
@@ -1097,8 +1177,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 +1210,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">
@@ -1146,10 +1226,35 @@ export function RoutineSchedulesContent({
const upcoming = upcomingRuns.find(
(item) => item.scheduleId === schedule.scheduleId,
);
// The whole card opens the details dialog; clicks on the row's
// own controls (all button elements, including the Radix
// switch) are excluded via closest().
return (
// biome-ignore lint/a11y/useSemanticElements: The card contains nested action buttons and a switch, so the wrapper cannot be a native button.
<div
key={schedule.scheduleId}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
className="cursor-pointer rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
onClick={(event) => {
if (
(event.target as HTMLElement).closest(
"button,a,input,textarea,[role='menuitem']",
)
) {
return;
}
setViewingSchedule(schedule);
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setViewingSchedule(schedule);
}
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-3">
<Circle
@@ -1175,25 +1280,13 @@ export function RoutineSchedulesContent({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => setViewingSchedule(schedule)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>View details</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
size="icon"
className="size-7"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit schedule</TooltipContent>
@@ -1202,15 +1295,16 @@ export function RoutineSchedulesContent({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
size="icon"
className="size-7"
aria-label={`Run ${schedule.name} now`}
onClick={() => void triggerSchedule(schedule)}
disabled={isBusy}
>
{triggeringScheduleIds.has(schedule.scheduleId) ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
<RefreshCw className="size-4 animate-spin" />
) : (
<PlayIcon className="h-3.5 w-3.5" />
<PlayIcon className="size-4" />
)}
</Button>
</TooltipTrigger>
@@ -1220,7 +1314,8 @@ export function RoutineSchedulesContent({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
size="icon"
className="size-7"
aria-label={
schedule.enabled
? `Pause ${schedule.name}`
@@ -1235,9 +1330,9 @@ export function RoutineSchedulesContent({
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
<Pause className="size-4" />
) : (
<Play className="h-3.5 w-3.5" />
<Play className="size-4" />
)}
</Button>
</TooltipTrigger>
@@ -1251,12 +1346,13 @@ export function RoutineSchedulesContent({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
size="icon"
className="size-7"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete schedule</TooltipContent>
@@ -1343,6 +1439,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) => {
@@ -1351,124 +1486,121 @@ export function RoutineSchedulesContent({
}
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogContent
aria-describedby={undefined}
className="flex max-h-[85vh] flex-col sm:max-w-2xl"
>
<DialogHeader>
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
<DialogDescription>
Full configuration for this schedule.
</DialogDescription>
</DialogHeader>
{viewingSchedule && (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="runs">
Runs
{viewingExecutions.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
{viewingExecutions.length}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent
className="mt-4 flex flex-col gap-3"
value="overview"
>
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
<p>
<span className="text-muted-foreground/70">Schedule:</span>{" "}
{formatScheduleTrigger(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Mode:</span>{" "}
{viewingSchedule.mode}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Enabled:</span>{" "}
{viewingSchedule.enabled ? "yes" : "no"}
</p>
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(viewingSchedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(viewingSchedule.nextRunAt)}
</p>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
<p>
<span className="text-muted-foreground/70">Schedule:</span>{" "}
{formatScheduleTrigger(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Mode:</span>{" "}
{viewingSchedule.mode}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Enabled:</span>{" "}
{viewingSchedule.enabled ? "yes" : "no"}
</p>
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(viewingSchedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(viewingSchedule.nextRunAt)}
</p>
</div>
{/* The JSON block scrolls internally past its cap so it
cannot push the runs below it out of easy reach. */}
<pre className="max-h-64 shrink-0 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{JSON.stringify(viewingSchedule, null, 2)}
</pre>
<div className="mt-1 flex items-center justify-between">
<h3 className="text-sm font-semibold">Runs</h3>
<span className="text-xs text-muted-foreground">
{viewingExecutions.length} result
{viewingExecutions.length === 1 ? "" : "s"}
</span>
</div>
{viewingExecutions.length === 0 ? (
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
No runs yet.
</div>
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{JSON.stringify(viewingSchedule, null, 2)}
</pre>
</TabsContent>
<TabsContent className="mt-4" value="runs">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Runs</h3>
<span className="text-xs text-muted-foreground">
{viewingExecutions.length} result
{viewingExecutions.length === 1 ? "" : "s"}
</span>
</div>
{viewingExecutions.length === 0 ? (
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
No runs yet.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
{viewingExecutions.map((execution) => {
const status = execution.status?.toLowerCase() ?? "";
const succeeded = ["success", "completed"].includes(
status,
);
const failed = ["failed", "timeout", "aborted"].includes(
status,
);
return (
<button
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
disabled={!execution.sessionId || !onOpenSession}
key={execution.executionId}
onClick={() => {
if (execution.sessionId) {
void onOpenSession?.(execution.sessionId);
}
}}
type="button"
>
{succeeded ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
) : failed ? (
<XCircle className="size-4 shrink-0 text-destructive" />
) : (
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium capitalize">
{execution.status || "Unknown result"}
) : (
<div className="overflow-hidden rounded-lg border border-border">
{(showAllViewingRuns
? viewingExecutions
: viewingExecutions.slice(0, 3)
).map((execution) => {
const status = execution.status?.toLowerCase() ?? "";
const succeeded = ["success", "completed"].includes(status);
const failed = ["failed", "timeout", "aborted"].includes(
status,
);
return (
<button
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
disabled={!execution.sessionId || !onOpenSession}
key={execution.executionId}
onClick={() => {
if (execution.sessionId) {
void onOpenSession?.(execution.sessionId);
}
}}
type="button"
>
{succeeded ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
) : failed ? (
<XCircle className="size-4 shrink-0 text-destructive" />
) : (
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium capitalize">
{execution.status || "Unknown result"}
</span>
{execution.errorMessage && (
<span className="block truncate text-xs text-destructive">
{execution.errorMessage}
</span>
{execution.errorMessage && (
<span className="block truncate text-xs text-destructive">
{execution.errorMessage}
</span>
)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatExecutionTimestamp(execution)}
</span>
{execution.sessionId && onOpenSession && (
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</button>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatExecutionTimestamp(execution)}
</span>
{execution.sessionId && onOpenSession && (
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</button>
);
})}
</div>
)}
{!showAllViewingRuns && viewingExecutions.length > 3 ? (
<Button
className="self-start text-muted-foreground"
onClick={() => setShowAllViewingRuns(true)}
size="sm"
type="button"
variant="ghost"
>
Show all {viewingExecutions.length} runs
<ChevronDown className="size-3.5" />
</Button>
) : null}
</div>
)}
</DialogContent>
</Dialog>
@@ -1536,7 +1668,7 @@ export function RoutineSchedulesContent({
</DialogHeader>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<div className="sm:col-span-2 space-y-2">
<Label htmlFor="routine-name">Name</Label>
<Input
id="routine-name"
@@ -1554,7 +1686,7 @@ export function RoutineSchedulesContent({
<div className="sm:col-span-2 space-y-3">
<Label>Schedule</Label>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border p-3">
<div className="min-w-32 flex-1">
<div className="min-w-32 flex-1 space-y-2">
<Label htmlFor="routine-schedule-type">Frequency</Label>
<Select
onValueChange={(value) =>
@@ -1582,7 +1714,7 @@ export function RoutineSchedulesContent({
</Select>
</div>
{createForm.scheduleType === "once" && (
<div className="min-w-40 flex-1">
<div className="min-w-40 flex-1 space-y-2">
<Label htmlFor="routine-date">Date</Label>
<Input
id="routine-date"
@@ -1618,7 +1750,7 @@ export function RoutineSchedulesContent({
</div>
)}
{createForm.scheduleType === "weekly" && (
<div className="min-w-44 flex-[1.4]">
<div className="min-w-44 flex-[1.4] space-y-2">
<Label>Days</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1661,7 +1793,7 @@ export function RoutineSchedulesContent({
</DropdownMenu>
</div>
)}
<div className="min-w-32 flex-1">
<div className="min-w-32 flex-1 space-y-2">
<Label htmlFor="routine-time">Time</Label>
<Input
id="routine-time"
@@ -1694,7 +1826,7 @@ export function RoutineSchedulesContent({
</div>
</div>
<div className="sm:col-span-2">
<div className="sm:col-span-2 space-y-2">
<Label htmlFor="routine-prompt">Prompt</Label>
<Textarea
id="routine-prompt"
@@ -1709,7 +1841,7 @@ export function RoutineSchedulesContent({
/>
</div>
<div>
<div className="space-y-2">
<Label>Provider</Label>
<Combobox
items={availableProviders}
@@ -1753,7 +1885,7 @@ export function RoutineSchedulesContent({
</Combobox>
</div>
<div>
<div className="space-y-2">
<Label>Model</Label>
<Combobox
items={availableModelsForProvider}
@@ -1784,7 +1916,7 @@ export function RoutineSchedulesContent({
</Combobox>
</div>
<div className="sm:col-span-2">
<div className="sm:col-span-2 space-y-2">
<Label htmlFor="routine-workspace">Workspace</Label>
<Input
id="routine-workspace"
@@ -1798,7 +1930,7 @@ export function RoutineSchedulesContent({
/>
</div>
<div className="sm:col-span-2">
<div className="sm:col-span-2 space-y-2">
<Label htmlFor="routine-system-prompt">
System prompt (optional)
</Label>
@@ -1815,7 +1947,7 @@ export function RoutineSchedulesContent({
/>
</div>
<div>
<div className="space-y-2">
<Label htmlFor="routine-timeout">
Timeout seconds (optional)
</Label>
@@ -1832,7 +1964,7 @@ export function RoutineSchedulesContent({
/>
</div>
<div>
<div className="space-y-2">
<Label htmlFor="routine-tags">
Tags (comma-separated, optional)
</Label>
@@ -5,26 +5,43 @@
* 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. "Customize" is the installed
// inventory (labeled "Installed" in the sidebar group); "Marketplace" is the
// dedicated browse-and-install directory.
const ALL_CUSTOMIZATION_SECTIONS = ["Customize", "Marketplace"] as const;
// Sidebar labels for the Customize group: the Customize section shows what is
// installed, so its row reads "Installed" next to the Marketplace row.
export const CUSTOMIZATION_SECTION_LABELS: Record<
(typeof ALL_CUSTOMIZATION_SECTIONS)[number],
string
> = {
Customize: "Installed",
Marketplace: "Marketplace",
};
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,
@@ -54,12 +64,8 @@ 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 +73,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 +95,6 @@ let providerCatalogCache: {
providers: Provider[];
fetchedAt: number;
} | null = null;
let voiceInputCache: VoiceInputSelection | undefined;
// -----------------------------------------------------------
// Component
@@ -126,10 +132,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 +165,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 +241,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 +261,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 +390,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 +443,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 +482,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 +523,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 +563,29 @@ 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
<>
{providerContent}
{addProviderDialog}
</>
) : activeNav === "Voice" ? (
<VoiceInputContent
onOpenModelProviders={() => onNavigateSection("Models")}
/>
) : activeNav === "Customize" ? (
<CustomizeView
onOpenMarketplace={() => onNavigateSection("Marketplace")}
/>
) : activeNav === "Marketplace" ? (
<MarketplaceView
onInstalledItemsChanged={invalidateExtensionInventoryCache}
onOpenInstalled={() => onNavigateSection("Customize")}
variant="directory"
/>
) : activeNav === "Hooks" ? (
<CustomizationSectionView section="Hooks" />
) : activeNav === "Rules" ? (
<CustomizationSectionView section="Rules" />
) : activeNav === "Agents" ? (
<CustomizationSectionView section="Agents" />
) : activeNav === "Tools" ? (
<CustomizationSectionView section="Tools" />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
@@ -0,0 +1,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,344 @@
"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 (
// biome-ignore lint/a11y/useSemanticElements: the model picker is a styled radiogroup of buttons; aria-checked + role convey the semantics, and an <input type="radio"> would need a full restyle.
<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,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { resolveCredentialError } from "./helpers";
import { inferHydratedChatStatus, resolveCredentialError } from "./helpers";
function makeConfig(overrides: Partial<ChatSessionConfig>): ChatSessionConfig {
return {
@@ -53,3 +53,30 @@ describe("resolveCredentialError", () => {
).toBeNull();
});
});
describe("inferHydratedChatStatus", () => {
it("treats an assistant-answered running record as completed", () => {
// The stale-record heuristic: a "running" record whose transcript
// ends on an assistant answer is read as a session that died without
// a status flip. (The stale-stream poll deliberately bypasses this
// via mapSessionRecordStatus — see use-chat-session.)
expect(
inferHydratedChatStatus("running", [
{
id: "u",
sessionId: "s",
role: "user",
content: "prompt",
createdAt: 1,
},
{
id: "a",
sessionId: "s",
role: "assistant",
content: "answer",
createdAt: 2,
},
]),
).toBe("completed");
});
});
@@ -221,3 +221,16 @@ export function inferHydratedChatStatus(
}
return mapHistoryStatusToChatStatus(fallback);
}
/**
* The session record's status mapped verbatim no transcript inference. For
* callers observing a session whose record is actively maintained by the
* executing host (the stale-stream poll), the record is the authority;
* inferHydratedChatStatus's stale-record heuristic would misread a mid-run
* snapshot that happens to end on assistant narration as a finished session.
*/
export function mapSessionRecordStatus(
status: SessionHistoryStatus,
): ChatSessionStatus {
return mapHistoryStatusToChatStatus(status);
}
@@ -166,6 +166,197 @@ describe("useChatSession", () => {
});
});
it("heals a running attached session with a dead event stream by polling history", async () => {
// Scheduled runs can execute on a host whose live events never reach
// this client; the transcript must still settle without a remount.
const hydratedSessionId = "session-dead-stream";
let readCount = 0;
let recordReads = 0;
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "read_session_messages") {
readCount += 1;
const base = [
{
id: "history-user",
sessionId: hydratedSessionId,
role: "user",
content: "tell me the current time",
createdAt: 1,
},
];
return readCount === 1
? base
: [
...base,
{
id: "history-answer",
sessionId: hydratedSessionId,
role: "assistant",
content: "It is 12:28 PM PT.",
createdAt: 2,
},
];
}
if (command === "get_discovered_session") {
recordReads += 1;
// Still running on the first poll — the snapshot already
// ends on assistant narration, which must NOT read as
// finished while the record says running.
return {
sessionId: hydratedSessionId,
status: recordReads === 1 ? "running" : "completed",
};
}
if (command === "read_session_hooks") return [];
if (command === "chat_session_command") {
const request = args?.request as { action?: string } | undefined;
if (request?.action === "attach") {
return {
sessionId: hydratedSessionId,
status: "running",
provider: "cline",
model: "test-model",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
return { promptsInQueue: [] };
}
return [];
},
);
// Fake timers must be active before hydration so the fallback's
// interval registers on the fake clock.
vi.useFakeTimers();
try {
await act(async () => {
await current.hydrateSession({
sessionId: hydratedSessionId,
status: "running",
provider: "cline",
model: "test-model",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
startedAt: "2026-08-12T00:00:00.000Z",
});
});
expect(current.status).toBe("running");
expect(current.messages).toHaveLength(1);
// No chat_event chunks arrive. The first poll surfaces the
// narration mid-run; the record still says running, and the
// record — not transcript shape — decides the status.
await act(async () => {
await vi.advanceTimersByTimeAsync(3_100);
});
expect(current.messages).toHaveLength(2);
expect(current.messages[1]?.content).toBe("It is 12:28 PM PT.");
expect(current.status).toBe("running");
// The record flips to completed; the next poll mirrors it.
await act(async () => {
await vi.advanceTimersByTimeAsync(3_100);
});
} finally {
vi.useRealTimers();
}
expect(current.status).toBe("completed");
});
it("keeps the stale-stream poll inert while a local turn is in flight", async () => {
// Regression: the fallback poll replaced an optimistic user bubble
// (raw prompt) with its canonical envelope-wrapped twin, desyncing
// the rekey bookkeeping so the stream appended a duplicate bubble.
const hydratedSessionId = "session-local-turn";
let readCount = 0;
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "read_session_messages") {
readCount += 1;
return [
{
id: "history-user",
sessionId: hydratedSessionId,
role: "user",
content: "earlier prompt",
createdAt: 1,
},
];
}
if (command === "get_discovered_session") {
return { sessionId: hydratedSessionId, status: "running" };
}
if (command === "read_session_hooks") return [];
if (command === "chat_session_command") {
const request = args?.request as { action?: string } | undefined;
if (request?.action === "attach" || request?.action === "start") {
return {
sessionId: hydratedSessionId,
status: "idle",
provider: "cline",
model: "test-model",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
if (request?.action === "send") {
// Keep the send unresolved: the local turn stays in
// flight for the whole test.
return await new Promise(() => {});
}
return { promptsInQueue: [] };
}
return [];
},
);
vi.useFakeTimers();
try {
await act(async () => {
await current.hydrateSession({
sessionId: hydratedSessionId,
status: "idle",
provider: "cline",
model: "test-model",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
startedAt: "2026-08-12T00:00:00.000Z",
});
});
const readsAfterHydration = readCount;
await act(async () => {
void current.sendPrompt("what time is it");
await Promise.resolve();
});
expect(current.status).toBe("starting");
expect(
current.messages.filter((m) => m.content === "what time is it"),
).toHaveLength(1);
// Model produces nothing for a long quiet window; the poll must
// not fire while the local turn is unsettled.
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(readCount).toBe(readsAfterHydration);
expect(
current.messages.filter((m) => m.content === "what time is it"),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("routes command updates after attaching to an in-flight tool call", async () => {
const hydratedSessionId = "session-in-flight-command";
invokeMock.mockImplementation(
@@ -11,6 +11,7 @@ import {
extractAssistantTurnDataFromRpcMessages,
inferHydratedChatStatus,
makeId,
mapSessionRecordStatus,
normalizeRuntimeConfig,
resolveCredentialError,
} from "@/hooks/chat-session/helpers";
@@ -91,6 +92,12 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
"stopping",
]);
// Stale-stream fallback cadence for attached sessions (see the polling
// effect below): only poll after the live stream has been quiet this long,
// and re-check at this interval while it stays quiet.
const STALE_STREAM_QUIET_MS = 5_000;
const STALE_STREAM_POLL_INTERVAL_MS = 3_000;
type PendingToolOutput = {
text: string;
truncated: boolean;
@@ -382,6 +389,9 @@ export function useChatSession() {
>([]);
const [promptsInQueue, setPromptsInQueue] = useState<PromptInQueue[]>([]);
const messagesRef = useRef<ChatMessage[]>([]);
// When the last chat_event chunk for the active session arrived. The
// stale-stream fallback below only polls while this stays quiet.
const lastLiveChunkAtRef = useRef(0);
const promptsInQueueRef = useRef<PromptInQueue[]>([]);
const liveToolMessageIdsRef = useRef<Record<string, string>>({});
const pendingToolOutputRef = useRef(new Map<string, PendingToolOutput>());
@@ -1224,6 +1234,7 @@ export function useChatSession() {
if (!listeningSessionId || payload.sessionId !== listeningSessionId) {
return;
}
lastLiveChunkAtRef.current = Date.now();
if (abortedRef.current) {
return;
}
@@ -1800,6 +1811,119 @@ export function useChatSession() {
};
}, [clearLiveToolRefs, finalizeSettledTurn]);
// ---- Stale-stream fallback for attached sessions ----
// Scheduled/automation runs execute on a session host whose events are
// not projected through the hub's live pipeline (and with several hub
// daemons sharing cron.db, a different daemon can claim the run
// entirely), so an attached session can sit at "running" with a dead
// event stream — stuck on the thinking shimmer until a remount re-reads
// history. While an attached session is busy and the stream is quiet,
// poll canonical history and the session record so the transcript and
// status heal in place. A locally driven turn keeps chunks flowing, so
// the quiet-window guard keeps this fallback out of the way there.
useEffect(() => {
if (!sessionId || hydratedHistorySessionId !== sessionId) {
return;
}
if (!BUSY_STATUSES.has(status)) {
return;
}
let cancelled = false;
let polling = false;
const poll = async () => {
if (cancelled || polling) {
return;
}
if (Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS) {
return;
}
// An assistant bubble mid-stream means the live pipeline works;
// canonical history could lag behind it.
if (activeAssistantMessageIdRef.current) {
return;
}
// A locally driven turn is in flight (submit/queue bumps the epoch;
// settling closes it). Its optimistic user bubble carries the raw
// prompt while canonical history stores it wrapped in a
// user_input envelope, so replacing state mid-turn desyncs the
// rekey bookkeeping and the stream then appends a duplicate
// bubble. The fallback exists for externally driven runs
// (schedules, other clients) — stay inert until the local turn
// settles.
if (
turnEpochRef.current !== turnSettledEpochRef.current ||
outstandingOptimisticUserIdsRef.current.size > 0
) {
return;
}
polling = true;
try {
const pollStartedAt = Date.now();
const [historyMessages, record] = await Promise.all([
desktopClient
.invoke<ChatMessage[]>("read_session_messages", {
sessionId,
maxMessages: MAX_MESSAGES,
})
.catch(() => null),
desktopClient
.invoke<{ status?: string } | null>("get_discovered_session", {
sessionId,
})
.catch(() => null),
]);
if (
cancelled ||
activeSessionIdRef.current !== sessionId ||
// The live stream resumed (or a local turn started) while
// the poll was in flight; live state is fresher than the
// snapshot we just read.
Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS ||
activeAssistantMessageIdRef.current ||
turnEpochRef.current !== turnSettledEpochRef.current ||
outstandingOptimisticUserIdsRef.current.size > 0
) {
return;
}
if (Array.isArray(historyMessages) && historyMessages.length > 0) {
const mergedMessages = mergeHydratedMessagesWithLive({
hydrated: historyMessages,
current: messagesRef.current,
sessionId,
hydrationStartedAt: pollStartedAt,
});
// Same as hydration: canonical rows may have replaced live
// tool rows, so rebuild the tool routing keys or later
// tool events would append instead of updating in place.
const liveToolState = deriveLiveToolState(mergedMessages);
liveToolMessageIdsRef.current = liveToolState.messageIds;
liveToolInputsRef.current = liveToolState.inputs;
setMessages(mergedMessages);
}
// The record is the authority here: the sessions this poll
// serves have a live host maintaining their record, and it
// flips to a terminal status when the run ends. Transcript
// inference (inferHydratedChatStatus) would misread a mid-run
// snapshot ending on assistant narration as finished, hiding
// the working indicator and disarming this poll.
const nextStatus = record?.status?.trim();
if (nextStatus) {
setStatus(mapSessionRecordStatus(nextStatus as SessionHistoryStatus));
}
} finally {
polling = false;
}
};
const interval = window.setInterval(
() => void poll(),
STALE_STREAM_POLL_INTERVAL_MS,
);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [hydratedHistorySessionId, sessionId, status]);
// ---- Shared: start a new session via RPC ----
const startSession = useCallback(
@@ -2742,6 +2866,10 @@ export function useChatSession() {
activeSessionIdRef.current = session.sessionId;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
// A freshly hydrated session has no local turn in flight; without
// this the mount defaults (epoch 0, settled -1) read as an open
// turn and keep the stale-stream fallback inert forever.
turnSettledEpochRef.current = turnEpochRef.current;
setHydratedHistorySessionId(session.sessionId);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
@@ -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;
}
@@ -118,6 +118,56 @@ describe("useSessionHistory session mapping", () => {
current.threads.find((thread) => thread.id === "regular-session"),
).toMatchObject({ source: "core", isScheduled: false });
});
it("marks sessions scheduled when a schedule execution names them", async () => {
// Scheduled runs executed by the local hub don't reliably stamp the
// hub-schedule trigger into session metadata, so the executions list
// is the fallback signal.
invokeMock.mockImplementation(
async (command: string, args?: { limit?: number }) => {
if (command === "list_discovered_sessions") {
return await new Promise<unknown[]>((resolve, reject) => {
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
});
}
if (command === "list_routine_schedules") {
return {
activeExecutions: [{ sessionId: "cron-active" }],
lastExecutions: [{ sessionId: "cron-session" }, {}],
};
}
return [];
},
);
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve([
{
...sessionRow("cron-session"),
source: "core",
metadata: { sessionHistoryOrigin: { mode: "user" } },
},
{
...sessionRow("regular-session"),
source: "core",
metadata: { sessionHistoryOrigin: { mode: "user" } },
},
]);
await Promise.resolve();
});
expect(
current.threads.find((thread) => thread.id === "cron-session"),
).toMatchObject({ isScheduled: true });
expect(
current.threads.find((thread) => thread.id === "regular-session"),
).toMatchObject({ isScheduled: false });
});
});
describe("useSessionHistory initial load", () => {
@@ -504,6 +504,14 @@ export function useSessionHistory({
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
() => new Set(),
);
// Session ids that schedule executions report as their own. Scheduled runs
// executed by the local hub do not reliably carry the "hub-schedule"
// origin trigger in their session metadata (the runtime that claims the
// run doesn't always stamp provenance), so the metadata check alone would
// miss them; the executions list is the authoritative link.
const [scheduledSessionIds, setScheduledSessionIds] = useState<Set<string>>(
() => new Set(),
);
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
// Limit of the most recent refresh that actually returned sessions. Failed
// attempts roll back to this rather than to a caller-local snapshot, which
@@ -553,6 +561,52 @@ export function useSessionHistory({
});
}, [activeSessionId]);
useEffect(() => {
let cancelled = false;
const collectScheduledSessionIds = async () => {
const response = await desktopClient
.invoke<{
activeExecutions?: Array<{ sessionId?: unknown }>;
lastExecutions?: Array<{ sessionId?: unknown }>;
}>("list_routine_schedules")
.catch(() => null);
if (cancelled || !response) {
return;
}
const ids = new Set<string>();
for (const execution of [
...(response.activeExecutions ?? []),
...(response.lastExecutions ?? []),
]) {
const sessionId =
typeof execution?.sessionId === "string"
? execution.sessionId.trim()
: "";
if (sessionId) {
ids.add(sessionId);
}
}
setScheduledSessionIds((current) => {
// Merge instead of replace: the executions list is a rolling
// window, so ids that fell out of it are still scheduled runs.
const next = new Set(current);
for (const id of ids) {
next.add(id);
}
return next.size === current.size ? current : next;
});
};
void collectScheduledSessionIds();
const interval = window.setInterval(
() => void collectScheduledSessionIds(),
2 * 60 * 1000,
);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
const refreshSessions = useCallback(async () => {
// Reuse an in-flight refresh only when it already asked for at least as
// many sessions as we need now. "Load more" raises the limit and then
@@ -1209,7 +1263,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 +1282,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
@@ -1429,6 +1483,17 @@ export function useSessionHistory({
[sessions],
);
const threadsWithScheduled = useMemo(() => {
if (scheduledSessionIds.size === 0) {
return threads;
}
return threads.map((thread) =>
!thread.isScheduled && scheduledSessionIds.has(thread.id)
? { ...thread, isScheduled: true }
: thread,
);
}, [scheduledSessionIds, threads]);
return {
getSessionByThreadId,
hasLoadedHistory,
@@ -1446,7 +1511,7 @@ export function useSessionHistory({
forkThread,
sessionById,
sessions,
threads,
threads: threadsWithScheduled,
unreadSessionIds,
};
}
@@ -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;
@@ -2,7 +2,10 @@ import { isChatWorkspacePath } from "@cline/shared/browser";
import type { SessionThread } from "@/hooks/use-session-history";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
export const INITIAL_VISIBLE_THREAD_COUNT = 10;
// One page of sidebar rows. Large enough to fill the sidebar on a tall
// window (10 left a stub of rows over empty space); history fetches start at
// 50, so the first page never needs an extra request.
export const INITIAL_VISIBLE_THREAD_COUNT = 30;
export type SidebarProjectGroup = {
id: string;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

@@ -0,0 +1,3 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M118.894 28.2461C118.894 19.3941 111.59 12.1251 102.222 12.125C92.8539 12.125 85.5499 19.394 85.5499 28.2461C85.5499 30.3758 85.9304 33.1689 86.7003 36.542L88.3927 43.9531H49.1749C40.8046 43.9531 34.0189 50.7388 34.0187 59.1094V87.875L13.2052 112.156L34.0187 136.437V165.203C34.0187 173.574 40.8044 180.359 49.1749 180.359H155.269C163.639 180.359 170.425 173.574 170.425 165.203V136.437L191.236 112.156L170.425 87.875V59.1094C170.425 50.739 163.639 43.9533 155.269 43.9531H116.051L117.742 36.542C118.512 33.1689 118.894 30.3758 118.894 28.2461ZM131.012 28.8877C130.992 29.8428 130.928 30.8253 130.827 31.8281H155.269C170.336 31.8283 182.55 44.0428 182.55 59.1094V83.3906L202.345 106.485C205.141 109.749 205.142 114.564 202.345 117.828L182.55 140.921V165.203C182.55 180.27 170.336 192.484 155.269 192.484H49.1749C34.1079 192.484 21.8937 180.27 21.8937 165.203V140.923L2.09777 117.828C-0.699604 114.564 -0.69891 109.749 2.09777 106.485L21.8937 83.3896V59.1094C21.8939 44.0427 34.1079 31.8281 49.1749 31.8281H73.6163C73.5159 30.8253 73.4516 29.8428 73.4318 28.8877L73.4249 28.2461C73.4249 12.3814 86.478 0 102.222 0C117.965 0.00014284 131.019 12.3814 131.019 28.2461L131.012 28.8877Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.222 11.625C92.5893 11.6251 85.0497 19.1063 85.0497 28.2461L85.0546 28.6631C85.1002 30.7847 85.4871 33.4739 86.2128 36.6533L87.7655 43.4531H49.1747C40.5283 43.4532 33.5187 50.4628 33.5185 59.1094V87.6895L12.8251 111.831L12.5468 112.156L12.8251 112.481L33.5185 136.621V165.203C33.5185 173.85 40.528 180.859 49.1747 180.859H155.269C163.915 180.859 170.925 173.85 170.925 165.203V136.621L191.616 112.481L191.895 112.156L191.616 111.831L170.925 87.6895V59.1094L170.92 58.7051C170.705 50.2454 163.78 43.4533 155.269 43.4531H116.678L118.229 36.6533C119.004 33.262 119.394 30.4282 119.394 28.2461L119.388 27.8193C119.157 18.8746 111.704 11.6251 102.222 11.625ZM22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.3 0V400M40.3 0V400M80.3 0V400M120.3 0V400M160.3 0V400M200.3 0V400M240.3 0V400M280.3 0V400M320.3 0V400M360.3 0V400" stroke="black" stroke-width="0.6"/>
<path d="M0 0.3H400M0 40.3H400M0 80.3H400M0 120.3H400M0 160.3H400M0 200.3H400M0 240.3H400M0 280.3H400M0 320.3H400M0 360.3H400" stroke="black" stroke-width="0.6"/>
<path d="M0 0L400 400M400 0L0 400" stroke="black" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

@@ -0,0 +1,3 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" fill="black" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

+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.10",
"version": "4.1.16",
"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)
})
})
})

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