Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 7e31fb9e0d chore(vscode): prepare 4.1.8 release 2026-08-10 20:03:53 -07:00
John SimoneandSaoud Rizwan e0eb0167da feat(vscode): add Fable 5 + custom model IDs to Vertex; drop global-region picker filter (#12461)
* add custom model selection to the vertex provider

* fix race conditions from PR review

* fix linter warnings

* fix test failures

* refactor(vscode): drop Vertex global-endpoint picker filtering

The SDK catalog is live (models.dev), so a static host allowlist of
global-endpoint-capable models lags every model launch and silently hides
new models from users on vertexRegion=global. Remove the allowlist, the
host override that injected supportsGlobalEndpoint, and the picker filter;
show the full catalog for every region.

An unsupported pick now fails loudly at request time: map Vertex's
'model not available in region: global' (and Google's Publisher Model
locations/global not-found body) to recovery guidance in the error row.

Also drop Anthropic's universal pricing from the Vertex Fable 5 overlay —
Vertex bills region-dependently, so the copied price understated recorded
cost; the record now carries no pricing instead of a wrong one.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-10 19:58:20 -07:00
Mikołaj Kondratek d5748b2939 fix: remove stale Double-Check Completion feature tip (#13147)
* fix: remove stale Double-Check Completion feature tip

The rotating feature tips still told users to enable "Double-Check
Completion" in settings, but that toggle was removed in the new UI —
the Features section now offers Auto Compact, Feature Tips, Background
Edit, Checkpoints, Worktrees and Hooks. Following the tip sent users
searching the settings panel for something that isn't there.

Drop the tip. The remaining ten were checked against the current UI and
all still hold, including the "Settings → Features → Feature Tips" path.

* chore: remove dead CLI settings e2e page object and orphaned test

`page-objects/settings.ts` asserted the CLI settings Features tab shows
"Double-check completion" — the same removed setting behind the stale
feature tip. Nothing in the live tui-test suite (apps/cli/src/tests)
imported it; only chat.ts and auth.ts page objects are in use.

Its one importer, apps/vscode/tests/e2e/cli/interactive.test.ts, is a
leftover from the pre-2026-06-02 SDK migration squash: all three of its
imports resolve to files that don't exist, there's no tui-test config in
that tree, and no npm script runs it. It cannot execute.
2026-08-11 00:54:41 +02:00
Mikołaj Kondratek ffd6a6b1db fix: respect user max output tokens in compaction summarizer requests (#13137)
* fix: respect user max output tokens in compaction summarizer requests

The compaction summarizer hardcoded max_tokens to 1024 and the VSCode host
never mirrored the user's Max Output Tokens onto providerConfig, so summary
requests were always capped at 1024 tokens. Reasoning models can spend that
entire budget thinking; the reasoning stream is discarded, so no summary
text arrives and compaction is skipped on every attempt.

- Mirror maxTokensPerTurn onto providerConfig.maxOutputTokens in the VSCode
  session factory so consumers that build handlers straight from it (the
  compaction summarizer) honor the user's setting, matching the CLI.
- Resolve the summarizer output budget from explicit config, then model
  info, then knownModels, before the default; raise the default to 4096.
- Log a diagnostic warning (reasoning chars, incompleteReason, likely
  cause) when the summarizer returns no summary text instead of silently
  skipping.

* fix: clamp summarizer default output budget by model metadata instead of adopting it

Model maxTokens is reported capability, not a product default: without an
explicit configuration the summarizer now requests the 4096 default, lowered
by model metadata when the model reports less, never raised by it. Explicit
values still win as-is.
2026-08-11 00:20:45 +02:00
cline-cloud[bot]andCline 51784a3bf1 docs: add Qwen3.8 Max to ClinePass model list and reference pricing (#13144)
Co-authored-by: Cline <cline@users.noreply.github.com>
2026-08-10 14:52:43 -07:00
Saoud RizwanandSaoud Rizwan 149abb0ddb feat(vscode): remove YOLO mode setting, migrate old users to auto-approve all (#13126)
* feat(vscode): remove YOLO mode setting, migrate old users to auto-approve all

The SDK extension's YOLO toggle was cosmetic: nothing in the approval
path read it, so runs were silently governed by the per-action
auto-approval settings underneath (cline/cline#13114). Instead of
keeping a parallel override system, remove the setting entirely and
make the auto-approve menu the single source of truth:

- drop yoloModeToggled (and the equally dead autoApproveAllToggled)
  from state keys, settings handlers, state posts, telemetry, the
  remote-config yoloModeAllowed transform, and the settings protos
  (field numbers reserved)
- remove the Yolo Mode toggle from Settings -> Features (the whole
  Experimental section, it was the only entry) and the
  "Auto-approve: YOLO" AutoApproveBar takeover
- add a v3 storage migration that folds a previously-enabled YOLO /
  auto-approve-all toggle into autoApprovalSettings by enabling every
  action, so previously-unattended setups keep running unattended;
  the dead keys are cleared from the file store

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

* refactor(vscode): keep dead yolo keys in place instead of clearing them

Current builds never read the removed keys (the state loader only visits
known keys), so deleting them buys nothing - and the file store is shared
with older builds that still know them, so clearing would flip YOLO off
for a user who downgrades. Same downgrade-safety rule as the v1 export.

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

* chore(vscode): rename wasUnattended to shouldEnableAllActions in yolo migration

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

* chore(vscode): drop dead toggleActModeForYoloMode and stale yoloModeAllowed comment

The method was a legacy-controller carryover nothing called, and it set
the mode without rebuilding the session, which is wrong for the SDK
architecture. The comment cited yoloModeAllowed as a live remote-config
example; it no longer maps to anything.

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

* chore(vscode): refresh checked-in proto descriptor_set.pb

The tracked descriptor set had not been regenerated since the repo
move and still advertised long-changed schemas (including the removed
yolo_mode_toggled fields) to gRPC reflection clients. Sync it with the
output of bun run protos.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-09 17:15:37 -07:00
Saoud Rizwan b3cee3f973 chore(desktop): release v0.0.11 2026-08-08 20:53:18 -07:00
Saoud Rizwan c68f553856 chore(vscode): prepare 4.1.7 release 2026-08-08 20:38:09 -07:00
Saoud Rizwan fd794ce3be chore(cli): release v3.0.52 2026-08-08 19:34:11 -07:00
Saoud Rizwan f5f1071af2 chore(sdk): release v0.0.72 2026-08-08 19:00:03 -07:00
Saoud Rizwan 4540390096 desktop: hide git jargon for non-git folders (CLIENTS-100) (#13059) 2026-08-08 15:57:00 -07:00
Saoud RizwanandSaoud Rizwan b590e14b91 desktop: paste clipboard images into the composer (CLIENTS-78) (#13057)
* desktop: paste clipboard images into the composer as attachments (CLIENTS-78)

Pasting a screenshot into the composer did nothing: only drag-and-drop
and the paperclip file picker fed the attachment pipeline. Add an
onPaste handler on the composer textarea that extracts image files from
the clipboard, renames them to timestamped pasted-image-*.png files, and
routes them through the existing onAttachFiles flow. Text pastes are
untouched.

* desktop: only extract clipboard images in formats message serialization supports

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:56:27 -07:00
Saoud RizwanandSaoud Rizwan 54cc156089 desktop: context-aware welcome suggestions for non-code folders (CLIENTS-98) (#13060)
* desktop: context-aware welcome suggestions for non-code folders (CLIENTS-98)

* desktop: treat pending branch discovery as its own state for welcome cards

The welcome-card classifier read the "no-git" sentinel as a confirmed
non-repo, but page.tsx also used that value for the initial state and
while a workspace switch was awaiting branch discovery, so a git repo
could briefly show the plain-folder cards. Branch state is now null
while discovery is pending: the welcome screen shows no cards until the
folder is classified, and chat-mode cards (which never depend on git
state) still show immediately. Other branch consumers keep the string
contract via a "no-git" fallback.

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

* desktop: carry nullable branch state to all consumers

Propagate the pending-discovery null through ChatInputBar,
WorkspaceSelector, and the welcome workspace controls instead of
coercing to "no-git" at the page boundary, so only display leaves
fall back and the welcome classifier is the single consumer that
distinguishes pending from confirmed non-repo.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:45:29 -07:00
Saoud RizwanandSaoud Rizwan 6c599d18c3 desktop: surface folder picker failures and add manual path fallback (CLIENTS-73) (#13056)
* desktop: never let 'Add project…' fail silently; add manual folder path entry (CLIENTS-73)

- sidecar picker tries zenity then kdialog on Linux and throws a descriptive
  error when neither exists, instead of returning null (indistinguishable
  from user cancel); picked paths are trimmed of trailing separators
- picker failures now surface as visible error messages in both workspace
  selectors, with a manual path-entry fallback (typed absolute or ~ paths
  in the search box offer an 'Open folder' action)
- failed workspace switches (invalid/nonexistent paths) show an inline
  error instead of silently doing nothing
- validate_workspace_directory expands ~ and returns the resolved path

* desktop: keep workspace menu search/error state through catalog refreshes

The welcome-screen workspace picker reset its search text and error
message whenever onRefreshWorkspaces changed identity, which happens on
every session-history poll. Typing a path or reading an inline error
raced against the timer: the menu would silently wipe mid-interaction.
Hold the refresh callback in a ref so the reset only runs when the menu
actually opens.

* desktop: format welcome-workspace-controls test

* desktop: distinguish picker launch failures from user cancellation

A zenity/kdialog rejection with a non-ENOENT spawn error (EACCES, EMFILE,
ENOMEM) or a crash signal was classified as a user cancel, which skipped
the kdialog fallback and suppressed the inline error - recreating the
silent no-op this branch is meant to eliminate. Only a clean exit code 1
from a dialog that actually opened now counts as cancellation; broken
backends fall through to the next candidate and surface a descriptive
error otherwise. Picker logic moved to sidecar/workspace-picker.ts with
an injectable exec so the classification is unit-tested.

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

* Classify picker launch failures separately from user cancellation

zenity/kdialog failures like EACCES, EMFILE, or ENOMEM were treated as
user cancellation, suppressing the kdialog fallback and the inline
manual-entry error. Only a clean exit code 1 now counts as a cancel;
any other failure falls through to the next backend or throws the
picker-unavailable error. Picker logic moved to sidecar/folder-picker.ts
with an injectable exec so the classification is unit tested.

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

* Revert "Classify picker launch failures separately from user cancellation"

This reverts commit 24d27a004d.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:43:52 -07:00
Saoud Rizwan 513aacc0e6 fix(core): full-stop semantics and abort-window queue edits for surviving queues (#13100)
* fix(core): preserve queued prompts across user-initiated aborts

Pressing stop while prompts were queued silently destroyed them:
abort() called clearAborted(), which emptied the pending prompt queue
with no way to recover the typed input. The prompts vanished from the
UI, were never sent, and left no trace in session artifacts.

Aborting now only stops the in-flight turn. Queued prompts stay in the
queue and drain once the abort settles, matching the drain behavior
that already existed for self-aborted turns (loop detector / mistake
limit). The thrown-abort path (completeAbortedInteractiveTurn) now
schedules the same drain that runTurn schedules for turns resolving
with an aborted finish.

* fix(core): full-stop semantics and abort-window queue edits for surviving queues

Follow-up to the queued-prompt survival change: aborting a user turn keeps
the queue and auto-runs it, but two gaps remained.

1. No full stop: aborting a queue-initiated turn also kept draining, so
   every Escape consumed one queued prompt and started a fresh provider
   call - a session with queued messages could never be brought to rest.
   Aborting a drained turn now discards the remaining queue: the first
   Escape skips to your queued follow-ups, a second Escape stops the
   queued work too.

2. Queue operations were still rejected while an abort settled: a prompt
   typed right after Escape was silently dropped, and queued prompts were
   briefly uneditable and undeletable even though they were about to
   auto-run. enqueue/update/delete now work during the abort window;
   scheduleDrain/drain still wait for the abort to settle.
2026-08-08 12:37:18 -07:00
Saoud RizwanandSaoud Rizwan ff2f860941 test(core): pin abort + hub-restart durability, and title seeded sessions (#13097)
* test(core): cover abort + host restart + seeded recovery durability

Adds an e2e regression guard for the reported "cancel a turn, lose the
conversation" failure: a cancelled turn, a daemon restart, a
client-side recovery seeded from disk, and a second restart before that
replacement ever runs a turn. Reverting the eager seeded-history
persistence makes the final read come back empty.

Materializing a seeded session at start also left its history row with
no prompt and no title, since there is no first prompt to derive one
from. Seed the title from the inherited transcript using the same
inference listSessionHistory hydration applies, so forks and recoveries
stay identifiable in unhydrated surfaces too.

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

* fix(core): retitle seeded sessions from their first user prompt

Eagerly-materialized seeded sessions kept the interim transcript-
inferred title forever, a behavior change from pre-eager persistence
where a fork's history row was titled by the first post-fork prompt.

The interim title now only covers the window where no turn has run
(previously those rows were simply absent), and the first user prompt
after the seed backfills the row's prompt and retitles it — unless the
user renamed the session in the meantime, in which case only the prompt
column is backfilled. The resident manifest and session metadata are
updated in step so the end-of-turn usage-metadata merge cannot clobber
the title back through a stale in-memory fallback.

The e2e mock's updateSession now mirrors the real persistence-service
contract (row + manifest file), and the durability e2e covers both the
retitle and the rename guard; removing the retitle call fails the
'now add tests' assertion.

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

* refactor(core): collapse seeded-session titling to the old mechanism

The interim transcript-derived title, retitle flags, rename comparison,
and resident-manifest syncing existed only to title forks that never
run a turn - a new nicety, not parity. Dropping it collapses the whole
design back to what rows did before eager persistence: the persistence
service derives the title from the prompt when a row gains one, so the
host only needs to backfill the promptless row with the first user
prompt via updateSession. Renames win automatically because the service
preserves an existing title when no explicit title is passed.

Net production change vs main is a single 20-line backfill block in
executeTurn. The e2e mock's updateSession now models the service's
title semantics (explicit title wins, existing title preserved,
untitled rows derive from prompt), and the durability e2e asserts the
raw row stays untitled until first prompt while history hydration
infers a display title from the transcript.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:30:30 -07:00
Saoud Rizwan 62a6b5a0b2 fix(core): preserve queued prompts across user-initiated aborts (#13090)
Pressing stop while prompts were queued silently destroyed them:
abort() called clearAborted(), which emptied the pending prompt queue
with no way to recover the typed input. The prompts vanished from the
UI, were never sent, and left no trace in session artifacts.

Aborting now only stops the in-flight turn. Queued prompts stay in the
queue and drain once the abort settles, matching the drain behavior
that already existed for self-aborted turns (loop detector / mistake
limit). The thrown-abort path (completeAbortedInteractiveTurn) now
schedules the same drain that runTurn schedules for turns resolving
with an aborted finish.
2026-08-08 12:20:08 -07:00
Saoud Rizwan 0caf617b50 fix(core): keep a hung MCP server from taking down session creation (#13086)
* fix(core): keep a hung MCP server from taking down session creation

A stdio MCP server that never finishes initializing used to hold its
connect open for the full DEFAULT_MCP_CONNECT_TIMEOUT_MS (doubled across
the newline/framed attempts). MCP tool discovery runs on the
session.create critical path, so that wait blew past the 30s hub command
timeout and the CLI tore the whole interactive session down instead of
just skipping the bad server.

- Bound MCP tool loading during session build with a startup budget that
  is safely under the hub command timeout. Servers that connect in time
  contribute their tools; slower/hung servers are skipped for the session
  (their error still surfaces via the MCP manager) instead of failing
  session creation. Budget is overridable via CLINE_MCP_STARTUP_BUDGET_MS
  for tests.
- Add StdioMcpClient.close() (and optional McpServerClient.close) that
  marks the client disposed so an in-flight connect() aborts its retry
  loop instead of respawning the framed fallback.
- Dispose the manager by closing clients up front, outside the per-server
  operation locks, so a server hung in initialize can no longer stall
  teardown for the full connect budget.

Adds regression tests covering both the non-blocking build and prompt
disposal while a client is hung in connect().

* refactor(core): simplify hung-MCP-server fix to a startup budget

Replace the bespoke per-server race/tracking in loadConfiguredMcpTools
with a small withStartupBudget() wrapper around the existing
Promise.allSettled: a server that exceeds the budget becomes a normal
rejection that the existing loop already logs and skips. The connect
budget, MCP settings display (initialize timeout 30s), and the rest of
the loader are left untouched.

The client close()/manager.dispose() cleanup is kept minimal: it is what
lets teardown abort a still-in-flight connect instead of blocking on the
per-server lock (and clears the pending request timer).

* fix(mcp): cap the default initialize budget at 3s to protect session creation

Supersedes the startup-budget approach on this branch with the simple
constant fix.

MCP initialize runs on the session.create critical path, which the hub
caps at 30s, and connect() can spend the budget twice (newline then
Content-Length framing). The 30s default from #13067 meant a server that
never initializes held session.create for up to 60s, so the hub RPC
timed out and the CLI tore the whole session down and exited.

Return to the pre-#13067 shape with a bigger probe: 3s instead of 1.5s.
That still covers the ~2s starters the old probe killed (#13035) and
keeps the worst case at ~6s per server, far under the hub deadline.
Genuinely slow starters (JVM-based servers like Oracle SQLcl) now need
an explicit timeout in cline_mcp_settings.json, which continues to
override the default in either direction.

Tests: update the slow-start regression tests to the new policy (2s
connects by default, 4s connects with a configured timeout), refresh the
displayed initialize-timeout assertions, and add an invariant test that
keeps the doubled default well under HUB_DEFAULT_COMMAND_TIMEOUT_MS so
the budget cannot silently creep past the session deadline again.
2026-08-08 12:18:11 -07:00
Saoud RizwanandSaoud Rizwan 930575991d fix(desktop): stop opening a session from replacing the remembered model (#13091)
* fix(desktop): stop opening a session from replacing the remembered model

The composer's ModelSelector mirrored every provider/model prop change
into the remembered last selection (localStorage), which seeds new
sessions via getInitialChatConfig(). Opening an existing session drives
those props to that session's config, so merely viewing an old session
silently replaced the user's explicitly picked default model.

The remembered selection is now written only from the explicit picker
handlers (provider select and model select). Passive prop changes, such
as opening a session, no longer touch it.

* fix(desktop): re-seed remembered provider/model on chat reset

reset() kept the previous config's provider/model and only cleared the
session ID, so a chat pane that had hydrated a historical session could
carry that session's model into the next chat. Re-seed provider/model
(and apiKey when the provider changes) from the remembered defaults --
the same source a freshly mounted thread uses -- so reset and remount
behave identically.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:10:39 -07:00
Saoud Rizwan e40d7d44ae fix(desktop): stop treating leftover plugin install dirs as installed (#13095)
* fix(desktop): stop treating leftover plugin install dirs as installed

isOfficialPluginInstalled() only checked that the marketplace install
directory existed. A failed or interrupted install can leave that
directory behind with no plugin inside, and the next install attempt
then short-circuited with a fake 'already installed' success: the
marketplace button flipped to Uninstall with no error while nothing
actually worked, and the installed-entries listing kept reporting the
broken entry as installed.

The check now requires a loadable plugin module inside the directory
(via discoverPluginModulePaths) before reporting the entry as
installed, so partial directories fall through to a real install
attempt whose outcome is surfaced to the UI.

* fix(desktop): reclaim leftover partial plugin install dirs with --force
2026-08-08 12:06:35 -07:00
Saoud RizwanandSaoud Rizwan e973ce4f33 fix(cli): make queued message text readable on light theme TUI (#13098)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:00:50 -07:00
Saoud RizwanandSaoud Rizwan 4f25692d70 fix(desktop): canonicalize diff panel paths against the session cwd (#13092)
* fix(desktop): canonicalize diff panel paths against the session cwd

Tool calls address the same file inconsistently across a session: one
edit uses a workspace-relative path (journal.txt), a later one the
absolute path (/tmp/ws/journal.txt). mergeToolDiffs keyed entries by the
raw string, so the same file was listed twice in the diff panel with
split +/- counts and inconsistent naming, most visibly after git was
initialized mid-session and the model switched to absolute paths.

Diff paths are now canonicalized against the session cwd before
merging: entries for the same file collapse into one, files inside the
cwd display as workspace-relative paths, and files outside it display
their resolved path. Without a cwd the previous raw-key behavior is
kept.

* style: collapse editorReplaceEvent signature per biome format

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

* fix(desktop): collapse dot segments and keep root cwd in diff path keys

* fix(desktop): compare Windows diff path keys case-insensitively

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 11:55:58 -07:00
Saoud Rizwan b0bba2e5d6 fix(vscode): hide View Changes on completion rows until there are changes to show (#13096)
* fix(vscode): hide View Changes on completion rows until there are changes to show

The button previously always rendered on the latest completion row, faded
and disabled when the count check came back 0 - which covers both 'nothing
changed since your last message' and 'no checkpoint to compare against'
(non-git workspace, repo with no commits, comparison failure). A dead
button with a misleading tooltip in the non-git case is worse than no
button: now the row renders nothing until the host confirms there are
actual changes, and the button is always enabled when shown.

* fix(vscode): reset View Changes state when showViewChanges toggles

Greptile review: a stale positive hasChanges from a previous evaluation
could flash the button before the host confirms the new comparison when
showViewChanges flips false and back true on the same row. Reset to
'still checking' whenever the effect re-runs.
2026-08-08 11:41:41 -07:00
Saoud Rizwan a6e2c0c431 fix(core): never run a foreign compiled plugin-sandbox bootstrap for a source host (#13094)
* fix(core): never run a foreign compiled plugin-sandbox bootstrap for a source host

When @cline/core runs from source (e.g. the desktop hub daemon in dev)
with CLINE_WRAPPER_PATH set, resolveBootstrap() picked the compiled
plugin-sandbox-bootstrap.js from a separately installed CLI platform
package (such as a published version sitting in the package-manager
cache) before falling back to the source bootstrap. That bootstrap
resolves modules against the other installation's layout, so every
plugin failed to load with "Cannot find module '@cline/core'" - and
the settings pipeline swallowed the failure, leaving Settings > Tools
showing "No plugin tools found" and plugins showing no contributions
even though the same plugins loaded fine in chat sessions.

Bootstrap selection now prefers, in order: a compiled bootstrap next to
this module (always matches the host build), the source bootstrap when
the host runs from source, and only then wrapper/executable-derived
bootstraps - which remain the path for compiled binaries where
import.meta points inside the bunfs bundle.

* chore(core): restore untouched settings-service formatting
2026-08-08 11:23:25 -07:00
Saoud RizwanandSaoud Rizwan d011d049a1 fix(core): keep session context durable across aborts and hub restarts (#13078)
* fix(core): keep session context durable across aborts and hub restarts

Users on slow self-hosted endpoints reported sessions losing their entire
conversation after cancelling a long-running request: the TUI still showed
the transcript, but the next turn greeted them like a brand-new session.

Root cause is a stack of two failures:
1. The hub daemon exits on any unhandled rejection that is not an
   AgentRuntimeAbortError, so a floating abort-family rejection from a
   cancelled provider stream kills every resident session.
2. When the CLI recovers the missing session it rebuilds from the persisted
   messages file - but aborting a turn never flushed the transcript, and
   lazy session persistence (SDK 0.0.70) kept seeded history (mode-switch
   restarts, forks, previous recoveries) memory-only until the first
   completed turn. Recovery then seeds an empty session: silent context wipe.

Fixes:
- completeAbortedInteractiveTurn now flushes the transcript to disk, so an
  aborted exchange survives a hub restart.
- Sessions started with initialMessages persist them (and any compaction
  sidecar) immediately; brand-new empty sessions stay lazy, so closing an
  unused runtime still leaves no empty history entry.
- The hub daemon ignores abort-family unhandled rejections (DOMException
  AbortError, Node ABORT_ERR) the same way it already ignores
  AgentRuntimeAbortError, instead of exiting with every session resident.

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

* fix(core): write seeded history atomically with session materialization

Greptile review flagged a residual crash window in the seeded-session
persistence: ensureSessionPersisted created the session row (with an empty
messages file) and only then called persistSessionMessages, so a crash
between the two left a discoverable session whose seeded history was gone.

Close the window by threading initialMessages/systemPrompt through
createRootSessionWithArtifacts: the messages artifact is now written with
the seeded transcript before the session row is committed, so every crash
point leaves either nothing discoverable or complete data. The follow-up
persistSessionMessages call at session start is gone; the seed travels
inside session materialization.

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

* Revert "fix(core): write seeded history atomically with session materialization"

This reverts commit 5a7e0b37f1.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 21:44:40 -07:00
Saoud Rizwan e09afced69 fix(core/hub): report queued-turn failures as run.failed (#13074)
Turns drained from the pending-prompt queue resolve their errored
AgentResult inside PendingPromptsController.drain(), which discards it,
and the legacy 'error' agent event had no projection in the hub's
session-event projector — so a failed queued turn never produced any
terminal hub event. Interactive clients (e.g. the desktop app) hung on
'Thinking...' with no error shown.

The projector now publishes run.failed (with the error text and a core
session snapshot) for non-recoverable lead-agent error events, but only
when no RPC-driven turn is awaiting sessionHost.runTurn for that
session — the awaiting run.start handler already publishes the
authoritative terminal event, so this avoids double-reporting a turn
that resolves through both paths.
2026-08-07 20:52:01 -07:00
Saoud Rizwan 3f83be51a2 feat(vscode): fade View Changes button until changes since last message are confirmed (#13076) 2026-08-07 20:16:15 -07:00
Saoud RizwanandSaoud Rizwan 1efe5577e1 fix(core/cli): drain queued prompts after self-aborted turns and surface the stop (#13061)
* fix(core/cli): drain queued prompts after self-aborted turns and surface the stop

When a run ends with finishReason "aborted" without a user abort request
(loop detector hard escalation or the consecutive-mistake safety stop),
runTurn skipped the pending-prompt drain, stranding user-queued messages
forever, and the CLI rendered nothing - the task appeared to silently
stop with queued messages never consumed (#13030).

- core: schedule the drain after every completed turn, including
  aborted/error finishes. User-initiated aborts are unaffected because
  abortSession() already clears the queue, and drain() stops after one
  failed send so an erroring provider cannot spin the queue.
- cli: when a turn comes back aborted without the user having requested
  an abort, append a "Task stopped before completion." status entry
  instead of ending the turn silently.

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

* fix(core): hold queued prompts on error finishes instead of consuming them

Addresses the Greptile P1 review on #13061: a drained prompt whose turn
resolved with finishReason "error" returned normally, so the
exception-only requeue path treated the send as successful - the failed
prompt was consumed and draining continued firing the remaining queue
into a failing provider.

- drain() now stops the chain when a drained send resolves with an
  error finish. The errored entry itself is not requeued (its turn ran:
  the prompt is in the conversation and the error is surfaced), but the
  rest of the queue is held.
- runTurn() no longer schedules a drain after "error" finishes (the
  skip is removed only for "aborted", which is the #13030 fix).
  Held prompts still drain via the existing enqueue/update/delete
  triggers or the next successful turn.
- Two new unit tests cover both layers.

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

* revert(cli): drop the 'Task stopped before completion' status line

Keep the change scoped to the queue-drain fix in @cline/core. The CLI
no longer prints a notice for non-user-initiated aborted finishes;
apps/cli is back to parity with main. When messages are queued, the
drain itself makes the stop visible (the queued message runs); richer
stop-reason surfacing can be a follow-up.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 19:27:47 -07:00
Saoud RizwanandSaoud Rizwan 9cbc24d6e5 Restore "View Changes" on completion rows using SDK checkpoints (#13072)
* fix(core): read untracked-at-snapshot files from stash third parent in checkpoint diff

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

* feat(vscode): restore View Changes button on completion rows via SDK checkpoint diff

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

* refactor(vscode): integrate View Changes as a footer inside the completion card

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

* style(vscode): place the View Changes button inside the completion card

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 18:11:25 -07:00
Saoud RizwanandSaoud Rizwan 551286d33b fix(cli): preserve binary MCP payloads in expanded TUI tool output (#13071)
* fix(cli): preserve binary MCP payloads in expanded TUI tool output

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

* simplify to minimal payload-preserving fix

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 18:10:28 -07:00
Saoud Rizwan 361ac90978 fix(llms): route LiteLLM through Chat Completions instead of the Responses API (#13053)
The litellm builtin spec pinned protocol: "openai-responses", so every
request went to POST {baseUrl}/responses. Self-hosted LiteLLM proxies
commonly implement only /chat/completions, so all prompts failed with
404 Not Found on the SDK path (CLI, and now the Next extension bundle).

Drop the override so litellm inherits the openai-compatible family
default (openai-chat -> /chat/completions), matching every sibling
openai-compatible builtin and the Legacy extension behavior.

Fixes #13003, fixes #10781
2026-08-08 02:06:46 +02:00
Saoud RizwanandSaoud Rizwan 4eb7402334 fix(cli): render MCP tool result text instead of escaped JSON in TUI (#13066)
* fix(cli): render MCP tool result text instead of escaped JSON in TUI

MCP tools return {content: [{type: "text", text}]} which
extractFullOutputText JSON-stringified, escaping newlines into one giant
line that word-wrapped across the whole terminal and never triggered the
line-based collapse. Extract the text parts with real newlines so the
existing collapse works.

Fixes #13038

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

* fix(cli): keep placeholders for non-text blocks in mixed MCP results

Addresses Greptile review on #13066: text-only filtering silently
dropped image/resource/audio blocks from mixed MCP content. Render them
as [type] placeholders instead.

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

* fix(cli): surface non-text MCP block metadata in TUI output

Extract embedded resource text, and include resource/resource_link URIs
and image/audio mime types in placeholders so expanded mixed MCP
results keep identifying metadata.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 17:05:22 -07:00
Saoud RizwanandSaoud Rizwan ffb61a865f fix(mcp): give unconfigured stdio servers a 30s initialize budget (#13067)
The stdio MCP client gave servers without a configured `timeout` only
1.5 seconds to answer initialize before killing the process, so
slow-starting servers (e.g. Oracle SQLcl's JVM-based `sql -mcp`) could
never load and were silently skipped at session start.

Raise the default connect budget to 30s, in line with the startup
budget other MCP clients allow. A configured `timeout` still overrides
it in either direction, dead commands still fail fast through the spawn
error/exit path, and the newline -> Content-Length framing fallback is
unchanged.

Fixes #13035

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:49:28 -07:00
d3616b96ae feat(desktop): route /team prompts through core runtime (#12372)
* feat(desktop): route /team prompts through core runtime

Rewrite desktop `/team` commands as structured user command blocks before sending them to the core runtime. Validate task input and respect the globally disabled Teams tool setting.

Remove legacy agent spawn and team enablement flags from session configuration, and add coverage for prompt rewriting and disabled-tool behavior.

* fix(desktop): preserve team tool defaults

* fix(desktop): display queued /team prompts as their slash form

Queued prompts are stored in their runtime form, so a queued /team
command showed its raw <user_command> envelope in the prompt queue chip
and edit textarea. Fold queue items through formatDisplayUserInput for
display; saving an edit re-resolves the slash form through the sidecar,
so the round trip is lossless.

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

* chore(hub): align builtin tool catalog flags with the desktop sidecar

The desktop sidecar pins enableSpawnAgent/enableAgentTeams when listing
the builtin tool catalog; the hub's parallel listing did not, so the two
would drift if the preset defaults ever change. Pin the same flags in
the hub and cross-reference the two call sites.

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

* chore(desktop): drop inert enableSpawn/enableTeams config leftovers

buildCoreSessionConfig no longer reads these keys, so remove the dead
schema fields, default-config initializers, and chat-test payload
entries. The chat-session regression test still sends them on purpose
to prove legacy flags cannot override the runtime's tool presets.

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

* fix(desktop): reject /team when the mode's tool preset disables teams

The /team guard only checked the global disabled-tools setting, but the
runtime resolves tool availability from the mode's preset, so a preset
without team tools (yolo) would still send the model a spawn-a-team
instruction it cannot act on. Resolve the teams catalog entry for the
session's mode and reject /team when it is unavailable, mirroring the
runtime's own availability logic.

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-07 16:45:49 -07:00
98e5458b09 feat(hub): centralize plugin settings and contributions (#12942)
* fix(desktop): plugin package names

* feat(hub): centralize plugin settings and contributions

* fix(hub): address plugin settings review feedback

* fix(settings): make plugin snapshots host-aware

* fix(core): make host plugin toggles atomic

---------

Co-authored-by: cline-cloud[bot] <cline-cloud[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 16:44:02 -07:00
Saoud Rizwan 031b8d94e1 fix(core): surface OAuth authorization for SSE MCP servers on 401 (#13050)
* fix(core): surface OAuth authorization for SSE MCP servers on 401

A 401 from an SSE MCP server never persisted authorizationRequired: the
fetch-boundary UnauthorizedError was consumed by EventSource and re-thrown
as a status-less SseError, so the instanceof check routed it to
markConnectionError and hosts never offered the OAuth connect action.

Give the SSE stream request a raw fetch so a 401 fails the connection with
the SDK's typed SseError(401), and recognize 401s across transports with a
single isMcpUnauthorizedError predicate at every detection site.

* style(core): apply biome formatting to MCP oauth changes
2026-08-07 16:42:43 -07:00
Saoud RizwanandSaoud Rizwan e5bcba8ef9 fix(vscode): settle the turn phase when a mode switch aborts a running turn (#13063)
Toggling Plan/Act while a turn was streaming or waiting on a tool approval
aborted the turn but left the TurnStateTracker on its last live phase: the
aborted session's done event is fenced off as stale once the rebuild
unsubscribes it, so nothing ever settled the phase. The webview then kept
rendering that phase forever - an eternal Thinking spinner with the input
disabled (aborted while streaming), or dead Approve/Run Command buttons wired
to an approval that clearPending had already denied (aborted while awaiting
approval). Users experienced this as 'switched to act mode and nothing
happened / it never wrote the files'.

Mirror cancelTask: after aborting the turn for the mode change, append a
resume_task ask row and set the phase to resumable, so the footer offers
Resume Task with the input enabled in the new mode.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:41:13 -07:00
Saoud Rizwan 83182c0d96 fix(llms): retry mid-stream network interruptions before any model output (#13052)
* fix(llms): retry mid-stream network interruptions before any model output

* fix(llms): scale network retry backoff by network retry count, not shared attempt number
2026-08-07 16:36:27 -07:00
Saoud Rizwan 40ebd09dfa desktop: native-feel polish, render-path performance, and transition fixes (#13028)
* desktop: native-feel polish and render-path performance fixes

- Suppress the WebView browser context menu on app chrome (keep it for
  editable fields and active text selections)
- Make UI chrome unselectable app-wide; opt chat messages, markdown,
  code, diffs, and error banners back into text selection
- Contain overscroll so inner scrollers don't rubber-band the window
- Lazy-load Settings/Sessions/Onboarding/Diff views out of the entry chunk
- Memoize ChatInputBar and AgentHeader; stabilize their props in the chat
  pane so stream flushes only re-render the affected message bubble
- Stop refocusing the composer textarea on every keystroke (caret flicker)
- Cache slash commands across menu opens (stale-while-revalidate)
- Avoid rebuilding reversed message arrays and ask-question JSX per render
- Drop core info/debug console logging on the streaming hot path behind a
  cline:debug-logs opt-in; remove leftover [webview:delete] debug logs
- SearchCombobox (provider/model picker): Escape closes and restores focus
- Remove unused @vercel/analytics, recharts, embla-carousel deps and the
  unused chart/carousel UI components

* desktop: surface failed-turn errors instead of leaving the chat blank

On a failed run the runtime reports its error string in result.text.
The webview rendered that as an assistant bubble, which the canonical
history rehydration then wiped (the failed turn is never persisted),
so provider errors like a retired model id left the user staring at a
silently empty chat. Route failed-turn text to a persistent error-role
message added after rehydration instead.

* desktop: fade the welcome/conversation swap instead of hard-cutting

Sending the first message replaced the hero layout with the message
grid in a single commit, which read as a white flash. A 180ms enter
animation now plays when either side becomes visible; disabled under
prefers-reduced-motion.

* desktop: render new-chat panes instantly from the last catalog load

Clicking + remounts ChatThreadPane, which refused to render until the
provider catalog (a large fetch) and workspace list resolved again —
about a second of blank pane plus boot spinner on every new chat.
Seed remounts from a module-level snapshot of the last successful
load; the mount effect still refreshes both in the background.

* desktop: invalidate the provider-catalog snapshot with the cache

Seeding remounted chat panes from the last catalog load left a window
where a pane created right after a credential change could act on the
old keys. The snapshot now lives in the catalog module and is dropped
by invalidateProviderCatalogCache(), so credential edits force the
next remount to wait for fresh data.
2026-08-07 16:18:47 -07:00
Saoud RizwanandSaoud Rizwan 7bc18f7e15 Bring back a copy button on turn-final response rows with a subtle header (#13051)
* Add subtle response header with copy button to completion and plan rows

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

* Add changeset for response header copy button

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

* Rename turn-final headers to Completed and Plan

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:12:43 -07:00
BeeandSaoud Rizwan fad8006730 feat(hub): add execution context for scheduled run reports (#12718)
* feat(hub): add execution context for scheduled run reports

Add human-readable headers, schedule metadata, durations, and lifecycle error context to cron run reports. Resolve file-based definitions to real paths while clearly identifying Hub-managed schedules stored in cron.db.

* fix cron report formatting edge cases

* Escape schedule titles in reports

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 15:57:33 -07:00
Saoud RizwanandSaoud Rizwan 28f35a2ec8 fix(cli): harden tool input/output formatters against malformed payloads (#13048)
* fix(cli): harden tool input/output formatters against malformed payloads

Tool inputs cross the model/tool boundary and may not match their
TypeScript annotations (e.g. run_commands with { command: null }).
truncate() called str.replace() on such values, crashing the TUI with
'.replace is not a function' and making persisted sessions containing
the payload non-resumable, since hydration replays the same input
through formatToolInput().

Normalize untrusted values at the formatting boundary: truncate() now
accepts unknown and safely stringifies null/undefined/objects (including
circular structures and throwing toJSON), formatStructuredCommand no
longer returns non-string commands verbatim, and fetch_web_content
request summaries tolerate malformed entries.

Fixes #13036

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

* fix(cli): keep valid empty-string args in structured command summaries

Greptile review: filtering normalized args by truthiness also dropped
genuine empty-string argv entries, so summaries could show a different
argument list than the one executed. Filter only nullish entries before
normalization instead.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 15:53:01 -07:00
Saoud Rizwan 1e24807d1a fix(vscode): fall back to session cwd or Desktop for @-mention file search in empty windows (#12982)
* fix(vscode): fall back to session cwd/Desktop for @-mention search in empty windows

* fix(vscode): use the shared chat workspace as the no-folder fallback root
2026-08-07 15:38:43 -07:00
Saoud Rizwan a5f90e0d53 fix(core): pick up checkpoints when git is initialized mid-session (#13026)
ensureGitRepository cached a negative probe for the lifetime of the hook
instance, so a session started in a non-git folder never got checkpoints
even after the user ran git init. Cache only the positive answer and
re-probe otherwise; the probe runs at most once per user turn.
2026-08-07 15:25:11 -07:00
Saoud RizwanandSaoud Rizwan adabfc6bd5 fix(desktop): treat signed-out state as a typed result instead of a command error (#12976)
* fix(desktop): treat signed-out state as a typed result instead of a command error

* fix(desktop): sign out when the organization balance fetch reports the typed signed-out result

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

* chore: retrigger checks after runner outage

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 15:06:45 -07:00
Saoud Rizwan 397d6f344f fix(desktop): resolve startup script-load SyntaxError and attribute webview errors to their source URL (#12974)
* fix(desktop): remove Vercel Analytics injection that breaks packaged webview startup

* fix(desktop): attribute webview uncaught errors to their source URL

* chore: drop unrelated claude-dev version bump from lockfile

* chore: retrigger checks after runner outage
2026-08-07 15:02:36 -07:00
Bee 8ca3068b73 refactor(desktop): update team tools component (#13047)
* refactor(desktop): update team tools component

* fix(desktop): address team tool review feedback
2026-08-07 22:58:06 +02:00
John Choi 6e6befdb65 fix(ui): remove nested tool output scrolling (#13043)
* fix(ui): remove nested tool output scrolling

* fix(desktop): avoid nested tool output scrolling

* chore(desktop): remove obsolete scroll utility

* fix(desktop): preserve multiline tool details
2026-08-07 13:44:23 -07:00
Haley ParkandSaoud Rizwan 71536e55aa refactor(ui): introduce Cline-owned semantic color system (#12941)
* refactor(ui): introduce Cline-owned semantic color system

* refactor(desktop): adopt shared semantic theme roles

* refactor(ui): set 15px root and recalibrate xs/sm type scale

Scale rem steps so xs/sm stay 12/13px visually, and slightly lift dark-mode neutral-4.

* refactor(ui): align SearchCombobox with package type and hover tokens

Use host-safe cline-ui utilities and keep option font inheritance from CSS.

* fix(ui): use standard stroke-2 utility on approval spinner

* refactor(desktop): modernize shared UI primitives for Tailwind v4

Replace legacy arbitrary/has selectors with current utility syntax.

* refactor(desktop): bump chat chrome typography to text-sm

Keep composer controls and pickers on the shared sm type step.

* refactor(desktop): use max-w-344 for page frame content width

* chore(desktop): disable Next.js dev indicators

* chore: ignore desktop-app Cursor settings

* docs(pr): add before/after screenshots for #12941

* chore: retrigger checks

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 10:15:18 -07:00
Saoud Rizwan 7348ba1847 chore(desktop): release v0.0.10 2026-08-06 23:45:56 -07:00
BeeandSaoud Rizwan 3e96fc6112 feat(cli): add mcp uninstall command (#12985)
* feat(cli): add mcp uninstall command

* unit test wiring

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-06 19:40:45 -07:00
Saoud RizwanandSaoud Rizwan d84d09c543 desktop: fix silent turn failures, message duplication, and stuck composer; add first-run setup guidance (#12984)
* desktop: fix silent turn failures, message duplication, and stuck composer; add first-run setup guidance

Findings from two full computer-use UX audits of the desktop app:

- Surface failed turns in the transcript: queued turns (incl. the first
  prompt of a fresh session) only signal errors via chat_done, which the
  UI previously ignored - sending a message with no credentials failed
  in complete silence. Failed turns now show an error message enriched
  with the latest core error log and a pointer to Settings -> Models.
- Fix duplicated user messages: a live send's optimistic user message
  was materialized a second time by the runtime's queued-prompt-start
  event.
- Fix composer stuck on 'Agent is working...': drop prompts from the
  local queue snapshot when they start, emit a fresh queue snapshot from
  the sidecar on pending_prompt_submitted, and double-check the server
  queue on turn completion.
- Add a 'Connect a model' notice on the welcome screen when no provider
  has credentials, with actions to reopen onboarding at the connect step
  or jump to model settings; it reacts live to credential changes.
- Add 'Get an API key' links for popular providers in onboarding and
  Settings -> Models (the catalog docUrl is never populated), and link
  the Cline dashboard from the Cline API key form.
- Explain what Cline is on the onboarding welcome step.
- Make the stop button visible (was 8px with no padding) and support
  Esc to stop; add Cmd/Ctrl+N (new session) and Cmd/Ctrl+, (settings).
- Remove leftover [webview:delete] console.error debug logging that
  surfaced an error badge after deleting a session.

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

* desktop: remove remaining delete debug logging in session history hook

The sidebar right-click delete path had the same leftover [webview:delete]
console.error instrumentation, which made the Next dev-mode issues badge
appear after every deletion.

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

* desktop: fix Biome a11y error in WelcomeSetupNotice

biome's lint/a11y/useSemanticElements errors on role="status" divs;
use the semantic <output> element (implicit status role) instead. This
was failing the repo's 'bun run lint'.

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

* desktop: count structured-config and keyless providers as connected

The welcome setup notice previously only recognized apiKey/OAuth
credentials, so users running Bedrock/Vertex (structured configValues)
or a deliberately enabled keyless local endpoint (e.g. Ollama) were
nagged to connect a model they already use. isProviderConnected now
also counts an enabled provider whose required config fields are all
filled, or an enabled provider that has no API-key field at all.

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

* desktop: keep re-key eligible when chat_done lands in the same batch as its prompt start

When a turn fails fast, chat_queued_prompt_start and chat_done can be
dispatched in one React batch. Clearing the outstanding-optimistic-
bubble registry synchronously in the chat_done handler ran before the
re-key updater enqueued by the prompt-start event, so the optimistic
bubble was appended a second time instead of re-keyed. Clear the
registry inside a state updater so it executes in event order after
the re-key. Caught by the queued-turn-failure regression test.

* desktop: make the queued-prompt re-key updater idempotent under StrictMode

React StrictMode double-invokes state updaters in dev. The
chat_queued_prompt_start re-key updater consumed the optimistic
bubble's id from outstandingOptimisticUserIdsRef on its first run, so
the second run against the same prev found no eligible candidate and
appended the same user message a second time (and, without a promptId,
makeId() minted a different id per invocation). Hoist the message id
out of the updater and remember which optimistic bubble each queued
message id re-keyed so a re-run reaches the identical result. The memo
resets alongside the outstanding set (error state, reset, hydration).

Root-caused with runtime instrumentation: the duplicate only appeared
on turns that exercised the queue-drain re-key path, and hydration
later collapsed it to one message because the duplicate never existed
in persisted state.

* desktop: preserve failure messages across post-send canonical hydration

Persisted history never contains UI-only error bubbles, so the two
post-send read_session_messages replacements in sendPrompt wiped the
failure explanation appended from chat_done ~40ms after it rendered
(confirmed with runtime instrumentation). Re-append the active
session's error messages after the canonical history. Includes a
regression test reproducing the chat_done-error-then-RPC-resolution
race.

* desktop: don't let an optional API-key field veto a connected provider

Greptile P1 follow-up: Bedrock's catalog entry carries an optional
apiKey field ('Optional Bedrock bearer token') alongside IAM/profile
authentication, and keyless local endpoints can also surface one — so
treating the mere presence of an apiKey field as proof of disconnection
kept nagging configured users. An enabled provider (the user
deliberately persisted settings for it) now counts as connected unless
a required config field is unmet; auth may legitimately live outside
the catalog (IAM, env vars, local endpoints). Brand-new users have no
enabled providers, so the first-run notice still shows for them.

* desktop: tighten credential-error guidance and stop re-pinning stale failure bubbles

* desktop: invalidate the shared provider catalog after settings OAuth login

Greptile P1 follow-up: runOAuthProviderLogin only updated the settings
view's local provider state, so the shared catalog cache and its
invalidation subscribers (the composer selector and the welcome
screen's 'Connect a model' notice) kept reporting the provider as
disconnected until an unrelated invalidation or a pane remount. Notify
the shared cache on successful OAuth login, like the account view and
the API-key save path already do.

* desktop: clear the remembered core error on turn end, reset, and hydration

Greptile flagged that turn-start events are the only thing clearing
lastCoreErrorBySessionRef, and websocket events are not replayed: a
transport interruption that drops a turn's start event lets a later
detail-less failure resurrect an earlier turn's error. The remembered
error belongs to exactly one turn, so clear it whenever a turn ends
(chat_done, any reason) as well as on reset() and history hydration.
Regression test covers the dropped-start-event sequence.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-06 19:25:42 -07:00
BeeandSaoud Rizwan 6f7f817d63 feat(core): support pre-registered OAuth clients for remote MCP (#12983)
* feat(core): support pre-registered OAuth clients for remote MCP

* fix(core): invalidate tokens when OAuth client changes

* fix(core): preserve compatible MCP OAuth sessions

* fix tests

* feat(desktop): wire mcp oauth in desktop

* UI update

* fix(core): reject stale mcp oauth callbacks

* fix(mcp): handle invalid settings and preserve state

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-06 18:58:52 -07:00
BeeandCline Agent c18d9478ba feat(cli): use saved provider settings for schedules (#10667)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-06 17:34:40 -07:00
Ara 574b8eb45e fix(llms): use configured fetch for Vertex ADC refreshes (#12981) (#12991) 2026-08-06 10:07:06 -07:00
Saoud Rizwan 81cce3d70e chore(vscode): prepare 4.1.6 release 2026-08-06 00:47:54 -07:00
Saoud Rizwan e1352fa709 chore(cli): release v3.0.51 2026-08-06 00:28:37 -07:00
Saoud Rizwan 394fb04518 chore(sdk): release v0.0.71 2026-08-06 00:14:21 -07:00
Saoud Rizwan f1aebbfd5a feat(llms): surface meta/muse-spark-1.2-contributor for the Cline provider (#12995)
* chore(llms): regenerate model catalog from models.dev

* feat(llms): surface meta/muse-spark-1.2-contributor for the Cline provider

* test(llms): guard Vercel-only Cline model allowlist
2026-08-06 00:04:16 -07:00
Saoud Rizwan 543dd0d818 fix(telemetry): attribute agent.run sdk.error events to the active model (#12972)
* fix(telemetry): attribute agent.run sdk.error events to the active model

* fix(telemetry): strip undefined values from sdk.error properties
2026-08-05 17:51:06 -07:00
Saoud Rizwan 1f2cbbeb9f chore(vscode): prepare 4.1.5 release 2026-08-05 14:04:04 -07:00
Saoud Rizwan b1a89156d6 feat(vscode): explain when a free model promotion ends (#12970)
* feat(vscode): explain when a free model promotion ends

Once a free promotion ends, the cline-free/ model is removed from the
catalog and the backend answers 'model not found' to requests against it.
The CLI has shown a dedicated 'Free model promotion ended' banner for this
since #12593; the extension instead rewrote the answer into generic
model-not-found guidance with no model-picker offramp.

Detect the case in the host where the active model id is known
(reshapeErrorForWebview, fed by a new MessageTranslatorState model-id
source), stamp the payload with a cline_free_promotion_ended code, and
render a dedicated card in the webview with a button into the model
picker. Classification is gated on the cline-free/ prefix so ordinary
model-not-found errors keep their generic path, and it runs before the
auth branch since the 404 status falls inside the generic auth range.

* fix(vscode): prefer the live task model over session-start metadata

A mid-task model-only switch updates the running session's model in place
(updateActiveSessionModel) and refreshes the task API shim, but never
touches the session's startConfig/manifest. Preferring the session-start
snapshot could therefore misclassify after such a switch: a genuine
retired-model 404 would miss the promotion-ended card, and the reverse
switch could show it for the wrong model. Provider switches restart the
session, so both sources agree there; the shim starts as "unknown"
(filtered out), so fresh sessions still resolve through start metadata.
2026-08-05 14:00:09 -07:00
BeeandSaoud Rizwan 1d7d9ce5e2 feat(llms): add portable reasoning resolution for AI SDK providers (#12946)
* feat(llms): add portable reasoning resolution for AI SDK providers

Introduce resolvePortableReasoning to map gateway reasoning requests
(effort levels, enabled/disabled flags) to the AI SDK's top-level
reasoning setting, applying it in buildAiSdkStreamConfig for supported
providers including Ollama.

- Defer exact token budgets to provider-specific options
- Omit reasoning when the caller expresses no explicit intent
- Replace manual provider-specific thinking overrides (e.g. Anthropic
  budget clamping, Moonshot/OpenAI-compatible toggles) with the
  portable reasoning path where applicable
- Add tests covering effort mapping, budget passthrough, and provider
  stream config integration

* fix(llms): prioritize explicit reasoning disable

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-05 12:17:49 -07:00
Mikołaj Kondratek 78b7c3d8ac fix(desktop): stop rendering the first chat message twice (#12779)
* fix(desktop): dedupe chat_queued_prompt_start emitted for the same prompt

PendingPromptService.drain() emits a pending_prompts snapshot (head
removed) and a pending_prompt_submitted event back-to-back for the same
prompt. The sidecar translated both into chat_queued_prompt_start, so
the webview rendered the user's message twice until the chat was
re-hydrated from history. Track the last announced prompt id per live
session and emit the start chunk once.

* fix(desktop): re-key optimistic user bubble when the runtime queues the prompt

The send path renders an optimistic user bubble for prompts dispatched
while the session is idle, keyed by a random id. When the runtime
routes that prompt through its pending queue (e.g. during session
startup), the queued-prompt-start event appended a second bubble under
queued_user_<promptId> — the same message rendered twice until the
chat was re-hydrated from history. Re-key the trailing optimistic
bubble to the event's id instead of appending.

* fix(desktop): re-key only outstanding optimistic bubbles on queued prompt start

Review follow-up: matching by content alone could swallow a new queued
prompt that repeats the text of a message left at the transcript tail
by an earlier cancelled/failed turn. Track in-flight optimistic bubble
ids explicitly (registered on optimistic append; cleared on re-key,
turn end, error, and history hydration) and only re-key those.
2026-08-05 14:49:05 +02:00
Saoud Rizwan d626cfb0b5 chore(vscode): prepare 4.1.4 release 2026-08-05 03:03:51 -07:00
Saoud Rizwan e14f354c59 chore(desktop): release v0.0.9 2026-08-05 02:29:46 -07:00
Saoud Rizwan 41ba332f0a chore(cli): release v3.0.50 2026-08-05 02:16:56 -07:00
303 changed files with 24441 additions and 7809 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
+1
View File
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
apps/examples/desktop-app/.cursor/settings.json
+96
View File
@@ -1,5 +1,101 @@
# Changelog
## [4.1.8]
### Added
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
- Support Fable 5 on Vertex.
### Changed
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
### Fixed
- Respect your configured max output tokens when the compaction summarizer requests a summary.
- Remove the stale "Double-Check Completion" feature tip.
## [4.1.7]
### Added
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
- Bring back a copy button on turn-final response rows.
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
### Changed
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
### Fixed
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
- Settle the turn phase when a mode switch aborts a running turn.
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
## [4.1.6]
### Added
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
### Fixed
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
## [4.1.5]
### Added
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
### Changed
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
## [4.1.4]
### Added
- Recognize Chutes as a provider.
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
### Changed
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
### Fixed
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
- Show installed plugins under their real package names instead of all appearing as "index".
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
## [4.1.3]
### Fixed
+41
View File
@@ -1,5 +1,46 @@
# Cline CLI Changelog
## 3.0.52
- Added `cline mcp uninstall` for removing an installed MCP server
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
## 3.0.51
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
## 3.0.50
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
- Auto-approval settings are now honored over ACP
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
## 3.0.49
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
+4 -2
View File
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
Schedule agents on cron-like intervals or external events.
If `--provider` and `--model` are omitted, schedules use the last configured
provider and model. If only `--provider` is given, the schedule uses that
provider's saved model.
```sh
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.49",
"version": "3.0.52",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+65
View File
@@ -767,6 +767,71 @@ Break work into clear steps.`,
);
});
it("routes mcp uninstall and its rm alias", () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
tempDirs.push(tempRoot);
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
const writeSettings = () => {
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: { transport: { type: "stdio", command: "node" } },
remote: {
transport: {
type: "streamableHttp",
url: "https://mcp.example.com",
},
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
};
const readServers = () =>
(
JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
}
).mcpServers ?? {};
writeSettings();
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(uninstallResult.status).toBe(0);
expect(asText(uninstallResult.stdout)).toContain(
"Uninstalled MCP server docs.",
);
expect(Object.keys(readServers())).toEqual(["remote"]);
writeSettings();
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(aliasResult.status).toBe(0);
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
name: "remote",
status: "uninstalled",
});
expect(Object.keys(readServers())).toEqual(["docs"]);
writeSettings();
const missingResult = runCli(["mcp", "remove", "missing"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(missingResult.status).toBe(1);
expect(asText(missingResult.stderr)).toContain(
'MCP server "missing" is not installed.',
);
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+146 -1
View File
@@ -1,9 +1,13 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
runMcpUninstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
});
});
it("shows mcp-remote marketplace entries as native remote servers", () => {
expect(
buildMcpInstallDefaults({
name: "linear",
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
}),
).toEqual({
name: "linear",
type: "streamableHttp",
url: "https://mcp.linear.app/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
});
});
});
describe("mcp uninstall command", () => {
let root = "";
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSettings(): string {
const settingsPath = join(root, "cline_mcp_settings.json");
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
},
},
keep: {
transport: { type: "stdio", command: "node" },
disabled: true,
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
return settingsPath;
}
function readSettings(settingsPath: string): Record<string, unknown> & {
mcpServers?: Record<string, unknown>;
} {
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
string,
unknown
> & { mcpServers?: Record<string, unknown> };
}
it("uninstalls the requested server and reports success", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
const written = readSettings(settingsPath);
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
expect(written.mcpServers?.keep).toEqual({
transport: { type: "stdio", command: "node" },
disabled: true,
});
expect(written.customTopLevelKey).toBe(true);
});
it("prints uninstall JSON with --json", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
name: "docs",
status: "uninstalled",
});
expect(writeln).toHaveBeenCalledTimes(1);
});
it("reports an error and leaves settings intact for an unknown server", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "missing",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
'MCP server "missing" is not installed.',
);
expect(writeln).not.toHaveBeenCalled();
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
it("rejects a blank name without rewriting settings", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: " ",
settingsPath,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
});
+52 -58
View File
@@ -1,12 +1,16 @@
import {
buildMcpInstallTransport as buildCoreMcpInstallTransport,
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
type McpUninstallOptions as CoreMcpUninstallOptions,
type McpUninstallResult as CoreMcpUninstallResult,
uninstallMcpServer,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
const { name, transport } = buildCoreMcpInstallTransport(options);
if (transport.type === "stdio") {
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
type: transport.type,
command: [transport.command, ...(transport.args ?? [])]
.map(quoteCommandArg)
.join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
type: transport.type,
url: transport.url,
};
}
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
return 1;
}
}
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
io?: McpCommandIo;
json?: boolean;
}
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
export function uninstallMcpServerDirect(
options: McpUninstallOptions,
): McpUninstallDirectResult {
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
return {
name: result.name,
status: result.status,
};
}
export async function runMcpUninstallCommand(
options: McpUninstallOptions,
): Promise<number> {
try {
const name = options.name?.trim() ?? "";
if (!name) {
throw new Error("MCP server name is required");
}
const result = uninstallMcpServerDirect({ ...options, name });
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
}
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+177 -4
View File
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
providers: {} as Record<string, { provider?: string; model?: string }>,
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
sendHubCommand: mockSendHubCommand,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
}
getProviderSettings(providerId: string) {
return mockProviderSettings.providers[providerId];
}
},
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
@@ -47,6 +65,8 @@ async function runScheduleCommand(
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it('prints "No schedules found." for empty non-json list output', async () => {
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
});
});
describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand create", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("uses the last used provider and model when both flags are omitted", async () => {
mockProviderSettings.lastUsed = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("uses an explicit provider with that provider's configured model", async () => {
mockProviderSettings.lastUsed = {
provider: "cline",
model: "openai/gpt-5.3-codex",
};
mockProviderSettings.providers.anthropic = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("fails when an explicit provider has no configured model and no model flag", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(1);
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockSendHubCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("preserves exported modelSelection providerId/modelId values", async () => {
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("writes JSON content to the --to file path", async () => {
+9 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -19,6 +18,7 @@ import {
registerScheduleImportCommand,
registerScheduleUpdateCommand,
} from "./import-export";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
export function registerScheduleCommands(
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--model <model>", "Model to use")
.option("--provider <id>", "Provider ID")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
.option("--timeout <seconds>", "Timeout in seconds");
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
parseJsonObjectFlag(opts.metadataJson),
opts,
);
const modelSelection = resolveScheduleModelSelection({
provider: opts.provider,
model: opts.model,
});
const created = await client.createSchedule({
name,
cronPattern: opts.cron,
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
provider: modelSelection.provider,
model: modelSelection.model,
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
+15 -14
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -18,8 +17,13 @@ import {
resolveAddress,
toPositiveInt,
} from "./common";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
provider: string;
model: string;
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
!Array.isArray(parsed.modelSelection)
? (parsed.modelSelection as Record<string, unknown>)
: undefined;
const provider = String(
modelSelection?.providerId ??
parsed.providerId ??
parsed.provider ??
"cline",
).trim();
const model = String(
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
return resolveScheduleModelSelection({
provider:
stringValue(modelSelection?.providerId) ??
stringValue(parsed.providerId) ??
stringValue(parsed.provider),
model:
stringValue(modelSelection?.modelId) ??
stringValue(parsed.modelId) ??
stringValue(parsed.model),
});
}
export function registerScheduleExportCommand(
@@ -0,0 +1,52 @@
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
interface ProviderSettingsReader {
getLastUsedProviderSettings(): ProviderSettings | undefined;
getProviderSettings(providerId: string): ProviderSettings | undefined;
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveScheduleModelSelection(
options: {
provider?: string;
model?: string;
},
providerSettingsManager?: ProviderSettingsReader,
): { provider: string; model: string } {
const explicitProvider = trimToUndefined(options.provider);
const explicitModel = trimToUndefined(options.model);
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, model: explicitModel };
}
const manager = providerSettingsManager ?? new ProviderSettingsManager();
const lastUsedSettings = manager.getLastUsedProviderSettings();
const provider =
explicitProvider ??
trimToUndefined(lastUsedSettings?.provider) ??
DEFAULT_SCHEDULE_PROVIDER;
const selectedProviderSettings = explicitProvider
? manager.getProviderSettings(provider)
: lastUsedSettings;
const model =
explicitModel ??
trimToUndefined(selectedProviderSettings?.model) ??
(provider === DEFAULT_SCHEDULE_PROVIDER
? CLINE_DEFAULT_MODEL_ID
: undefined);
if (!model) {
throw new Error(
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
);
}
return { provider, model };
}
+18
View File
@@ -505,6 +505,24 @@ export async function runCli(): Promise<void> {
io,
});
});
const mcpUninstallCmd = mcpCmd
.command("uninstall")
.alias("remove")
.alias("rm")
.description("Uninstall an MCP server by name")
.argument("<name>", "MCP server name")
.option("--json", "Output as JSON")
.action(async (name: string) => {
const opts = mcpUninstallCmd.opts<{
json?: boolean;
}>();
const { runMcpUninstallCommand } = await import("./commands/mcp");
ctx.exitCode = await runMcpUninstallCommand({
name,
json: opts.json === true || program.opts().json === true,
io,
});
});
const createDoctorRuntimeCommand = async () => {
const { createDoctorCommand } = await import("./commands/doctor");
@@ -1,89 +0,0 @@
// ---------------------------------------------------------------------------
// Page-object helpers for the /settings view.
// ---------------------------------------------------------------------------
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { expectVisible } from "../terminal.js";
const TAB_ORDER = [
"API",
"Auto-approve",
"Features",
"Account",
"Other",
] as const;
export type SettingsTab = (typeof TAB_ORDER)[number];
/**
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
* Waits for each tab's content to appear before pressing the next key, making
* navigation deterministic regardless of machine speed.
*/
export async function goToSettingsTab(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
const targetIndex = TAB_ORDER.indexOf(tab);
for (let i = 0; i < targetIndex; i++) {
terminal.keyRight();
// Wait for the next tab's content to appear before pressing again
await assertTabContent(terminal, TAB_ORDER[i + 1]);
}
}
/** Assert the API tab content is visible */
export async function assertApiTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Provider:", "Model ID:"]);
}
/** Assert the Auto-approve tab content is visible */
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Read project files",
"Execute safe commands",
"Edit project files",
]);
}
/** Assert the Features tab content is visible */
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Subagents",
"Web tools",
"Double-check completion",
]);
}
/** Assert the Account tab content is visible */
export async function assertAccountTab(terminal: Terminal): Promise<void> {
// The account tab shows sign-in options when not authenticated to Cline
await expectVisible(terminal, /sign in|sign out/i);
}
/** Assert the Other tab content is visible */
export async function assertOtherTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
}
/**
* Assert the content for a given tab is visible.
* Used internally by goToSettingsTab to confirm navigation landed correctly.
*/
export async function assertTabContent(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
switch (tab) {
case "API":
return assertApiTab(terminal);
case "Auto-approve":
return assertAutoApproveTab(terminal);
case "Features":
return assertFeaturesTab(terminal);
case "Account":
return assertAccountTab(terminal);
case "Other":
return assertOtherTab(terminal);
}
}
@@ -117,7 +117,10 @@ function QueuedPromptRow(props: {
flexGrow={1}
/>
) : (
<text fg={selected ? theme.textOnSelection : undefined} flexGrow={1}>
<text
fg={selected ? theme.textOnSelection : theme.defaultForeground}
flexGrow={1}
>
{truncatePrompt(item.prompt)}
</text>
)}
+3 -3
View File
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
import { getMcpDescription } from "./interactive-config";
describe("getMcpDescription", () => {
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
it("shows one configured timeout when it also applies to initialize", () => {
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
transport: { type: "stdio", command: "node" },
timeoutSeconds: Number.NaN,
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
hasMcpSettingsFile,
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
const timeoutDescription =
registration.transport.type === "stdio" &&
!isMcpTimeoutConfigured(registration.timeoutSeconds)
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
: `timeout ${timeoutSeconds}s`;
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
}
@@ -135,6 +135,35 @@ describe("hydrateSessionMessages", () => {
]);
});
// Regression test for https://github.com/cline/cline/issues/13036:
// persisted sessions with malformed tool inputs must stay resumable.
it("hydrates tool calls with malformed inputs without throwing", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "run_commands",
input: { command: null },
},
],
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{
kind: "tool_call",
toolName: "run_commands",
inputSummary: "",
rawInput: { command: null },
streaming: false,
mode: undefined,
},
]);
});
it("leaves mode undefined for transcripts without user_input wrappers", () => {
const messages = [
{ role: "user", content: "plain old message" },
+66 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
import {
buildReadFilesKeys,
extractFullOutputText,
parseReadFilesInput,
} from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
describe("extractFullOutputText", () => {
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
const raw = {
content: [
{ type: "text", text: "# Memory\n\nline one" },
{ type: "text", text: "line two" },
],
};
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
});
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
const raw = {
content: [
{ type: "text", text: "before" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
{
type: "resource",
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
},
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
{ type: "text", text: "after" },
],
};
expect(extractFullOutputText(raw)).toBe(
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
);
});
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
const raw = {
content: [
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
],
};
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
"[image: image/png]",
"A".repeat(76),
"A".repeat(76),
"A".repeat(8),
]);
});
it("extracts embedded resource text from MCP content", () => {
const raw = {
content: [
{
type: "resource",
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
},
],
};
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
});
it("falls back to pretty JSON for objects without text content", () => {
const raw = { structuredContent: { ok: true } };
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
});
});
+51
View File
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
return { task: input.task };
}
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
// line-based collapse stays compact and expand shows the full data.
function chunkBase64(data: string): string {
return data.match(/.{1,76}/g)?.join("\n") ?? data;
}
export function extractFullOutputText(raw: unknown): string | undefined {
if (raw === null || raw === undefined) return undefined;
if (typeof raw === "string") return raw;
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
}
if (typeof raw === "object") {
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
// text so multi-line results keep real newlines instead of being
// JSON-escaped into one giant line that floods the terminal (#13038).
// Non-text blocks keep their identifying metadata plus their base64
// payloads so mixed results are not silently truncated.
const content = (raw as { content?: unknown }).content;
if (Array.isArray(content)) {
const parts = content
.map((part) => {
if (!isRecord(part)) return "";
if (part.type === "text" && typeof part.text === "string") {
return part.text;
}
if (part.type === "resource" && isRecord(part.resource)) {
if (typeof part.resource.text === "string") {
return part.resource.text;
}
if (typeof part.resource.blob === "string" && part.resource.blob) {
const label =
typeof part.resource.uri === "string"
? `[resource: ${part.resource.uri}]`
: "[resource]";
return `${label}\n${chunkBase64(part.resource.blob)}`;
}
if (typeof part.resource.uri === "string") {
return `[resource: ${part.resource.uri}]`;
}
}
if (part.type === "resource_link" && typeof part.uri === "string") {
return `[resource_link: ${part.uri}]`;
}
if (
(part.type === "image" || part.type === "audio") &&
typeof part.mimeType === "string"
) {
if (typeof part.data === "string" && part.data) {
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
}
return `[${part.type}: ${part.mimeType}]`;
}
return typeof part.type === "string" ? `[${part.type}]` : "";
})
.filter(Boolean);
if (parts.length > 0) return parts.join("\n");
}
try {
return JSON.stringify(raw, null, 2);
} catch {
+68
View File
@@ -10,6 +10,7 @@ import {
isCliHookPayload,
normalizeAutoApproveArgs,
parseArgs,
truncate,
} from "./helpers";
type EnvSnapshot = {
@@ -374,6 +375,73 @@ describe("format helpers", () => {
).toBe("first (+2 more)");
expect(formatToolOutput(null)).toBe("");
});
// Regression tests for https://github.com/cline/cline/issues/13036:
// malformed tool inputs crossing the model/tool boundary must never
// throw from display-only formatters.
it("does not crash on run_commands with a null command", () => {
expect(formatToolInput("run_commands", { command: null })).toBe("");
});
it("does not crash on run_commands with a non-string command", () => {
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
'{"nested":true}',
);
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
"42",
);
});
it("keeps valid empty-string args in structured command summaries", () => {
expect(
formatToolInput("run_commands", {
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
}),
).toBe("grep pattern file.txt");
expect(
formatToolInput("run_commands", {
commands: [{ command: "git", args: [null, "status", undefined] }],
}),
).toBe("git status");
});
it("skips null entries in run_commands command arrays", () => {
expect(
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
).toBe("echo hi");
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
"echo hi",
);
});
it("does not crash on fetch_web_content with malformed requests", () => {
expect(
formatToolInput("fetch_web_content", {
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
}),
).toBe("https://example.com, 42");
});
it("falls back to an empty summary for unserializable inputs", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatToolInput("unknown_tool", circular)).toBe("");
expect(formatToolOutput(circular)).toBe("");
expect(
formatToolInput("unknown_tool", {
toJSON() {
throw new Error("boom");
},
}),
).toBe("");
});
it("truncates non-string values without throwing", () => {
expect(truncate(null, 10)).toBe("");
expect(truncate(undefined, 10)).toBe("");
expect(truncate(42, 10)).toBe("42");
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
});
});
describe("hook payload validation and audit logging", () => {
+58 -14
View File
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
return cwd;
}
export function truncate(str: string, maxLen: number): string {
const oneLine = str.replace(/\n/g, " ").trim();
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
/**
* Normalizes an untrusted runtime value into a display string without ever
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
* not match their TypeScript annotations (e.g. `{ command: null }`).
*/
export function toDisplayString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return safeJsonStringify(value);
}
try {
return String(value);
} catch {
return "";
}
}
export function truncate(value: unknown, maxLen: number): string {
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
if (oneLine.length <= maxLen) {
return oneLine;
}
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
return cmd;
}
if (cmd && typeof cmd === "object" && "command" in cmd) {
const structured = cmd as { command: string; args?: unknown };
const args = Array.isArray(structured.args) ? structured.args : [];
const structured = cmd as { command?: unknown; args?: unknown };
const command = toDisplayString(structured.command);
// Drop only nullish entries: they carry no display value, while an
// empty string is a valid argv entry that must stay in the summary.
const args = Array.isArray(structured.args)
? structured.args
.filter((arg) => arg !== null && arg !== undefined)
.map(toDisplayString)
: [];
if (args.length === 0) {
return structured.command;
return command;
}
return `${structured.command} ${args.join(" ")}`;
return `${command} ${args.join(" ")}`;
}
return String(cmd);
return toDisplayString(cmd);
}
function summarizeRunCommandsInput(input: unknown): string {
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
}
if (Array.isArray(input)) {
return input.map(formatStructuredCommand).join("; ");
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
}
if (input && typeof input === "object") {
const obj = input as Record<string, unknown>;
if (obj.commands !== undefined) {
if (Array.isArray(obj.commands)) {
return obj.commands.map(formatStructuredCommand).join("; ");
return obj.commands
.map(formatStructuredCommand)
.filter(Boolean)
.join("; ");
}
return formatStructuredCommand(obj.commands);
}
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
if (Array.isArray(obj.requests)) {
return truncate(
obj.requests
.map((r) => r.url)
.map((r) =>
r && typeof r === "object" && "url" in r
? toDisplayString((r as { url?: unknown }).url)
: "",
)
.filter(Boolean)
.join(", "),
120,
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
return "list";
}
return truncate(JSON.stringify(input), 60);
return truncate(input, 60);
}
export function formatToolOutput(output: unknown): string {
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
)
.filter(Boolean)
.join(" ") || "Successfully read image"
: String(result ?? "");
: toDisplayString(result);
return truncate(resultStr, 80);
}
return truncate(JSON.stringify(item), 80);
return truncate(item, 80);
})
.filter((s) => s.length > 0);
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
return `${results[0]} (+${results.length - 1} more)`;
}
return truncate(JSON.stringify(output), 100);
return truncate(output, 100);
}
function isRecord(value: unknown): value is Record<string, unknown> {
+33 -1
View File
@@ -8,6 +8,7 @@ import {
type McpServerEntry,
type McpTransport,
removeServer,
setServerOAuthClient,
toggleServer,
updateServer,
} from "./settings";
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
interface UrlServerConfig {
transport: McpTransport;
authMode: RemoteAuthMode;
oauthClient?: { clientId: string; clientSecret?: string };
}
export interface McpAddDefaults {
@@ -196,7 +198,29 @@ async function collectUrlTransport(
});
if (isCancel(authMode)) return null;
if (authMode === "oauth" || authMode === "none") {
if (authMode === "oauth") {
const clientId = await p.text({
message: "OAuth client ID (leave empty for dynamic registration)",
});
if (isCancel(clientId)) return null;
const normalizedClientId = (clientId as string).trim();
let clientSecret: string | undefined;
if (normalizedClientId) {
const secret = await p.password({
message: "OAuth client secret (leave empty for public clients)",
});
if (isCancel(secret)) return null;
clientSecret = (secret as string).trim() || undefined;
}
return {
transport: { type, url: (url as string).trim() },
authMode,
oauthClient: normalizedClientId
? { clientId: normalizedClientId, clientSecret }
: undefined,
};
}
if (authMode === "none") {
return {
transport: { type, url: (url as string).trim() },
authMode,
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport(defaults?.command);
} else {
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
);
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
addServer(serverName, transport);
if (authMode !== "oauth") {
clearServerOAuth(serverName);
} else {
setServerOAuthClient(serverName, oauthClient);
}
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
if (authMode === "oauth") {
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport();
} else {
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
updateServer(name, transport);
if (type === "stdio" || authMode !== "oauth") {
clearServerOAuth(name);
} else {
setServerOAuthClient(name, oauthClient);
}
p.log.success(`Updated "${name}"`);
if (authMode === "oauth") {
+32
View File
@@ -8,6 +8,7 @@ import {
clearServerOAuth,
loadServers,
removeServer,
setServerOAuthClient,
} from "./settings";
describe("MCP wizard settings", () => {
@@ -139,4 +140,35 @@ describe("MCP wizard settings", () => {
await expect(readFile(settingsPath, "utf8")).resolves.toBe(before);
});
it("clears OAuth state when the configured client changes", async () => {
const settingsPath = await useTempSettingsPath();
await writeFile(
settingsPath,
JSON.stringify({
mcpServers: {
github: {
transport: {
type: "streamableHttp",
url: "https://api.githubcopilot.com/mcp/",
},
oauthClient: { clientId: "old-client", clientSecret: "old-secret" },
oauth: { tokens: { access_token: "old-token" } },
},
},
}),
);
setServerOAuthClient("github", {
clientId: "new-client",
clientSecret: "new-secret",
});
const [github] = loadServers();
expect(github?.oauth).toBeUndefined();
expect(github?.oauthClient).toEqual({
clientId: "new-client",
clientSecret: "new-secret",
});
});
});
+29
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import {
type McpServerOAuthClientConfig,
type McpServerOAuthState,
McpSettingsUpdateSkippedError,
resolveDefaultMcpSettingsPath,
@@ -10,6 +11,7 @@ export interface McpServerEntry {
name: string;
transport: McpTransport;
disabled?: boolean;
oauthClient?: McpServerOAuthClientConfig;
oauth?: McpServerOAuthState;
}
@@ -49,6 +51,9 @@ export function loadServers(): McpServerEntry[] {
name,
transport,
disabled: entry.disabled === true,
oauthClient: entry.oauthClient as
| McpServerOAuthClientConfig
| undefined,
oauth,
};
});
@@ -138,6 +143,7 @@ export function clearServerOAuth(name: string): void {
);
}
delete existing.oauth;
delete existing.oauthClient;
servers[name] = existing;
});
} catch (error) {
@@ -148,6 +154,29 @@ export function clearServerOAuth(name: string): void {
}
}
export function setServerOAuthClient(
name: string,
client: McpServerOAuthClientConfig | undefined,
): void {
mutateServers((servers) => {
const existing = getOwnServerRecord(servers, name);
if (!existing)
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
const previous = existing.oauthClient as
| McpServerOAuthClientConfig
| undefined;
if (
previous?.clientId !== client?.clientId ||
previous?.clientSecret !== client?.clientSecret
) {
delete existing.oauth;
}
if (client) existing.oauthClient = client;
else delete existing.oauthClient;
servers[name] = existing;
});
}
export function toggleServer(name: string, disabled: boolean): void {
mutateServers((servers) => {
const existing =
+4 -3
View File
@@ -1,10 +1,10 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
} from "../../commands/schedule/client";
import { resolveAddress } from "../../commands/schedule/common";
import { resolveScheduleModelSelection } from "../../commands/schedule/model-selection";
import { CRON_PRESETS } from "./cron-presets";
function isCancel(value: unknown): value is symbol {
@@ -211,12 +211,13 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
}
}
const modelSelection = resolveScheduleModelSelection({ provider, model });
const created = (await client.createSchedule({
name: (name as string).trim(),
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? CLINE_DEFAULT_MODEL_ID,
provider: modelSelection.provider,
model: modelSelection.model,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
+26 -6
View File
@@ -21,10 +21,7 @@ import {
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import {
@@ -297,7 +294,13 @@ export async function handleDesktopCommand(
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
toggleDisabledTool(toolName);
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "tools",
name: toolName,
workspaceRoot,
cwd: workspaceRoot,
});
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "set_tool_disabled") {
@@ -306,13 +309,30 @@ export async function handleDesktopCommand(
.map((name) => String(name ?? "").trim())
.filter(Boolean);
if (toolNames.length === 0) throw new Error("tool name is required");
setDisabledTools(toolNames, args?.disabled === true);
for (const name of toolNames) {
if (!ctx.uiClient)
throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "tools",
name,
enabled: args?.disabled !== true,
workspaceRoot,
cwd: workspaceRoot,
});
}
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "set_plugin_disabled") {
const pluginPath = String(args?.path ?? "").trim();
if (!pluginPath) throw new Error("plugin path is required");
setDisabledPlugin(pluginPath, args?.disabled === true);
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "plugins",
path: pluginPath,
enabled: args?.disabled !== true,
workspaceRoot,
cwd: workspaceRoot,
});
return await listUserInstructionConfigs(workspaceRoot);
}
throw new Error(`unsupported desktop command: ${command}`);
+17 -43
View File
@@ -1,14 +1,11 @@
import { existsSync, readdirSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
createCoreSettingsService,
createUserInstructionConfigService,
discoverPluginModulePaths,
getCoreBuiltinToolCatalog,
getPluginDisplayName,
listHookConfigFiles,
listPluginTools,
readGlobalSettings,
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
@@ -99,49 +96,21 @@ export async function listUserInstructionConfigs(
}
};
const loadPlugins = (): Array<{
name: string;
path: string;
enabled: boolean;
}> => {
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
const pluginsByPath = new Map<
string,
{ name: string; path: string; enabled: boolean }
>();
const directories = resolvePluginConfigSearchPaths(
targetWorkspaceRoot,
).filter((d) => existsSync(d));
for (const directory of directories) {
try {
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) continue;
pluginsByPath.set(filePath, {
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
}
} catch {
// best-effort
}
}
return [...pluginsByPath.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
};
const [rules, workflows, skills, pluginTools] = await Promise.all([
const [rules, workflows, skills, settingsSnapshot] = await Promise.all([
loadUserInstructionSnapshot("rule"),
loadUserInstructionSnapshot("workflow"),
loadUserInstructionSnapshot("skill"),
listPluginTools({
workspacePath: targetWorkspaceRoot,
createCoreSettingsService().list({
workspaceRoot: targetWorkspaceRoot,
cwd: targetWorkspaceRoot,
}),
]);
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the desktop
// sidecar's (sidecar/commands.ts) even if the preset defaults change.
const builtinToolCatalog = getCoreBuiltinToolCatalog({
enableSpawnAgent: true,
enableAgentTeams: true,
disabledToolIds: disabledTools,
});
@@ -151,7 +120,12 @@ export async function listUserInstructionConfigs(
workflows,
skills,
agents: loadAgents(),
plugins: loadPlugins(),
plugins: settingsSnapshot.plugins.map((plugin) => ({
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
contributions: plugin.contributions,
})),
tools: [
...builtinToolCatalog.map((tool) => ({
id: tool.id,
@@ -163,11 +137,11 @@ export async function listUserInstructionConfigs(
source: "builtin",
headlessToolNames: tool.headlessToolNames,
})),
...pluginTools.map((tool) => ({
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
...settingsSnapshot.tools.map((tool) => ({
id: tool.id,
name: tool.name,
description: tool.description,
enabled: tool.enabled,
enabled: tool.enabled !== false,
source: tool.source,
path: tool.path,
pluginName: tool.pluginName,
@@ -3,9 +3,10 @@
import {
Bot,
Code,
Copy,
FileText,
MoreVertical,
Play,
Puzzle,
RefreshCw,
Server,
Trash2,
@@ -16,6 +17,12 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
@@ -97,6 +104,19 @@ type PluginItem = {
name: string;
path: string;
enabled: boolean;
contributions?: PluginContributions;
};
type PluginContributions = {
inspectionStatus?: "available" | "disabled" | "failed";
capabilities: string[];
tools: string[];
skills: string[];
rules: string[];
hooks: string[];
commands: string[];
mcpServers: string[];
providers: string[];
};
type ToolItem = {
@@ -856,6 +876,46 @@ export function CustomizationSectionView({
);
};
const renderPluginMenu = (target: LocalUninstallTarget) => {
const uninstalling = localUninstallingKeys.has(target.key);
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={`More actions for ${target.name ?? "plugin"}`}
className="m-0 size-auto shrink-0 p-0 text-muted-foreground"
onClick={(event) => event.stopPropagation()}
size="icon"
type="button"
variant="ghost"
/>
}
>
<MoreVertical className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
void navigator.clipboard.writeText(target.path ?? "")
}
>
<Copy className="size-4" />
Copy path
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={uninstalling}
onClick={() => void uninstallLocalPrimitive(target)}
>
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
{uninstalling ? "Uninstalling..." : "Uninstall"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const renderSkillCard = (item: CommandItem) => {
const key = `${item.type}:${item.path}`;
return (
@@ -910,13 +970,32 @@ export function CustomizationSectionView({
scope: ItemScope;
}) => {
const key = plugin.path;
const contributionGroups = [
{
label: "Tools",
items:
plugin.contributions?.tools ??
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => tool.name,
),
},
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
{
label: "Capabilities",
items: plugin.contributions?.capabilities ?? [],
},
].filter((group) => group.items.length > 0);
return (
<div
<details
key={plugin.path}
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<summary className="flex cursor-pointer list-none items-center gap-3">
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -929,61 +1008,61 @@ export function CustomizationSectionView({
onCheckedChange={() => {
void setPluginEnabled(plugin);
}}
onClick={(event) => event.stopPropagation()}
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map((tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() || "No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
})}
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) === 0 && (
<p className="text-xs text-muted-foreground">
No plugin tools found.
</p>
)}
</div>
<div className="mt-3">
{renderLocalActionRow({
{renderPluginMenu({
key,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})}
</summary>
<div className="mt-3">
{plugin.contributions?.inspectionStatus === "disabled" ? (
<p className="mb-2 text-xs text-muted-foreground">
Enable this plugin to inspect its dynamic contributions.
</p>
) : null}
{contributionGroups.length > 0 ? (
<div>
<div className="flex flex-wrap items-center gap-2 py-2 text-xs font-medium text-foreground">
<span className="mr-1">Contributions</span>
{contributionGroups.map((group) => (
<Badge key={group.label} variant="outline">
{group.label} {group.items.length}
</Badge>
))}
</div>
<div className="grid max-h-56 gap-3 overflow-y-auto pt-2 sm:grid-cols-2">
{contributionGroups.map((group) => (
<div key={group.label} className="min-w-0">
<p className="mb-1 text-xs font-medium text-muted-foreground">
{group.label}
</p>
<div className="flex flex-wrap gap-1">
{group.items.map((item) => (
<Badge key={item} variant="secondary">
{item}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
No plugin contributions found.
</p>
)}
</div>
</div>
{renderLocalActionMessage(key) ? (
<div className="mt-3">{renderLocalActionMessage(key)}</div>
) : null}
</details>
);
};
@@ -1000,53 +1079,56 @@ export function CustomizationSectionView({
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
{renderPluginMenu({
key: plugin.path,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})}
</>
);
const renderPluginMatchedMeta = (plugin: PluginItem) => (
<p className="min-w-0 truncate text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
);
const renderPluginMatchedDetails = (plugin: PluginItem) => {
const pluginTools = pluginToolsByPluginKey.get(plugin.path) ?? [];
if (pluginTools.length === 0) {
const contributionGroups = [
{
label: "Tools",
items:
plugin.contributions?.tools ??
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => tool.name,
),
},
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
{
label: "Capabilities",
items: plugin.contributions?.capabilities ?? [],
},
].filter((group) => group.items.length > 0);
if (contributionGroups.length === 0) {
return null;
}
return (
<div className="grid gap-2">
{pluginTools.map((tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() || "No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
<div className="grid max-h-56 gap-3 overflow-y-auto sm:grid-cols-2">
{contributionGroups.map((group) => (
<div key={group.label} className="min-w-0">
<p className="mb-1 text-xs font-medium text-muted-foreground">
{group.label}
</p>
<div className="flex flex-wrap gap-1">
{group.items.map((item) => (
<Badge key={item} variant="secondary">
{item}
</Badge>
))}
</div>
);
})}
</div>
))}
</div>
);
};
@@ -1147,10 +1229,12 @@ export function CustomizationSectionView({
renderMatchedControls: () =>
renderPluginMatchedControls(item.plugin),
renderMatchedDetails:
Object.values(item.plugin.contributions ?? {}).some(
(values) => values.length > 0,
) ||
(pluginToolsByPluginKey.get(item.plugin.path) ?? []).length > 0
? () => renderPluginMatchedDetails(item.plugin)
: undefined,
renderMatchedMeta: () => renderPluginMatchedMeta(item.plugin),
}),
)
: catalogPrimitive === "mcp"
@@ -1476,7 +1560,6 @@ export function CustomizationSectionView({
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -1492,13 +1575,9 @@ export function CustomizationSectionView({
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
@@ -1513,19 +1592,6 @@ export function CustomizationSectionView({
"No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
},
@@ -1558,7 +1624,6 @@ export function CustomizationSectionView({
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -1574,13 +1639,9 @@ export function CustomizationSectionView({
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
@@ -1595,19 +1656,6 @@ export function CustomizationSectionView({
"No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
},
+57
View File
@@ -1,5 +1,62 @@
# Cline Code Desktop Changelog
## 0.0.11
- Images can now be pasted straight from the clipboard into the composer.
- Opening a folder that isn't a git repo no longer shows git jargon, and the welcome suggestions now adapt to what's actually in the folder instead of assuming a code project.
- The folder picker now reports failures instead of doing nothing, and offers a manual path entry as a fallback.
- Opening an existing session no longer overwrites the model you had selected.
- The diff panel now resolves file paths against the session's working directory, so diffs open correctly for sessions rooted outside the app's own directory.
- `/team` prompts now run through the core runtime.
- Failed turns surface their error in the transcript instead of leaving the chat blank.
- Plugins left behind as empty install directories are no longer listed as installed, and plugin settings and contributions are now managed centrally with atomic toggles.
- Fixed a startup script-load error, and webview errors are now attributed to the source URL that caused them.
- Signing out is handled as a normal state rather than surfacing as a command error.
- Native-feel and performance polish: the browser context menu is suppressed on app chrome (kept for text fields and selections), UI chrome is no longer text-selectable while chat content still is, inner scrollers no longer rubber-band the window, Settings/Sessions/Onboarding/Diff load lazily, the composer no longer flickers the caret on every keystroke, slash commands are cached across menu opens, and Escape closes the provider/model picker.
- Tool output no longer nests its own scrollbar.
- Prompts queued during a turn now survive being interrupted — they're preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped. Queued turns that fail are reported as failures.
- Session context stays durable across aborts and hub restarts.
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely.
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright.
- LiteLLM requests route through Chat Completions instead of the Responses API.
- Network interruptions mid-stream but before any model output are retried instead of failing the turn.
- Checkpoints are picked up when git is initialized part-way through a session, and checkpoint diffs include files that were untracked when the snapshot was taken.
- Scheduled run reports carry execution context — schedule metadata, durations, and lifecycle error details.
## 0.0.10
- Remote MCP servers can now authenticate with OAuth from Settings → MCP — authorize a server, see its auth status, and cancel or retry a pending authorization. Servers that require a pre-registered OAuth client (client ID/secret) instead of dynamic registration are now supported, and stored tokens are invalidated when a server's client configuration changes.
- MCP errors are now shown on the individual server rather than as a page-level error, and a server with invalid configuration is surfaced with its error instead of silently disappearing from the list.
- Failed turns no longer fail silently. Sending a message with no model credentials — or any queued turn that fails — now shows an error in the transcript, enriched with the underlying cause and a pointer to Settings → Models.
- Fixed the first message of a chat (and some queued messages) rendering twice.
- Fixed the composer getting stuck on "Agent is working…" after a turn already finished.
- New "Connect a model" notice on the welcome screen when no provider has credentials, with one click to onboarding or model settings. It reacts live as you add credentials, and correctly recognizes Bedrock/Vertex and keyless local endpoints as already connected.
- Added "Get an API key" links for popular providers in onboarding and Settings → Models, plus a link to the Cline dashboard from the Cline API key form.
- The onboarding welcome step now explains what Cline is.
- The stop button is now actually visible and clickable, Esc stops the current turn, and new shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for settings.
- Reasoning controls now resolve consistently across AI SDK providers, including Ollama, so effort levels and thinking on/off are honored wherever the provider supports them.
- Vertex AI: credential refreshes now use the configured fetch, fixing ADC authentication behind proxies and custom networking.
- Refreshed the bundled provider and model catalog.
## 0.0.9
- Cline Code now ships as a single universal macOS download that runs natively on both Apple Silicon and Intel — no more picking the right architecture. Existing per-architecture installs migrate to it automatically on their next update.
- Session history can now be filtered by where a session came from — Desktop, CLI, extension, or scheduled — from a new filter control in the sidebar.
- The composer now shows a token usage ring for the active model's context window, with cumulative cost, and it changes color as you approach the limit.
- Skills now appear in the slash command menu alongside workflows, and commands that share a name are disambiguated instead of shadowing each other.
- Installed plugins now show their real package names instead of all appearing as "index".
- The agent header can be dragged to move the window again, including on read-only titles.
- Chat message actions (copy, fork, edit, restore) no longer collide with the descenders of the message's last line.
- Application errors are now reported in diagnostics, and the packaged app's telemetry configuration is baked into the sidecar at build time — previously the packaged build shipped with it empty, so no diagnostics were ever sent.
- Plan mode now hard-blocks file-editing shell commands rather than relying on prompting alone; read-only investigation still works.
- Running out of context is now recovered from automatically — the run compacts and retries once instead of failing with a raw provider error.
- Empty model responses are now retried on every provider, not just Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported".
- Bedrock prompt caching works again — cache reads and writes were always 0 — and Bedrock foundation models now route through geo inference profiles.
- Reasoning models on OpenAI-compatible endpoints now get the correct token parameter, and models without image support substitute image content instead of failing.
- Refreshed the bundled provider and model catalog, adding Infomaniak and SCX.ai.
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native provider.
## 0.0.8
- Edit any earlier message in a conversation — the app forks the session at that point, rewinds the workspace to that run's checkpoint, and re-runs from your edited prompt. Restores are transactional and workspace-atomic, so a failed restore won't leave you half-rewound.
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.8",
"version": "0.0.11",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -65,13 +65,11 @@
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"next": "16.2.11",
@@ -83,7 +81,6 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"shiki": "^4.0.2",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
@@ -151,6 +151,8 @@ Supported commands:
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
| `list_mcp_servers` | Direct file I/O |
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | async `execFile("git", ...)` |
@@ -17,11 +17,52 @@ import {
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
rewriteDesktopTeamPrompt,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
describe("rewriteDesktopTeamPrompt", () => {
it("rewrites /team for the core runtime", () => {
expect(
rewriteDesktopTeamPrompt("/team inspect the app", {
disabledTools: new Set(),
}),
).toBe(
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
);
});
it("rejects /team when the Teams tool is disabled", () => {
expect(() =>
rewriteDesktopTeamPrompt("/team inspect the app", {
disabledTools: new Set(["teams"]),
}),
).toThrow("Agent teams are disabled");
});
it("rejects /team when the mode's tool preset has no team tools", () => {
expect(() =>
rewriteDesktopTeamPrompt("/team inspect the app", {
mode: "yolo",
disabledTools: new Set(),
}),
).toThrow("Agent teams are not available in yolo mode");
});
it("accepts /team in act and plan modes", () => {
for (const mode of ["act", "plan", undefined]) {
expect(
rewriteDesktopTeamPrompt("/team inspect the app", {
mode,
disabledTools: new Set(),
}),
).toContain('<user_command slash="team">');
}
});
});
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
@@ -141,6 +182,8 @@ describe("pathless session starts", () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
expect(input.config).not.toHaveProperty("enableSpawnAgent");
expect(input.config).not.toHaveProperty("enableAgentTeams");
return {
sessionId: "session-pathless",
manifest: {
@@ -163,6 +206,10 @@ describe("pathless session starts", () => {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
// Legacy desktop capability flags must not override the SDK's
// current tool preset or global tool customizations.
enableSpawn: false,
enableTeams: false,
},
})) as {
sessionId: string;
@@ -1420,6 +1467,25 @@ Follow the desktop send skill instructions.`,
});
});
it("rewrites a team command when a queued prompt is edited", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
await handleChatSessionCommand(ctx, {
action: "update_pending_prompt",
sessionId,
promptId: "queued-team",
prompt: "/team inspect the app",
});
expect(updatePendingPrompt).toHaveBeenCalledWith({
sessionId,
promptId: "queued-team",
prompt:
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
});
});
it("leaves built-in and unknown slash commands untouched", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, sessionId } = createContext(workspace);
@@ -8,7 +8,9 @@ import {
type ClineCoreStartConfig,
createSessionCompactionState,
createUserInstructionConfigService,
getCoreBuiltinToolCatalog,
projectSessionCompactionState,
readGlobalSettings,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
@@ -17,7 +19,7 @@ import {
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
@@ -133,6 +135,63 @@ async function expandRuntimeSlashCommand(
}
}
type TeamPromptAvailability = {
/** Session mode as stored in config; anything but plan/yolo counts as act. */
mode?: unknown;
disabledTools?: ReadonlySet<string>;
};
export function rewriteDesktopTeamPrompt(
prompt: string,
availability: TeamPromptAvailability = {},
): string {
const match = /^\/team\b([\s\S]*)$/i.exec(prompt.trim());
if (!match) return prompt;
const task = match[1]?.trim();
if (!task) {
throw new Error(
"Usage: /team <task description>. Starts a team of agents for the given task.",
);
}
const disabledTools =
availability.disabledTools ??
new Set(readGlobalSettings().disabledTools ?? []);
if (disabledTools.has("teams")) {
throw new Error(
"Agent teams are disabled. Enable the Teams tool in Customizations → Tools.",
);
}
// The runtime resolves tool availability from the mode's preset, so a
// preset without team tools must reject /team here rather than send the
// model an instruction it cannot act on.
const mode =
availability.mode === "plan" || availability.mode === "yolo"
? availability.mode
: "act";
const teamsAvailable = getCoreBuiltinToolCatalog({ mode }).some(
(entry) => entry.id === "teams" && entry.defaultEnabled,
);
if (!teamsAvailable) {
throw new Error(`Agent teams are not available in ${mode} mode.`);
}
return formatUserCommandBlock(
`spawn a team of agents for the following task: ${task}`,
"team",
);
}
async function resolveDesktopRuntimePrompt(
ctx: SidecarContext,
workspacePath: string | undefined,
prompt: string,
mode?: unknown,
): Promise<string> {
return rewriteDesktopTeamPrompt(
await expandRuntimeSlashCommand(ctx, workspacePath, prompt),
{ mode },
);
}
function hasActiveWorkspaceTurn(session: LiveSession): boolean {
return (
session.busy ||
@@ -357,16 +416,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
enableSpawnAgent:
config.enableSpawn ??
config.enableSpawnAgent ??
config.enable_spawn ??
false,
enableAgentTeams:
config.enableTeams ??
config.enableAgentTeams ??
config.enable_teams ??
false,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
@@ -856,12 +905,13 @@ async function handleSend(
if (session?.transitioningProvider) {
throw new Error("A provider switch is already in progress");
}
// Dispatch the expanded instructions, but keep the raw `/command` token as
// the session's display prompt.
const expandedPrompt = await expandRuntimeSlashCommand(
// Dispatch the expanded or rewritten instructions, but keep the raw
// `/command` token as the session's display prompt.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
prompt,
request.config?.mode ?? session?.config?.mode,
);
let delivery = request.delivery;
if (!delivery && session?.busy) {
@@ -929,7 +979,7 @@ async function handleSend(
}
await manager.send({
sessionId,
prompt: expandedPrompt,
prompt: runtimePrompt,
delivery: "queue",
userImages: request.attachments?.userImages,
userFiles,
@@ -953,7 +1003,7 @@ async function handleSend(
try {
result = await manager.send({
sessionId,
prompt: expandedPrompt,
prompt: runtimePrompt,
delivery,
userImages: request.attachments?.userImages,
userFiles,
@@ -1417,18 +1467,19 @@ async function handleUpdatePendingPrompt(
throw new Error("prompt is required");
}
const manager = getSessionManager(ctx);
const sessionConfig = ctx.liveSessions.get(sessionId)?.config;
// Queued prompts are delivered by the runtime without another pass
// through handleSend, so expand a leading slash command here too.
const expandedPrompt = await expandRuntimeSlashCommand(
// through handleSend, so resolve slash commands here too.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(ctx.liveSessions.get(sessionId)?.config) ??
ctx.workspaceRoot,
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
prompt,
sessionConfig?.mode,
);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
prompt: expandedPrompt,
prompt: runtimePrompt,
});
return {
sessionId,
@@ -112,8 +112,6 @@ async function main() {
cwd: cwd,
mode: "act",
enableTools: false,
enableSpawn: false,
enableTeams: false,
},
},
},
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
import type { SidecarContext } from "./types";
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClineAccountService: class {
constructor(options: unknown) {
clineAccountServiceCtorMock(options);
}
},
executeClineAccountAction: executeClineAccountActionMock,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
};
});
function createContext() {
const capture = vi.fn();
const ctx = {
telemetry: { capture },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
}
const FETCH_ME_ARGS = {
action: "clineAccount",
operation: "fetchMe",
} as const;
async function runClineAccountCommand(ctx: SidecarContext) {
const { handleCommand } = await import("./commands");
return handleCommand(ctx, "cline_account", { ...FETCH_ME_ARGS });
}
beforeEach(() => {
clineAccountServiceCtorMock.mockReset();
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
describe("cline_account command auth states", () => {
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
const result = await runClineAccountCommand(ctx);
expect(result).toEqual({
signedIn: false,
code: "ACCOUNT_NOT_AUTHENTICATED",
});
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
});
it("runs the account action unchanged when a fresh token resolves", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const user = { id: "user-1", email: "beatrix@cline.bot" };
executeClineAccountActionMock.mockResolvedValue(user);
const result = await runClineAccountCommand(ctx);
expect(result).toBe(user);
expect(executeClineAccountActionMock).toHaveBeenCalledWith(
expect.objectContaining(FETCH_ME_ARGS),
expect.anything(),
);
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
getAuthToken: () => Promise<string | undefined>;
};
await expect(serviceOptions.getAuthToken()).resolves.toBe("fresh-token");
expect(capture).not.toHaveBeenCalled();
});
it("falls back to the persisted token silently when the refresh fails", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockRejectedValue(
new Error("Token refresh failed: 500"),
);
getProviderSettingsMock.mockReturnValue({
auth: { accessToken: "persisted-token" },
});
executeClineAccountActionMock.mockResolvedValue({ id: "user-1" });
await runClineAccountCommand(ctx);
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
getAuthToken: () => Promise<string | undefined>;
};
await expect(serviceOptions.getAuthToken()).resolves.toBe(
"persisted-token",
);
expect(capture).not.toHaveBeenCalled();
});
it("reports one auth refresh soft-failure event when the refresh fails and no fallback token exists", async () => {
const { ctx, capture } = createContext();
const refreshError = new Error(
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
);
refreshError.name = "OAuthReauthRequiredError";
resolveProviderApiKeyMock.mockRejectedValue(refreshError);
getProviderSettingsMock.mockReturnValue(undefined);
const result = await runClineAccountCommand(ctx);
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
expect(capture).toHaveBeenCalledTimes(1);
expect(capture).toHaveBeenCalledWith({
event: "user.auth_refresh_soft_failure",
properties: expect.objectContaining({
provider: "cline",
errorName: "OAuthReauthRequiredError",
errorCode: "desktop_refresh_failed_no_fallback_token",
}),
});
});
});
+291 -236
View File
@@ -1,16 +1,11 @@
import { execFile, spawn } from "node:child_process";
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import type {
ClineAccountActionRequest,
CoreSettingsSnapshot,
ProviderCapability,
ProviderClient,
ProviderProtocol,
@@ -19,31 +14,29 @@ import type {
import {
addLocalProvider,
ClineAccountService,
captureAuthRefreshSoftFailure,
createUserInstructionConfigService,
discoverPluginModulePaths,
ensureCustomProvidersLoaded,
executeClineAccountAction,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
getPluginDisplayName,
listHookConfigFiles,
listLocalProviders,
listPluginTools,
normalizeOAuthProvider,
ProviderSettingsManager,
parseMcpServerRegistration,
probeMcpServerConnection,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveLocalClineAuthToken,
resolvePluginConfigSearchPaths,
resolveMcpServerRegistration,
resolveSessionBackend,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
SqliteSessionStore,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setMcpServerDisabled,
setTelemetryOptOutGlobally,
toggleDisabledTool,
updateLocalProvider,
updateMcpSettingsFileSync,
} from "@cline/core";
@@ -56,6 +49,7 @@ import {
} from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -72,6 +66,17 @@ import {
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
ensureMcpSettingsFile,
readMcpServersResponse,
shouldProbeMcpServerAfterUpsert,
} from "./mcp";
import {
cancelMcpOAuthAuthorizationForReason,
McpOAuthAuthorizationCancelledError,
runCancellableMcpOAuthAuthorization,
shouldRestoreEnabledStateAfterOAuthCancellation,
} from "./mcp-oauth";
import {
cancelProviderOAuthLogin,
runCancellableProviderOAuthLogin,
@@ -95,6 +100,7 @@ import type {
JsonRecord,
SidecarContext,
} from "./types";
import { pickWorkspaceDirectory } from "./workspace-picker";
// All child processes in this module run asynchronously: the sidecar is a
// single event loop shared by every UI command and streaming chat session, so
@@ -162,82 +168,21 @@ function readProviderSettingsUpdate(
: {};
}
// ---------------------------------------------------------------------------
// MCP settings helpers
// ---------------------------------------------------------------------------
function readMcpServersResponse(): JsonRecord {
const settingsPath = resolveMcpSettingsPath();
if (!existsSync(settingsPath)) {
return { settingsPath, hasSettingsFile: false, servers: [] };
}
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
const servers = parsed.mcpServers as JsonRecord | undefined;
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
const record = body as JsonRecord;
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const rawTransportType =
transport?.type ?? record.transportType ?? record.type;
const transportType = String(
rawTransportType ??
(typeof transport?.url === "string" || typeof record.url === "string"
? "sse"
: "stdio"),
).trim();
return {
name,
transportType,
disabled: record.disabled === true,
command:
typeof transport?.command === "string"
? transport.command
: typeof record.command === "string"
? record.command
: undefined,
args: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd:
typeof transport?.cwd === "string"
? transport.cwd
: typeof record.cwd === "string"
? record.cwd
: undefined,
env:
transport?.env && typeof transport.env === "object"
? transport.env
: record.env && typeof record.env === "object"
? record.env
: undefined,
url:
typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: undefined,
headers:
transport?.headers && typeof transport.headers === "object"
? transport.headers
: record.headers && typeof record.headers === "object"
? record.headers
: undefined,
metadata: record.metadata,
};
});
return { settingsPath, hasSettingsFile: true, servers: entries };
}
/**
* Transport type + URL a server record actually points at, tolerating both
* the nested `transport` shape and legacy flat fields (mirrors
* readMcpServersResponse).
*/
function mcpTransportIdentity(record: JsonRecord): string {
function mcpTransportIdentity(name: string, record: JsonRecord): string {
try {
const resolved = parseMcpServerRegistration(name, record).transport;
return resolved.type === "stdio"
? "stdio\u0000"
: `${resolved.type}\u0000${resolved.url}`;
} catch {
// Preserve a best-effort identity for malformed entries so the editor can
// still repair them without requiring the entire settings file to parse.
}
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
@@ -257,20 +202,6 @@ function mcpTransportIdentity(record: JsonRecord): string {
return `${normalizedType}\u0000${url}`;
}
function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
function ensureMcpSettingsFile(): string {
const path = resolveMcpSettingsPath();
if (!existsSync(path)) {
writeMcpServersMap({});
}
return path;
}
function removePathIfExists(
path: string,
options?: { recursive?: boolean },
@@ -293,8 +224,10 @@ function removePathIfExists(
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
async function resolveFreshClineAuthToken(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): Promise<string | undefined> {
let refreshError: Error | undefined;
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
@@ -303,11 +236,28 @@ async function resolveFreshClineAuthToken(
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces the
// auth failure to the caller.
} catch (error) {
// Fall back to the persisted token; when one exists the account request
// surfaces the auth failure to the caller.
refreshError = error instanceof Error ? error : new Error(String(error));
}
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
const persisted = resolveLocalClineAuthToken(
manager.getProviderSettings("cline"),
);
// Never-signed-in resolves to undefined without a refresh attempt and is
// silent. A refresh failure with no persisted fallback means credentials
// existed but yielded nothing — that is the signal a real auth regression
// would show up as, so report exactly one event for it.
if (!persisted && refreshError) {
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
error: refreshError,
});
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
errorName: refreshError.name,
errorCode: "desktop_refresh_failed_no_fallback_token",
});
}
return persisted;
}
function mergePersistedSessionRecord(
@@ -731,9 +681,51 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
}
async function listHubSettings(
ctx: SidecarContext,
): Promise<CoreSettingsSnapshot> {
const hubClient = await ensureSharedHubClient(ctx);
const reply = await hubClient.command("settings.list", {
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? "hub command failed: settings.list",
);
}
return reply.payload?.snapshot as CoreSettingsSnapshot;
}
async function toggleHubSetting(
ctx: SidecarContext,
input: {
type: "plugins" | "tools";
path?: string;
name?: string;
enabled?: boolean;
},
): Promise<CoreSettingsSnapshot> {
const hubClient = await ensureSharedHubClient(ctx);
const reply = await hubClient.command("settings.toggle", {
...input,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? "hub command failed: settings.toggle",
);
}
return reply.payload?.snapshot as CoreSettingsSnapshot;
}
async function listUserInstructionConfigs(
workspaceRoot: string,
ctx: SidecarContext,
settingsSnapshot?: CoreSettingsSnapshot,
): Promise<JsonRecord> {
const workspaceRoot = ctx.workspaceRoot;
const hubSettings = settingsSnapshot ?? (await listHubSettings(ctx));
const warnings: string[] = [];
const userInstructionService = createUserInstructionConfigService({
skills: { workspacePath: workspaceRoot },
@@ -804,40 +796,6 @@ async function listUserInstructionConfigs(
}
};
const loadPlugins = (): Array<{
name: string;
path: string;
enabled: boolean;
}> => {
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
const pluginsByPath = new Map<
string,
{ name: string; path: string; enabled: boolean }
>();
const directories = resolvePluginConfigSearchPaths(workspaceRoot).filter(
(d) => existsSync(d),
);
for (const directory of directories) {
try {
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) {
continue;
}
pluginsByPath.set(filePath, {
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
}
} catch {
// best-effort
}
}
return [...pluginsByPath.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
};
try {
await userInstructionService.start();
} catch (error) {
@@ -858,13 +816,14 @@ async function listUserInstructionConfigs(
} finally {
userInstructionService.stop();
}
const pluginTools = await listPluginTools({
workspacePath: workspaceRoot,
cwd: workspaceRoot,
});
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the hub's
// (apps/cline-hub/src/server/user-instructions.ts) even if the preset
// defaults change.
const builtinToolCatalog = getCoreBuiltinToolCatalog({
enableSpawnAgent: true,
enableAgentTeams: true,
disabledToolIds: disabledTools,
});
@@ -875,7 +834,12 @@ async function listUserInstructionConfigs(
skills,
runtimeCommands,
agents: loadAgents(),
plugins: loadPlugins(),
plugins: hubSettings.plugins.map((plugin) => ({
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
contributions: plugin.contributions,
})),
tools: [
...builtinToolCatalog.map((tool) => ({
id: tool.id,
@@ -887,11 +851,11 @@ async function listUserInstructionConfigs(
source: "builtin",
headlessToolNames: tool.headlessToolNames,
})),
...pluginTools.map((tool) => ({
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
...hubSettings.tools.map((tool) => ({
id: tool.id,
name: tool.name,
description: tool.description,
enabled: tool.enabled,
enabled: tool.enabled !== false,
source: tool.source,
path: tool.path,
pluginName: tool.pluginName,
@@ -907,41 +871,6 @@ async function listUserInstructionConfigs(
// Native OS commands
// ---------------------------------------------------------------------------
// Async is load-bearing here: the native picker blocks until the user chooses
// a folder, and a synchronous exec would freeze every other sidecar command
// (chat streams, history, settings) for however long the dialog stays open.
async function pickWorkspaceDirectory(): Promise<string | null> {
const platform = process.platform;
if (platform === "darwin") {
try {
const { stdout } = await execFileAsync(
"osascript",
[
"-e",
'set theFolder to choose folder with prompt "Select workspace directory"',
"-e",
"return POSIX path of theFolder",
],
{ encoding: "utf8" },
);
return stdout.trim() || null;
} catch {
return null;
}
}
// Linux — try zenity
try {
const { stdout } = await execFileAsync(
"zenity",
["--file-selection", "--directory", "--title=Select workspace directory"],
{ encoding: "utf8" },
);
return stdout.trim() || null;
} catch {
return null;
}
}
function openFileInEditor(filePath: string): void {
const platform = process.platform;
const cmd =
@@ -1460,11 +1389,19 @@ export async function handleCommand(
const operation = String(args?.operation ?? "").trim();
if (!operation) throw new Error("operation is required");
const manager = new ProviderSettingsManager();
// Signed out is an expected state, not a command failure: resolve the
// token up front and return a typed result the webview can act on
// instead of letting the account service throw a generic error that
// would be captured as error telemetry and shown raw to the user.
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveFreshClineAuthToken(manager),
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -1604,22 +1541,78 @@ export async function handleCommand(
if (command === "list_mcp_servers") {
return readMcpServersResponse();
}
if (command === "set_mcp_server_disabled") {
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = {
...(current as JsonRecord),
disabled: Boolean(args?.disabled),
};
settings.mcpServers = servers;
if (command === "authorize_mcp_server_oauth") {
const name = String(args?.name ?? "").trim();
if (!name) throw new Error("server name is required");
const settingsPath = resolveMcpSettingsPath();
const registration = resolveMcpServerRegistration(name, {
filePath: settingsPath,
});
if (!registration) {
throw new Error(`unknown MCP server: ${name}`);
}
const wasDisabled = registration.disabled === true;
setMcpServerDisabled({ filePath: settingsPath, name, disabled: true });
try {
await runCancellableMcpOAuthAuthorization(
{
serverName: name,
filePath: settingsPath,
openUrl: openUrlInDefaultBrowser,
},
options?.connection,
);
} catch (error) {
if (!(error instanceof McpOAuthAuthorizationCancelledError)) {
throw error;
}
if (
shouldRestoreEnabledStateAfterOAuthCancellation(
wasDisabled,
error.reason,
)
) {
setMcpServerDisabled({
filePath: settingsPath,
name,
disabled: false,
});
}
return readMcpServersResponse();
}
setMcpServerDisabled({ filePath: settingsPath, name, disabled: false });
return readMcpServersResponse();
}
if (command === "cancel_mcp_server_oauth") {
const name = String(args?.name ?? "").trim();
if (!name) throw new Error("server name is required");
cancelMcpOAuthAuthorizationForReason(name, "user");
return readMcpServersResponse();
}
if (command === "set_mcp_server_disabled") {
const name = String(args?.name ?? "").trim();
const disabled = Boolean(args?.disabled);
const path = ensureMcpSettingsFile();
if (disabled) {
cancelMcpOAuthAuthorizationForReason(name, "server-disabled");
setMcpServerDisabled({ filePath: path, name, disabled: true });
return readMcpServersResponse();
}
const registration = resolveMcpServerRegistration(name, { filePath: path });
if (!registration) {
throw new Error(`unknown MCP server: ${name}`);
}
if (registration.transport.type !== "stdio") {
setMcpServerDisabled({ filePath: path, name, disabled: true });
const probe = await probeMcpServerConnection({
serverName: name,
filePath: path,
});
if (!probe.connected) {
return readMcpServersResponse();
}
}
setMcpServerDisabled({ filePath: path, name, disabled: false });
return readMcpServersResponse();
}
if (command === "upsert_mcp_server") {
@@ -1635,6 +1628,8 @@ export async function handleCommand(
const transportType = String(
input.transportType ?? input.transport_type ?? "",
).trim();
const requestedDisabled = Boolean(input.disabled);
const isRemote = transportType !== "stdio";
const next: JsonRecord =
transportType === "stdio"
? {
@@ -1645,7 +1640,7 @@ export async function handleCommand(
cwd: input.cwd,
env: input.env,
},
disabled: Boolean(input.disabled),
disabled: requestedDisabled,
metadata: input.metadata,
}
: {
@@ -1654,40 +1649,78 @@ export async function handleCommand(
url: input.url,
headers: input.headers,
},
disabled: Boolean(input.disabled),
disabled: requestedDisabled,
metadata: input.metadata,
};
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
// Preserve machine-managed fields the editor dialog doesn't expose:
// oauth tokens for remote servers and plugin-ownership metadata.
const sourceName =
previousName && servers[previousName] ? previousName : name;
const existing = servers[sourceName];
const upserted = { ...next };
if (existing && typeof existing === "object") {
const record = existing as JsonRecord;
if (upserted.metadata === undefined && record.metadata !== undefined) {
upserted.metadata = record.metadata;
const { shouldProbeAfterSave } = updateMcpSettingsFileSync(
path,
(settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
// Preserve machine-managed fields the editor dialog doesn't expose:
// oauth tokens for remote servers and plugin-ownership metadata.
const existingName =
previousName && servers[previousName] ? previousName : name;
const existing = servers[existingName];
let existingTransportIdentity: string | undefined;
let existingWasEnabled = false;
if (existing && typeof existing === "object") {
const record = existing as JsonRecord;
existingTransportIdentity = mcpTransportIdentity(
existingName,
record,
);
existingWasEnabled = record.disabled !== true;
}
// OAuth tokens were issued for a specific endpoint; carrying them
// onto an edited transport or URL would send the old server's
// credentials to a different endpoint.
if (
record.oauth !== undefined &&
mcpTransportIdentity(record) === mcpTransportIdentity(upserted)
) {
upserted.oauth = record.oauth;
const nextTransportIdentity = mcpTransportIdentity(name, next);
const transportIdentityUnchanged =
existingTransportIdentity === nextTransportIdentity;
const shouldProbe = shouldProbeMcpServerAfterUpsert({
isRemote,
requestedDisabled,
existingWasEnabled,
transportIdentityUnchanged,
});
const upserted: JsonRecord = {
...next,
disabled: requestedDisabled || shouldProbe,
};
if (existing && typeof existing === "object") {
const record = existing as JsonRecord;
if (
upserted.metadata === undefined &&
record.metadata !== undefined
) {
upserted.metadata = record.metadata;
}
// OAuth tokens were issued for a specific endpoint; carrying them
// onto an edited transport or URL would send the old server's
// credentials to a different endpoint.
if (record.oauth !== undefined && transportIdentityUnchanged) {
upserted.oauth = record.oauth;
}
if (record.oauthClient !== undefined && transportIdentityUnchanged) {
upserted.oauthClient = record.oauthClient;
}
}
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = upserted;
settings.mcpServers = servers;
return { shouldProbeAfterSave: shouldProbe };
},
);
if (shouldProbeAfterSave) {
const probe = await probeMcpServerConnection({
serverName: name,
filePath: path,
});
if (probe.connected) {
setMcpServerDisabled({ filePath: path, name, disabled: false });
}
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = upserted;
settings.mcpServers = servers;
});
}
return readMcpServersResponse();
}
if (command === "delete_mcp_server") {
@@ -1750,12 +1783,12 @@ export async function handleCommand(
// ── User instruction configs ──────────────────────────────────────
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(ctx.workspaceRoot);
return await listUserInstructionConfigs(ctx);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(ctx.workspaceRoot),
await listUserInstructionConfigs(ctx),
);
}
if (command === "install_marketplace_entry") {
@@ -1777,8 +1810,11 @@ export async function handleCommand(
if (!toolName) {
throw new Error("tool name is required");
}
toggleDisabledTool(toolName);
return await listUserInstructionConfigs(ctx.workspaceRoot);
const snapshot = await toggleHubSetting(ctx, {
type: "tools",
name: toolName,
});
return await listUserInstructionConfigs(ctx, snapshot);
}
if (command === "set_tool_disabled") {
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
@@ -1788,26 +1824,45 @@ export async function handleCommand(
if (toolNames.length === 0) {
throw new Error("tool name is required");
}
setDisabledTools(toolNames, args?.disabled === true);
return await listUserInstructionConfigs(ctx.workspaceRoot);
let snapshot: CoreSettingsSnapshot | undefined;
for (const name of toolNames) {
snapshot = await toggleHubSetting(ctx, {
type: "tools",
name,
enabled: args?.disabled !== true,
});
}
return await listUserInstructionConfigs(ctx, snapshot);
}
if (command === "set_plugin_disabled") {
const pluginPath = String(args?.path ?? "").trim();
if (!pluginPath) {
throw new Error("plugin path is required");
}
setDisabledPlugin(pluginPath, args?.disabled === true);
return await listUserInstructionConfigs(ctx.workspaceRoot);
const snapshot = await toggleHubSetting(ctx, {
type: "plugins",
path: pluginPath,
enabled: args?.disabled !== true,
});
return await listUserInstructionConfigs(ctx, snapshot);
}
// ── Native OS commands ────────────────────────────────────────────
if (command === "validate_workspace_directory") {
const workspacePath = String(args?.path ?? "").trim();
if (!workspacePath) return { valid: false };
// Support typed/pasted paths like "~/projects/app" from the manual
// path-entry fallback; return the resolved path so the caller adopts it.
const resolved =
workspacePath === "~"
? homedir()
: workspacePath.startsWith("~/") || workspacePath.startsWith("~\\")
? join(homedir(), workspacePath.slice(2))
: workspacePath;
try {
return { valid: statSync(workspacePath).isDirectory() };
return { valid: statSync(resolved).isDirectory(), path: resolved };
} catch {
return { valid: false };
return { valid: false, path: resolved };
}
}
if (command === "pick_workspace_directory") {
@@ -222,6 +222,79 @@ describe("Code sidecar runtime capabilities", () => {
});
});
it("announces a queued prompt start once when drain emits both queue events", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
let onEvent: ((event: unknown) => void) | undefined;
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
subscribe: vi.fn((handler: (event: unknown) => void) => {
onEvent = handler;
return () => {};
}),
dispose: vi.fn(),
});
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
const session = {
config: {},
messages: [],
promptsInQueue: [
{ id: "prompt-1", prompt: "hi there", steer: false, attachmentCount: 0 },
],
busy: false,
startedAt: Date.now(),
status: "running",
} satisfies LiveSession;
ctx.liveSessions.set("session-1", session);
// PendingPromptService.drain() emits both events for the same prompt:
// a queue snapshot with the head removed, then the submitted event.
onEvent?.({
type: "pending_prompts",
payload: { sessionId: "session-1", prompts: [] },
});
onEvent?.({
type: "pending_prompt_submitted",
payload: {
sessionId: "session-1",
id: "prompt-1",
prompt: "hi there",
attachmentCount: 0,
},
});
const starts = readEvents(ctx).filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_queued_prompt_start",
);
expect(starts).toHaveLength(1);
// A different prompt id must still be announced.
onEvent?.({
type: "pending_prompt_submitted",
payload: {
sessionId: "session-1",
id: "prompt-2",
prompt: "second",
attachmentCount: 0,
},
});
expect(
readEvents(ctx).filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_queued_prompt_start",
),
).toHaveLength(2);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
+55 -23
View File
@@ -325,6 +325,35 @@ function handleAgentEvent(
// CoreSessionEvent routing
// ---------------------------------------------------------------------------
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
// and a pending_prompt_submitted event for the same prompt back-to-back, and
// both are translated here into chat_queued_prompt_start — dedupe by prompt
// id or the UI renders the user message twice.
function emitQueuedPromptStart(
ctx: SidecarContext,
sessionId: string,
session: LiveSession | undefined,
input: {
promptId: string;
prompt: string;
attachmentCount: number;
userImages?: string[];
},
): void {
if (session) {
if (session.lastQueuedPromptStartId === input.promptId) {
return;
}
session.lastQueuedPromptStartId = input.promptId;
}
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
serializeQueuedPromptStart(input),
);
}
function handleCoreSessionEvent(
ctx: SidecarContext,
event: CoreSessionEvent,
@@ -367,17 +396,12 @@ function handleCoreSessionEvent(
previous[0] &&
previous[0].id !== mapped[0]?.id
) {
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
serializeQueuedPromptStart({
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
}),
);
emitQueuedPromptStart(ctx, sessionId, session, {
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
});
}
}
sendPromptsInQueueSnapshot(ctx, sessionId);
@@ -386,18 +410,26 @@ function handleCoreSessionEvent(
case "pending_prompt_submitted": {
const { sessionId, id, prompt, attachmentCount, userImages } =
event.payload;
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
serializeQueuedPromptStart({
promptId: id,
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
}),
);
const session = ctx.liveSessions.get(sessionId);
markQueuedAttachmentsSubmitted(session, id);
emitQueuedPromptStart(ctx, sessionId, session, {
promptId: id,
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
});
// The prompt left the queue; without a fresh snapshot the webview
// keeps a stale busy queue and the composer never returns to idle
// after the turn completes.
if (session) {
const remaining = session.promptsInQueue.filter(
(item) => item.id !== id,
);
if (remaining.length !== session.promptsInQueue.length) {
session.promptsInQueue = remaining;
sendPromptsInQueueSnapshot(ctx, sessionId);
}
}
break;
}
case "ended": {
@@ -0,0 +1,152 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getOfficialPluginInstallPath,
installMarketplaceEntry,
listMarketplaceInstalledEntries,
} from "./marketplace";
import type { JsonRecord } from "./types";
const GOAL_ENTRY = {
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
};
let tempClineDir: string;
let previousClineDir: string | undefined;
beforeEach(async () => {
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
previousClineDir = process.env.CLINE_DIR;
process.env.CLINE_DIR = tempClineDir;
});
afterEach(async () => {
if (previousClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = previousClineDir;
}
await rm(tempClineDir, { recursive: true, force: true });
});
function goalInstallDir(): string {
const path = getOfficialPluginInstallPath("goal");
if (!path) {
throw new Error("expected an official install path for goal");
}
return path;
}
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 },
);
expect(result).toMatchObject({
status: "installed",
message: "Installed Goal.",
});
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
});
it("does not pass --force for a clean first install", async () => {
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
});
it("still short-circuits when the directory contains a plugin module", async () => {
const installDir = goalInstallDir();
await mkdir(join(installDir, "package"), { recursive: true });
await writeFile(
join(installDir, "package.json"),
JSON.stringify({
name: "goal",
private: true,
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
}),
);
await writeFile(
join(installDir, "package", "index.ts"),
"export default {};",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
const result = await installMarketplaceEntry(
{ entry: GOAL_ENTRY },
{ spawnCommand },
);
expect(result).toMatchObject({
status: "installed",
message: "Goal is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
});
it("excludes partial install directories from the installed entries list", async () => {
await mkdir(goalInstallDir(), { recursive: true });
const empty = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
plugins: [],
} as JsonRecord);
expect(empty.installedKeys).toEqual([]);
const installDir = goalInstallDir();
await mkdir(join(installDir, "package"), { recursive: true });
await writeFile(
join(installDir, "package", "index.ts"),
"export default {};",
);
const populated = listMarketplaceInstalledEntries(
{ entries: [GOAL_ENTRY] },
{ plugins: [] } as JsonRecord,
);
expect(populated.installedKeys).toEqual(["plugin:goal"]);
});
});
@@ -25,7 +25,10 @@ import {
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import {
discoverPluginModulePaths,
resolveClineDir,
} from "@cline/shared/storage";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -584,7 +587,9 @@ function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function getOfficialPluginInstallPath(source: string): string | undefined {
export function getOfficialPluginInstallPath(
source: string,
): string | undefined {
const slug = source.trim();
if (!isOfficialPluginSlug(slug)) return undefined;
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
@@ -597,12 +602,33 @@ function getOfficialPluginInstallPath(source: string): string | undefined {
);
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "plugin") return false;
type OfficialPluginInstallState = "installed" | "partial" | "missing";
function getOfficialPluginInstallState(
entry: MarketplaceInstallInput,
): OfficialPluginInstallState {
if (entry.type !== "plugin") return "missing";
const [source] = entry.install.args ?? [];
if (!source) return false;
if (!source) return "missing";
const installPath = getOfficialPluginInstallPath(source);
return Boolean(installPath && existsSync(installPath));
if (!installPath || !existsSync(installPath)) return "missing";
// A bare directory is not an install: a failed or interrupted install can
// leave the directory behind with no plugin inside, and treating that as
// installed makes the next install attempt "succeed" silently ("already
// installed") while nothing actually works. Require a loadable plugin
// module before reporting the entry as installed; a directory without one
// is "partial" so the installer can reclaim it with --force.
try {
return discoverPluginModulePaths(installPath).length > 0
? "installed"
: "partial";
} catch {
return "partial";
}
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
return getOfficialPluginInstallState(entry) === "installed";
}
function resolveHomeDir(): string {
@@ -805,7 +831,8 @@ async function installPlugin(
"Plugin marketplace installs currently support exactly one source argument.",
);
}
if (isOfficialPluginInstalled(entry)) {
const installState = getOfficialPluginInstallState(entry);
if (installState === "installed") {
return {
id: entry.id,
type: entry.type,
@@ -819,6 +846,12 @@ async function installPlugin(
"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) {
@@ -0,0 +1,95 @@
import type {
AuthorizeMcpServerOAuthOptions,
AuthorizeMcpServerOAuthResult,
} from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
cancelMcpOAuthAuthorization,
cancelMcpOAuthAuthorizationForReason,
cancelMcpOAuthAuthorizationsForOwner,
McpOAuthAuthorizationCancelledError,
runCancellableMcpOAuthAuthorization,
shouldRestoreEnabledStateAfterOAuthCancellation,
} from "./mcp-oauth";
function waitForAbort(
options: AuthorizeMcpServerOAuthOptions,
): Promise<AuthorizeMcpServerOAuthResult> {
return new Promise((_, reject) => {
if (options.signal?.aborted) {
reject(new Error("aborted"));
return;
}
options.signal?.addEventListener(
"abort",
() => reject(new Error("aborted")),
{ once: true },
);
});
}
describe("desktop MCP OAuth authorization", () => {
it("starts browser authorization only through the explicit runner", async () => {
const authorize = vi.fn(
async (
options: AuthorizeMcpServerOAuthOptions,
): Promise<AuthorizeMcpServerOAuthResult> => ({
serverName: options.serverName,
authorized: true,
message: "authorized",
}),
);
await expect(
runCancellableMcpOAuthAuthorization({ serverName: "github" }, undefined, {
authorize,
}),
).resolves.toMatchObject({ authorized: true });
expect(authorize).toHaveBeenCalledOnce();
expect(authorize.mock.calls[0]?.[0].signal).toBeInstanceOf(AbortSignal);
});
it("cancels a pending callback when the server is turned off", async () => {
const pending = runCancellableMcpOAuthAuthorization(
{ serverName: "github" },
undefined,
{ authorize: waitForAbort },
);
expect(
cancelMcpOAuthAuthorizationForReason("github", "server-disabled"),
).toBe(true);
await expect(pending).rejects.toMatchObject({
name: "McpOAuthAuthorizationCancelledError",
reason: "server-disabled",
});
expect(cancelMcpOAuthAuthorization("github")).toBe(false);
});
it("restores a previously enabled server only for an explicit user cancel", () => {
expect(shouldRestoreEnabledStateAfterOAuthCancellation(false, "user")).toBe(
true,
);
expect(
shouldRestoreEnabledStateAfterOAuthCancellation(false, "server-disabled"),
).toBe(false);
expect(shouldRestoreEnabledStateAfterOAuthCancellation(true, "user")).toBe(
false,
);
});
it("cancels an abandoned flow when its webview connection closes", async () => {
const owner = {};
const pending = runCancellableMcpOAuthAuthorization(
{ serverName: "linear" },
owner,
{ authorize: waitForAbort },
);
expect(cancelMcpOAuthAuthorizationsForOwner({})).toBe(0);
expect(cancelMcpOAuthAuthorizationsForOwner(owner)).toBe(1);
await expect(pending).rejects.toBeInstanceOf(
McpOAuthAuthorizationCancelledError,
);
});
});
@@ -0,0 +1,124 @@
import {
type AuthorizeMcpServerOAuthOptions,
type AuthorizeMcpServerOAuthResult,
authorizeMcpServerOAuth,
} from "@cline/core";
export type McpOAuthCancellationReason =
| "user"
| "server-disabled"
| "superseded"
| "owner-closed";
export function shouldRestoreEnabledStateAfterOAuthCancellation(
wasDisabled: boolean,
reason: McpOAuthCancellationReason,
): boolean {
return !wasDisabled && reason === "user";
}
type PendingMcpOAuthAuthorization = {
controller: AbortController;
owner?: object;
cancellationReason?: McpOAuthCancellationReason;
};
const pendingAuthorizations = new Map<string, PendingMcpOAuthAuthorization>();
export class McpOAuthAuthorizationCancelledError extends Error {
constructor(
serverName: string,
readonly reason: McpOAuthCancellationReason,
) {
super(`MCP OAuth authorization was cancelled for "${serverName}".`);
this.name = "McpOAuthAuthorizationCancelledError";
}
}
export type McpOAuthAuthorizationDependencies = {
authorize: (
options: AuthorizeMcpServerOAuthOptions,
) => Promise<AuthorizeMcpServerOAuthResult>;
};
const defaultDependencies: McpOAuthAuthorizationDependencies = {
authorize: authorizeMcpServerOAuth,
};
/**
* Owns the lifetime of a desktop MCP browser authorization. Only an explicit
* call to this function can open the browser; ordinary server enable/connect
* attempts only persist the SDK's `authorizationRequired` status.
*/
export async function runCancellableMcpOAuthAuthorization(
options: Omit<AuthorizeMcpServerOAuthOptions, "signal">,
owner?: object,
dependencies: McpOAuthAuthorizationDependencies = defaultDependencies,
): Promise<AuthorizeMcpServerOAuthResult> {
const serverName = options.serverName.trim();
if (!serverName) {
throw new Error("MCP server name cannot be empty.");
}
cancelMcpOAuthAuthorizationForReason(serverName, "superseded");
const entry: PendingMcpOAuthAuthorization = {
controller: new AbortController(),
owner,
};
pendingAuthorizations.set(serverName, entry);
try {
try {
return await dependencies.authorize({
...options,
serverName,
signal: entry.controller.signal,
});
} catch (error) {
if (entry.controller.signal.aborted) {
throw new McpOAuthAuthorizationCancelledError(
serverName,
entry.cancellationReason ?? "superseded",
);
}
throw error;
}
} finally {
if (pendingAuthorizations.get(serverName) === entry) {
pendingAuthorizations.delete(serverName);
}
}
}
/** Cancels the pending browser callback flow for one MCP server, if present. */
export function cancelMcpOAuthAuthorization(serverName: string): boolean {
return cancelMcpOAuthAuthorizationForReason(serverName, "user");
}
export function cancelMcpOAuthAuthorizationForReason(
serverName: string,
reason: McpOAuthCancellationReason,
): boolean {
const entry = pendingAuthorizations.get(serverName);
if (!entry) {
return false;
}
entry.cancellationReason = reason;
entry.controller.abort();
pendingAuthorizations.delete(serverName);
return true;
}
/** Cancels MCP OAuth flows started by a webview connection that went away. */
export function cancelMcpOAuthAuthorizationsForOwner(owner: object): number {
let cancelled = 0;
for (const [serverName, entry] of pendingAuthorizations) {
if (entry.owner === owner) {
entry.cancellationReason = "owner-closed";
entry.controller.abort();
pendingAuthorizations.delete(serverName);
cancelled += 1;
}
}
return cancelled;
}
@@ -0,0 +1,140 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { handleCommand } from "./commands";
import {
buildMcpServersResponse,
shouldProbeMcpServerAfterUpsert,
} from "./mcp";
import type { JsonRecord, SidecarContext } from "./types";
function createContext(workspaceRoot: string): SidecarContext {
return {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot,
unsubscribeSessionEvents: null,
};
}
describe("desktop MCP settings", () => {
it("keeps valid and malformed entries visible independently", () => {
const response = buildMcpServersResponse("/tmp/cline_mcp_settings.json", {
mcpServers: {
linear: {
command: "npx",
args: ["-y", "mcp-remote", "https://mcp.linear.app/mcp"],
disabled: true,
oauth: {
authorizationRequired: true,
lastError: "OAuth authorization required",
},
},
broken: {},
},
});
const servers = response.servers as JsonRecord[];
expect(servers).toHaveLength(2);
expect(servers[0]).toMatchObject({
name: "linear",
transportType: "streamableHttp",
url: "https://mcp.linear.app/mcp",
disabled: true,
oauthStatus: {
authorizationRequired: true,
lastError: "OAuth authorization required",
},
});
expect(servers[1]).toMatchObject({
name: "broken",
transportType: "stdio",
});
expect(String(servers[1]?.configurationError)).toContain(
'Invalid MCP server "broken"',
);
});
it("does not probe an unchanged enabled remote server after editing", () => {
expect(
shouldProbeMcpServerAfterUpsert({
isRemote: true,
requestedDisabled: false,
existingWasEnabled: true,
transportIdentityUnchanged: true,
}),
).toBe(false);
expect(
shouldProbeMcpServerAfterUpsert({
isRemote: true,
requestedDisabled: false,
existingWasEnabled: true,
transportIdentityUnchanged: false,
}),
).toBe(true);
});
it("keeps an unchanged enabled remote server enabled when saving metadata", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "desktop-mcp-settings-"));
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
try {
await writeFile(
settingsPath,
JSON.stringify({
mcpServers: {
linear: {
transport: {
type: "streamableHttp",
url: "http://127.0.0.1:1/mcp",
},
},
broken: {},
},
}),
"utf8",
);
const response = (await handleCommand(
createContext(tempRoot),
"upsert_mcp_server",
{
input: {
name: "linear",
previousName: "linear",
transportType: "streamableHttp",
url: "http://127.0.0.1:1/mcp",
disabled: false,
metadata: { source: "edited" },
},
},
)) as JsonRecord;
const servers = response.servers as JsonRecord[];
expect(servers.find((server) => server.name === "linear")).toMatchObject({
disabled: false,
metadata: { source: "edited" },
});
expect(servers.find((server) => server.name === "broken")).toHaveProperty(
"configurationError",
);
const written = JSON.parse(await readFile(settingsPath, "utf8"));
expect(written.mcpServers.linear.disabled).toBe(false);
} finally {
if (previousSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
}
await rm(tempRoot, { recursive: true, force: true });
}
});
});
+107 -23
View File
@@ -1,64 +1,148 @@
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import {
getMcpServerOAuthStatus,
parseMcpServerRegistration,
updateMcpSettingsFileSync,
} from "@cline/core";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
export function shouldProbeMcpServerAfterUpsert(options: {
isRemote: boolean;
requestedDisabled: boolean;
existingWasEnabled: boolean;
transportIdentityUnchanged: boolean;
}): boolean {
return (
options.isRemote &&
!options.requestedDisabled &&
!(options.existingWasEnabled && options.transportIdentityUnchanged)
);
}
export function readMcpServersResponse(): JsonRecord {
const settingsPath = resolveMcpSettingsPath();
if (!existsSync(settingsPath)) {
return { settingsPath, hasSettingsFile: false, servers: [] };
}
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
const servers = parsed.mcpServers as JsonRecord | undefined;
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
const record = body as JsonRecord;
return buildMcpServersResponse(settingsPath, parsed);
}
export function buildMcpServersResponse(
settingsPath: string,
parsed: JsonRecord,
): JsonRecord {
const serversValue = parsed.mcpServers;
const servers =
serversValue &&
typeof serversValue === "object" &&
!Array.isArray(serversValue)
? (serversValue as JsonRecord)
: {};
const entries = Object.entries(servers).map(([name, body]) => {
const record =
body && typeof body === "object" && !Array.isArray(body)
? (body as JsonRecord)
: {};
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "stdio",
let registration: ReturnType<typeof parseMcpServerRegistration> | undefined;
let configurationError: string | undefined;
try {
registration = parseMcpServerRegistration(name, body);
} catch (error) {
configurationError =
error instanceof Error ? error.message : String(error);
}
const resolvedTransport = registration?.transport;
const rawTransportType = String(
transport?.type ?? record.transportType ?? record.type ?? "",
).trim();
const hasRawUrl =
typeof transport?.url === "string" || typeof record.url === "string";
const fallbackTransportType =
rawTransportType === "sse"
? "sse"
: rawTransportType === "streamableHttp" || rawTransportType === "http"
? "streamableHttp"
: hasRawUrl
? "sse"
: "stdio";
const oauthStatus = registration
? getMcpServerOAuthStatus(registration)
: undefined;
return {
name,
transportType,
transportType: resolvedTransport?.type ?? fallbackTransportType,
disabled: record.disabled === true,
command:
typeof transport?.command === "string"
command: resolvedTransport
? resolvedTransport.type === "stdio"
? resolvedTransport.command
: undefined
: typeof transport?.command === "string"
? transport.command
: typeof record.command === "string"
? record.command
: undefined,
args: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd:
typeof transport?.cwd === "string"
args: resolvedTransport
? resolvedTransport.type === "stdio"
? resolvedTransport.args
: undefined
: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd: resolvedTransport
? resolvedTransport.type === "stdio"
? resolvedTransport.cwd
: undefined
: typeof transport?.cwd === "string"
? transport.cwd
: typeof record.cwd === "string"
? record.cwd
: undefined,
env:
transport?.env && typeof transport.env === "object"
env: resolvedTransport
? resolvedTransport.type === "stdio"
? resolvedTransport.env
: undefined
: transport?.env && typeof transport.env === "object"
? transport.env
: record.env && typeof record.env === "object"
? record.env
: undefined,
url:
typeof transport?.url === "string"
url: resolvedTransport
? resolvedTransport.type !== "stdio"
? resolvedTransport.url
: undefined
: typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: undefined,
headers:
transport?.headers && typeof transport.headers === "object"
headers: resolvedTransport
? resolvedTransport.type !== "stdio"
? resolvedTransport.headers
: undefined
: transport?.headers && typeof transport.headers === "object"
? transport.headers
: record.headers && typeof record.headers === "object"
? record.headers
: undefined,
metadata: record.metadata,
metadata: registration?.metadata ?? record.metadata,
...(configurationError ? { configurationError } : {}),
oauthStatus: oauthStatus
? {
supported: oauthStatus.oauthSupported,
configured: oauthStatus.oauthConfigured,
authorizationRequired: oauthStatus.authorizationRequired,
lastError: oauthStatus.lastError,
lastAuthenticatedAt: oauthStatus.lastAuthenticatedAt,
}
: undefined,
};
});
return { settingsPath, hasSettingsFile: true, servers: entries };
@@ -129,6 +129,91 @@ describe("desktop error telemetry", () => {
});
});
it("forwards bounded source attribution for uncaught webview errors", async () => {
const server = createTestServer();
const { handler, capture } = createTelemetryHandler();
const response = await handler(
new Request("http://127.0.0.1:3126/telemetry/error", {
method: "POST",
headers: {
origin: "tauri://localhost",
"content-type": "application/json",
},
body: JSON.stringify({
operation: "webview.uncaught_error",
errorMessage: "Unexpected token '<'",
errorType: "SyntaxError",
handled: false,
transportState: "connecting",
sourceUrl: `tauri://localhost/_vercel/insights/script.js?${"q".repeat(600)}`,
lineno: 1,
colno: 1,
stack: `SyntaxError: Unexpected token '<'\n${"x".repeat(600)}`,
}),
}),
server,
);
expect(response?.status).toBe(202);
expect(capture).toHaveBeenCalledWith({
event: "sdk.error",
properties: expect.objectContaining({
component: "desktop",
operation: "webview.uncaught_error",
error_type: "SyntaxError",
error_message: "Unexpected token '<'",
handled: false,
transportState: "connecting",
lineno: 1,
colno: 1,
}),
});
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
string,
unknown
>;
expect(properties.sourceUrl).toHaveLength(500);
expect(
String(properties.sourceUrl).startsWith(
"tauri://localhost/_vercel/insights/script.js",
),
).toBe(true);
expect(properties.stack).toHaveLength(500);
});
it("drops malformed source attribution fields", async () => {
const server = createTestServer();
const { handler, capture } = createTelemetryHandler();
const response = await handler(
new Request("http://127.0.0.1:3126/telemetry/error", {
method: "POST",
headers: {
origin: "tauri://localhost",
"content-type": "application/json",
},
body: JSON.stringify({
operation: "webview.uncaught_error",
errorMessage: "boom",
sourceUrl: 42,
lineno: "one",
colno: null,
stack: " ",
}),
}),
server,
);
expect(response?.status).toBe(202);
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
string,
unknown
>;
expect("sourceUrl" in properties).toBe(false);
expect("lineno" in properties).toBe(false);
expect("colno" in properties).toBe(false);
expect("stack" in properties).toBe(false);
});
it("rejects error reports from untrusted origins", async () => {
const server = createTestServer();
const { handler, capture } = createTelemetryHandler();
+31 -3
View File
@@ -3,6 +3,7 @@ import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
import {
BunRuntime,
@@ -113,8 +114,16 @@ type DesktopClientErrorReport = {
command?: unknown;
timeoutMs?: unknown;
transportState?: unknown;
sourceUrl?: unknown;
lineno?: unknown;
colno?: unknown;
stack?: unknown;
};
// Bound for free-form attribution strings (source URLs, stack traces);
// matches ERROR_REPORT_FIELD_LIMIT in webview/lib/desktop-client.ts.
const ERROR_REPORT_FIELD_LIMIT = 500;
function captureDesktopError(
ctx: SidecarContext,
operation: string,
@@ -254,6 +263,24 @@ export function createFetchHandler(
if (typeof report.transportState === "string") {
context.transportState = report.transportState.slice(0, 30);
}
if (typeof report.sourceUrl === "string" && report.sourceUrl.trim()) {
context.sourceUrl = report.sourceUrl.slice(
0,
ERROR_REPORT_FIELD_LIMIT,
);
}
if (
typeof report.lineno === "number" &&
Number.isFinite(report.lineno)
) {
context.lineno = report.lineno;
}
if (typeof report.colno === "number" && Number.isFinite(report.colno)) {
context.colno = report.colno;
}
if (typeof report.stack === "string" && report.stack.trim()) {
context.stack = report.stack.slice(0, ERROR_REPORT_FIELD_LIMIT);
}
captureDesktopError(
ctx,
operation,
@@ -331,10 +358,11 @@ function createWebSocketHandler(ctx: SidecarContext) {
},
close(ws: SidecarWebSocketClient) {
ctx.wsClients.delete(ws);
// OAuth logins are interactive: if the connection that started one
// goes away (webview reload, transport drop), cancel it so the
// abandoned browser flow can never persist credentials later.
// Browser OAuth flows are interactive: if the connection that started
// one goes away (webview reload, transport drop), cancel its callback
// wait so the sidecar cannot retain an abandoned authorization attempt.
cancelProviderOAuthLoginsForOwner(ws);
cancelMcpOAuthAuthorizationsForOwner(ws);
},
};
}
@@ -60,6 +60,8 @@ export type LiveSession = {
attachedViaHub?: boolean;
/** Materialized attachment files for prompts still waiting in the queue. */
queuedAttachmentFiles?: Map<string, string[]>;
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
lastQueuedPromptStartId?: string;
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
consumedAttachmentFiles?: Map<string, string[]>;
};
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
normalizePickedDirectory,
type PickerExec,
pickWorkspaceDirectory,
} from "./workspace-picker";
/** Builds an error shaped like execFile's rejection values. */
function execError(props: { code?: unknown; signal?: unknown }): Error {
return Object.assign(new Error("Command failed"), props);
}
const CANCEL = execError({ code: 1 });
const MISSING = execError({ code: "ENOENT" });
const LAUNCH_FAILURE = execError({ code: "EACCES" });
const CRASH = execError({ code: null, signal: "SIGSEGV" });
/**
* Fake exec that responds per backend name and records the invocation order.
* A function outcome resolves with its return value as stdout; an Error
* outcome rejects.
*/
function fakeExec(outcomes: Record<string, string | Error>): {
exec: PickerExec;
calls: string[];
} {
const calls: string[] = [];
const exec: PickerExec = (command) => {
calls.push(command);
const outcome = outcomes[command];
if (outcome === undefined)
return Promise.reject(execError({ code: "ENOENT" }));
if (outcome instanceof Error) return Promise.reject(outcome);
return Promise.resolve({ stdout: outcome });
};
return { exec, calls };
}
describe("pickWorkspaceDirectory (linux)", () => {
it("returns the zenity selection", async () => {
const { exec } = fakeExec({ zenity: "/home/user/projects/app\n" });
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
"/home/user/projects/app",
);
});
it("returns null when the user cancels the zenity dialog", async () => {
const { exec, calls } = fakeExec({ zenity: CANCEL });
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBeNull();
expect(calls).toEqual(["zenity"]);
});
it("falls back to kdialog when zenity is missing", async () => {
const { exec, calls } = fakeExec({
zenity: MISSING,
kdialog: "/home/user/projects/app\n",
});
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
"/home/user/projects/app",
);
expect(calls).toEqual(["zenity", "kdialog"]);
});
// Regression: launch failures (EACCES, EMFILE, ENOMEM, ...) used to be
// classified as user cancellation, which skipped the kdialog fallback and
// silently swallowed the error.
it("falls back to kdialog when zenity fails to launch", async () => {
const { exec, calls } = fakeExec({
zenity: LAUNCH_FAILURE,
kdialog: "/home/user/projects/app\n",
});
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
"/home/user/projects/app",
);
expect(calls).toEqual(["zenity", "kdialog"]);
});
it("throws a descriptive error when every backend fails to launch", async () => {
const { exec } = fakeExec({ zenity: LAUNCH_FAILURE, kdialog: CRASH });
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
"zenity failed to launch (EACCES); kdialog was terminated by signal SIGSEGV",
);
});
it("throws the unavailable message when no backend is installed", async () => {
const { exec } = fakeExec({});
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
);
});
});
describe("pickWorkspaceDirectory (darwin)", () => {
it("returns the chosen folder", async () => {
const { exec } = fakeExec({ osascript: "/Users/user/projects/app/\n" });
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBe(
"/Users/user/projects/app",
);
});
it("returns null when the user cancels", async () => {
const { exec } = fakeExec({ osascript: CANCEL });
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBeNull();
});
it("throws when osascript fails for a reason other than cancel", async () => {
const { exec } = fakeExec({ osascript: execError({ code: "EMFILE" }) });
await expect(pickWorkspaceDirectory(exec, "darwin")).rejects.toThrow(
"osascript failed to launch (EMFILE)",
);
});
});
describe("normalizePickedDirectory", () => {
it("strips trailing whitespace and separators", () => {
expect(normalizePickedDirectory("/home/user/app/\n")).toBe(
"/home/user/app",
);
expect(normalizePickedDirectory("C:\\Users\\me\\app\\\\")).toBe(
"C:\\Users\\me\\app",
);
});
it("keeps the filesystem root intact", () => {
expect(normalizePickedDirectory("/\n")).toBe("/");
});
it("returns null for empty output", () => {
expect(normalizePickedDirectory(" \n")).toBeNull();
});
});
@@ -0,0 +1,127 @@
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export const FOLDER_PICKER_UNAVAILABLE_MESSAGE =
"No system folder picker found (zenity or kdialog). Type or paste a folder path in the workspace selector instead.";
/** Minimal exec surface so tests can simulate picker backends failing. */
export type PickerExec = (
command: string,
args: string[],
) => Promise<{ stdout: string }>;
const defaultExec: PickerExec = (command, args) =>
execFileAsync(command, args, { encoding: "utf8" });
interface ExecErrorShape {
code?: unknown;
signal?: unknown;
}
function execErrorShape(error: unknown): ExecErrorShape {
return typeof error === "object" && error !== null
? (error as ExecErrorShape)
: {};
}
/** A binary that isn't installed rejects at spawn time with code "ENOENT". */
export function isPickerCommandMissing(error: unknown): boolean {
return execErrorShape(error).code === "ENOENT";
}
// zenity, kdialog, and osascript all exit with status 1 when the user
// dismisses the dialog, which execFile reports as an error with a numeric
// `code` and no `signal`. Launch failures (EACCES, EMFILE, ENOMEM, ...)
// carry a string code instead, and crashes carry a `signal` — neither of
// those means the user cancelled, so they must not be swallowed.
export function isPickerCancellation(error: unknown): boolean {
const { code, signal } = execErrorShape(error);
return !signal && code === 1;
}
export function describePickerFailure(name: string, error: unknown): string {
const { code, signal } = execErrorShape(error);
if (signal) return `${name} was terminated by signal ${String(signal)}`;
if (typeof code === "number") return `${name} exited with code ${code}`;
if (typeof code === "string") return `${name} failed to launch (${code})`;
return `${name} failed: ${error instanceof Error ? error.message : String(error)}`;
}
export function folderPickerFailedMessage(failures: string[]): string {
return `The folder picker could not be opened (${failures.join("; ")}). Type or paste a folder path in the workspace selector instead.`;
}
export function normalizePickedDirectory(stdout: string): string | null {
const trimmed = stdout.trim();
if (!trimmed) return null;
// Dialogs occasionally return trailing separators (typed paths, GTK
// location bar); strip them so downstream normalization matches catalog
// entries.
const withoutTrailing = trimmed.replace(/(?<=.)[\\/]+$/, "");
return withoutTrailing || null;
}
// Async is load-bearing here: the native picker blocks until the user chooses
// a folder, and a synchronous exec would freeze every other sidecar command
// (chat streams, history, settings) for however long the dialog stays open.
//
// Contract: resolves to a path, resolves to null only when the user cancels,
// and throws when no picker backend could be opened — whether the binaries
// are missing or they failed to launch — so the UI can surface a manual
// path-entry fallback instead of a silent no-op.
export async function pickWorkspaceDirectory(
exec: PickerExec = defaultExec,
platform: NodeJS.Platform = process.platform,
): Promise<string | null> {
if (platform === "darwin") {
try {
const { stdout } = await exec("osascript", [
"-e",
'set theFolder to choose folder with prompt "Select workspace directory"',
"-e",
"return POSIX path of theFolder",
]);
return normalizePickedDirectory(stdout);
} catch (error) {
if (isPickerCancellation(error)) return null;
// osascript ships with macOS, so anything else is a real failure.
throw new Error(
folderPickerFailedMessage([describePickerFailure("osascript", error)]),
);
}
}
// Linux — try zenity, then kdialog. A backend that is missing or fails to
// launch falls through to the next candidate; only a clean cancel exit
// from a dialog that actually opened is treated as user cancellation.
const backends: { name: string; args: string[] }[] = [
{
name: "zenity",
args: [
"--file-selection",
"--directory",
"--title=Select workspace directory",
],
},
{ name: "kdialog", args: ["--getexistingdirectory", homedir()] },
];
const failures: string[] = [];
for (const backend of backends) {
try {
const { stdout } = await exec(backend.name, backend.args);
return normalizePickedDirectory(stdout);
} catch (error) {
if (isPickerCancellation(error)) return null;
if (!isPickerCommandMissing(error)) {
failures.push(describePickerFailure(backend.name, error));
}
}
}
throw new Error(
failures.length > 0
? folderPickerFailedMessage(failures)
: FOLDER_PICKER_UNAVAILABLE_MESSAGE,
);
}
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.8",
"version": "0.0.11",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -14,11 +14,42 @@
height: 100%;
min-height: 100vh;
overflow: hidden;
/* Keep scroll momentum inside the app: without this, hitting the end
* of an inner scroller rubber-bands the whole window like a web page. */
overscroll-behavior: none;
}
#__next {
height: 100%;
}
/*
* Native-app chrome: labels, buttons, and panel text must not highlight
* when the user drags across them. Content the user genuinely reads and
* copies chat messages, code, diffs, form fields opts back in below.
*/
body {
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
}
/* Selectable content surfaces: chat message bodies (incl. markdown and
* code blocks), reasoning/tool panels, and diff text. */
.cline-chat-message-content,
.cline-markdown,
.cline-chat-selectable,
pre,
code {
-webkit-user-select: text;
user-select: text;
}
}
/*
@@ -103,21 +134,35 @@
}
}
/* Softens the welcome <-> conversation swap: the hero and the message grid
* replace each other in a single commit, which otherwise reads as a hard
* white flash. Plays whenever the element (re)becomes visible display:none
* resets animations, so toggling Tailwind's `hidden` re-triggers it. */
@keyframes cline-view-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.cline-view-enter {
animation: cline-view-enter 180ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.cline-chat-streaming-title {
background: none;
-webkit-text-fill-color: currentcolor;
animation: none;
}
}
/* Tool panels scroll on both axes, but a horizontal thumb inside a short
* disclosure eats a whole detail row. Keep the axis scrollable (wheel, trackpad,
* keyboard, drag-select) and hide only its bar; the vertical one stays visible.
* `:horizontal` is the one selector that separates the two axes the standard
* `scrollbar-width: none` would take out both. */
.cline-chat-scroll-x-bare::-webkit-scrollbar:horizontal {
display: none;
.cline-view-enter {
animation: none;
}
}
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
@@ -1,6 +1,6 @@
import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import { DesktopErrorTelemetry } from "@/components/desktop-error-telemetry";
import { NativeShell } from "@/components/native-shell";
import { Toaster } from "@/components/ui/toaster";
import { HUB_THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme";
import "./globals.css";
@@ -48,9 +48,9 @@ export default function RootLayout({
</head>
<body className="h-full min-h-screen font-sans antialiased">
<DesktopErrorTelemetry />
<NativeShell />
{children}
<Toaster />
<Analytics />
</body>
</html>
);
+295 -126
View File
@@ -1,6 +1,7 @@
"use client";
import { ImagePlus } from "lucide-react";
import { ImagePlus, Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import {
useCallback,
useEffect,
@@ -30,14 +31,10 @@ import {
} from "@/components/ui/sidebar";
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { DiffView } from "@/components/views/chat/diff-view";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { OnboardingView } from "@/components/views/onboarding/onboarding-view";
import { SessionsView } from "@/components/views/sessions/sessions-view";
import {
type SettingsSection,
SettingsView,
} from "@/components/views/settings/settings-view";
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
import type { SettingsSection } from "@/components/views/settings/sections";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { useAppUpdate } from "@/hooks/use-app-update";
@@ -65,7 +62,13 @@ import {
markOnboardingCompleted,
ONBOARDING_RESET_EVENT,
} from "@/lib/onboarding";
import { fetchProviderCatalog } from "@/lib/provider-model-catalog";
import { isProviderConnected } from "@/lib/provider-connection";
import {
fetchProviderCatalog,
readProviderCatalogSnapshot,
subscribeToProviderCatalogInvalidation,
writeProviderCatalogSnapshot,
} from "@/lib/provider-model-catalog";
import {
buildSessionAgentActivity,
mergeAgentActivity,
@@ -85,6 +88,51 @@ import {
writeWorkspaceSelectionToWindow,
} from "@/lib/workspace-paths";
// Lazily loaded views: none of these are needed for the first paint of the
// chat shell, so keeping them out of the entry chunk shortens app startup.
// Each fallback paints the same background as the loaded view so switching
// never flashes.
const viewLoading = () => (
<div className="flex h-full flex-1 items-center justify-center bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
const SettingsView = dynamic(
() =>
import("@/components/views/settings/settings-view").then(
(module) => module.SettingsView,
),
{ loading: viewLoading, ssr: false },
);
const SessionsView = dynamic(
() =>
import("@/components/views/sessions/sessions-view").then(
(module) => module.SessionsView,
),
{ loading: viewLoading, ssr: false },
);
const OnboardingView = dynamic(
() =>
import("@/components/views/onboarding/onboarding-view").then(
(module) => module.OnboardingView,
),
{
loading: () => <div className="h-full w-full bg-background" />,
ssr: false,
},
);
const DiffView = dynamic(
() =>
import("@/components/views/chat/diff-view").then(
(module) => module.DiffView,
),
{ loading: viewLoading, ssr: false },
);
function makeThreadId(): string {
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
@@ -113,6 +161,11 @@ export default function Home() {
// Starts false on both server and first client render (hydration-safe);
// the effect below reads the persisted state right after mount.
const [showOnboarding, setShowOnboarding] = useState(false);
// "welcome" for the full first-run flow; "connect" when re-entered from
// the in-app "connect a model" notice, which should land directly on the
// provider setup step.
const [onboardingInitialStep, setOnboardingInitialStep] =
useState<OnboardingStep>("welcome");
const { navigation, threads } = appState;
const { activeThreadId, settingsSection, view } = navigation.current;
@@ -167,11 +220,17 @@ export default function Home() {
const completeOnboarding = useCallback(() => {
markOnboardingCompleted();
setShowOnboarding(false);
setOnboardingInitialStep("welcome");
// A fresh thread remounts the chat pane so it picks up credentials and
// the provider/model selection configured during onboarding.
handleNewThread();
}, [handleNewThread]);
const handleOpenSetup = useCallback(() => {
setOnboardingInitialStep("connect");
setShowOnboarding(true);
}, []);
const handleOpenSession = useCallback(
(session: SessionHistoryItem, initialPromptDraft?: string) => {
dispatchApp({ type: "open-session", session, initialPromptDraft });
@@ -252,6 +311,27 @@ export default function Home() {
}),
[handleNewThread, handleViewChange],
);
// Standard app shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for
// settings — matching the tray menu actions.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (showOnboarding) {
return;
}
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return;
}
if (event.key === "n" || event.key === "N") {
event.preventDefault();
handleNewThread();
} else if (event.key === ",") {
event.preventDefault();
handleViewChange("settings");
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleNewThread, handleViewChange, showOnboarding]);
const handleThreadStarted = useCallback((threadId: string) => {
dispatchApp({ type: "thread-started", threadId });
}, []);
@@ -372,6 +452,10 @@ export default function Home() {
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onOpenSessionById={handleOpenSessionById}
onOpenSetup={handleOpenSetup}
onOpenModelSettings={() =>
handleSettingsSectionChange("Models")
}
parentSession={activeParentSession}
onThreadStarted={handleThreadStarted}
/>
@@ -391,13 +475,23 @@ export default function Home() {
</SidebarProvider>
{showOnboarding ? (
<div className="fixed inset-0 z-50">
<OnboardingView onComplete={completeOnboarding} />
<OnboardingView
initialStep={onboardingInitialStep}
onComplete={completeOnboarding}
/>
</div>
) : null}
</AccountProvider>
);
}
// "+ new chat" remounts ChatThreadPane with a fresh thread id, and the pane
// blocks on the provider catalog (a large fetch) before rendering anything.
// Seed remounts from the last successful load (kept in the catalog module,
// where credential changes invalidate it) so only the first-ever mount shows
// the boot spinner; the effect still refreshes in the background.
let workspacesLoadedOnce = false;
function ChatThreadPane({
threadId,
historySession,
@@ -409,6 +503,8 @@ function ChatThreadPane({
onNewThread,
onOpenSession,
onOpenSessionById,
onOpenSetup,
onOpenModelSettings,
parentSession,
onThreadStarted,
}: {
@@ -428,6 +524,8 @@ function ChatThreadPane({
initialPromptDraft?: string,
) => void;
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
onOpenSetup?: () => void;
onOpenModelSettings?: () => void;
parentSession?: { sessionId: string; title?: string };
onThreadStarted?: (threadId: string) => void;
}) {
@@ -484,13 +582,23 @@ function ChatThreadPane({
const [dismissedHistorySessionId, setDismissedHistorySessionId] = useState<
string | null
>(null);
const [gitBranch, setGitBranch] = useState("no-git");
// Branch name, "no-git" once the folder is confirmed to not be a git
// repository, or null while branch discovery is pending.
const [gitBranch, setGitBranch] = useState<string | null>(null);
const [providerCredentials, setProviderCredentials] = useState<
Record<string, { apiKey: string }>
>({});
>(() => readProviderCatalogSnapshot()?.credentials ?? {});
const [providerModelContextWindows, setProviderModelContextWindows] =
useState<Record<string, Record<string, number>>>({});
const [providersLoaded, setProvidersLoaded] = useState(false);
useState<Record<string, Record<string, number>>>(
() => readProviderCatalogSnapshot()?.contextWindows ?? {},
);
const [providersLoaded, setProvidersLoaded] = useState(
() => readProviderCatalogSnapshot() !== null,
);
// null = unknown (catalog unavailable): never nag in that case.
const [hasConnectedProvider, setHasConnectedProvider] = useState<
boolean | null
>(null);
// History paths lead each merge: they are ordered by session recency, so
// stored or stale entries only append after them.
const [workspaces, setWorkspaces] = useState<string[]>(() =>
@@ -501,7 +609,9 @@ function ChatThreadPane({
),
),
);
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
const [workspacesLoaded, setWorkspacesLoaded] = useState(
() => workspacesLoadedOnce,
);
const hydratedSessionRef = useRef<string | null>(null);
const resetThreadRef = useRef<string | null>(null);
const manualTitleSessionRef = useRef<string | null>(null);
@@ -537,53 +647,67 @@ function ChatThreadPane({
});
}, [config.cwd, config.workspaceRoot, workspaces]);
useEffect(() => {
let cancelled = false;
async function loadProviderCredentials() {
try {
const payload = await fetchProviderCatalog();
if (cancelled) {
return;
const providerCredentialsRequestRef = useRef(0);
const loadProviderCredentials = useCallback(async () => {
const requestId = ++providerCredentialsRequestRef.current;
try {
const payload = await fetchProviderCatalog();
if (providerCredentialsRequestRef.current !== requestId) {
return;
}
const next: Record<string, { apiKey: string }> = {};
const nextContextWindows: Record<string, Record<string, number>> = {};
let anyConnected = false;
for (const provider of payload.providers ?? []) {
const id = provider.id?.trim();
if (!id) {
continue;
}
const next: Record<string, { apiKey: string }> = {};
const nextContextWindows: Record<string, Record<string, number>> = {};
for (const provider of payload.providers ?? []) {
const id = provider.id?.trim();
if (!id) {
continue;
}
next[id] = {
apiKey: provider.apiKey?.trim() ?? "",
};
const contextWindows: Record<string, number> = {};
for (const model of provider.modelList ?? []) {
if (
model.id &&
typeof model.contextWindow === "number" &&
Number.isFinite(model.contextWindow) &&
model.contextWindow > 0
) {
contextWindows[model.id] = model.contextWindow;
}
}
nextContextWindows[id] = contextWindows;
next[id] = {
apiKey: provider.apiKey?.trim() ?? "",
};
if (isProviderConnected(provider)) {
anyConnected = true;
}
setProviderCredentials(next);
setProviderModelContextWindows(nextContextWindows);
} catch {
// Keep current config if provider catalog cannot be read.
} finally {
if (!cancelled) setProvidersLoaded(true);
const contextWindows: Record<string, number> = {};
for (const model of provider.modelList ?? []) {
if (
model.id &&
typeof model.contextWindow === "number" &&
Number.isFinite(model.contextWindow) &&
model.contextWindow > 0
) {
contextWindows[model.id] = model.contextWindow;
}
}
nextContextWindows[id] = contextWindows;
}
writeProviderCatalogSnapshot({
credentials: next,
contextWindows: nextContextWindows,
});
setProviderCredentials(next);
setProviderModelContextWindows(nextContextWindows);
setHasConnectedProvider(anyConnected);
} catch {
// Keep current config if provider catalog cannot be read.
} finally {
if (providerCredentialsRequestRef.current === requestId) {
setProvidersLoaded(true);
}
}
void loadProviderCredentials();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void loadProviderCredentials();
// Credentials saved elsewhere (settings, onboarding, OAuth) invalidate
// the catalog cache; reload so the setup notice reflects reality
// without waiting for a pane remount.
return subscribeToProviderCatalogInvalidation(() => {
void loadProviderCredentials();
});
}, [loadProviderCredentials]);
const modelContextWindow =
providerModelContextWindows[config.provider.trim()]?.[config.model.trim()];
@@ -636,7 +760,9 @@ function ChatThreadPane({
const invalidateGitBranch = useCallback(() => {
gitBranchRequestGateRef.current.invalidate();
setGitBranch("no-git");
// Back to pending: the next workspace hasn't been classified yet, so
// don't report it as a confirmed non-repo in the meantime.
setGitBranch(null);
}, []);
const listGitBranches = useCallback(async (): Promise<{
@@ -713,6 +839,7 @@ function ChatThreadPane({
: merged;
});
} finally {
workspacesLoadedOnce = true;
setWorkspacesLoaded(true);
}
},
@@ -738,10 +865,13 @@ function ChatThreadPane({
return true;
}
const validation = await desktopClient
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
.invoke<{
valid?: boolean;
path?: string;
}>("validate_workspace_directory", {
path: nextWorkspace,
})
.catch(() => ({ valid: false }));
.catch(() => ({ valid: false, path: undefined }));
if (validation.valid !== true) {
return false;
}
@@ -749,14 +879,21 @@ function ChatThreadPane({
return false;
}
// The sidecar may resolve shorthand input (e.g. "~/projects/app")
// into an absolute path; adopt the resolved form.
const resolvedWorkspace =
typeof validation.path === "string" && validation.path.trim()
? validation.path.trim()
: nextWorkspace;
invalidateGitBranch();
setWorkspacePath(nextWorkspace);
setWorkspacePath(resolvedWorkspace);
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
filterWorkspacePaths(mergeWorkspacePaths(prev, [resolvedWorkspace])),
);
// Refresh the merged history, stored, and current workspace catalog.
void refreshWorkspaces(nextWorkspace);
void refreshWorkspaces(resolvedWorkspace);
return true;
},
@@ -772,6 +909,9 @@ function ChatThreadPane({
const pickWorkspaceDirectory = useCallback(
async (initialPath?: string): Promise<string | null> => {
// Resolves to null when the user cancels; rethrows picker failures
// (e.g. no zenity/kdialog on Linux) so callers can surface an error
// and offer manual path entry instead of a silent no-op.
try {
const selected = await desktopClient.invoke<string | null>(
"pick_workspace_directory",
@@ -784,8 +924,12 @@ function ChatThreadPane({
}
const trimmed = selected.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
} catch (error) {
throw new Error(
error instanceof Error && error.message.trim()
? error.message
: "The folder picker could not be opened.",
);
}
},
[],
@@ -1009,18 +1153,12 @@ function ChatThreadPane({
}
setDeletingSession(true);
try {
console.error(
`[webview:delete] invoke delete_chat_session sessionId=${activeSessionToDelete}`,
);
const deleted = await desktopClient.invoke<boolean>(
"delete_chat_session",
{
sessionId: activeSessionToDelete,
},
);
console.error(
`[webview:delete] invoke result sessionId=${activeSessionToDelete} deleted=${deleted}`,
);
if (!deleted) {
toast({
variant: "destructive",
@@ -1046,9 +1184,6 @@ function ChatThreadPane({
setShowDiffView(false);
void reset();
} catch (error) {
console.error(
`[webview:delete] invoke error sessionId=${activeSessionToDelete} error=${error instanceof Error ? error.message : String(error)}`,
);
const description =
error instanceof Error
? error.message
@@ -1134,11 +1269,61 @@ function ChatThreadPane({
[handleAttachFiles],
);
const attachmentList = pendingAttachments.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
name: file.name,
isImage: file.type.startsWith("image/"),
}));
const attachmentList = useMemo(
() =>
pendingAttachments.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
name: file.name,
isImage: file.type.startsWith("image/"),
})),
[pendingAttachments],
);
const handleRemoveAttachment = useCallback((id: string) => {
setPendingAttachments((prev) =>
prev.filter((file, index) => {
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
return fileId !== id;
}),
);
}, []);
const handleAbort = useCallback(() => {
void abort();
}, [abort]);
const handleModelChange = useCallback(
(nextModel: string) =>
setConfig((prev) =>
prev.model === nextModel ? prev : { ...prev, model: nextModel },
),
[setConfig],
);
const handleModeToggle = useCallback(
() =>
setConfig((prev) => ({
...prev,
mode: prev.mode === "plan" ? "act" : "plan",
})),
[setConfig],
);
const handleProviderChange = useCallback(
(nextProvider: string) =>
setConfig((prev) => {
const selected = providerCredentials[nextProvider];
const nextApiKey = selected?.apiKey ?? "";
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
return prev;
}
return {
...prev,
provider: nextProvider,
apiKey: nextApiKey,
};
}),
[providerCredentials, setConfig],
);
const handleSendPrompt = useCallback(
(prompt: string) => void handleSend(prompt),
[handleSend],
);
const firstUserMessage = messages.find(
(message) => message.role === "user",
@@ -1152,6 +1337,18 @@ function ChatThreadPane({
: (visibleHistorySession?.prompt ?? firstUserMessage),
});
const hasDiffChanges = summary.additions + summary.deletions > 0;
const headerDiff = useMemo(
() => ({
additions: summary.additions,
deletions: summary.deletions,
}),
[summary.additions, summary.deletions],
);
const handleOpenDiff = useCallback(() => {
if (summary.additions + summary.deletions > 0) {
setShowDiffView(true);
}
}, [summary.additions, summary.deletions]);
const activeSessionForTitle = hideDeletedSessionUi
? null
@@ -1295,49 +1492,20 @@ function ChatThreadPane({
const composer = (
<ChatInputBar
attachments={attachmentList}
onAbort={() => void abort()}
onAbort={handleAbort}
onAttachFiles={handleAttachFiles}
onListGitBranches={listGitBranches}
onRemoveAttachment={(id) => {
setPendingAttachments((prev) =>
prev.filter((file, index) => {
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
return fileId !== id;
}),
);
}}
onRemoveAttachment={handleRemoveAttachment}
onSwitchGitBranch={switchGitBranch}
onModelChange={(nextModel) =>
setConfig((prev) =>
prev.model === nextModel ? prev : { ...prev, model: nextModel },
)
}
onModeToggle={() =>
setConfig((prev) => ({
...prev,
mode: prev.mode === "plan" ? "act" : "plan",
}))
}
onModelChange={handleModelChange}
onModeToggle={handleModeToggle}
onPromptInputChange={handlePromptInputChange}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={steerPromptInQueue}
onEditPromptInQueue={updatePromptInQueue}
onRemovePromptInQueue={handleRemoveQueuedPrompt}
onProviderChange={(nextProvider) =>
setConfig((prev) => {
const selected = providerCredentials[nextProvider];
const nextApiKey = selected?.apiKey ?? "";
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
return prev;
}
return {
...prev,
provider: nextProvider,
apiKey: nextApiKey,
};
})
}
onSend={(prompt) => void handleSend(prompt)}
onProviderChange={handleProviderChange}
onSend={handleSendPrompt}
gitBranch={gitBranch}
model={config.model}
modelContextWindow={modelContextWindow}
@@ -1381,7 +1549,7 @@ function ChatThreadPane({
</div>
) : null}
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
agentActivity={agentActivity}
agents={agents}
@@ -1394,15 +1562,10 @@ function ChatThreadPane({
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
diff={headerDiff}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onOpenDiff={handleOpenDiff}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
@@ -1441,6 +1604,17 @@ function ChatThreadPane({
}
composer={composer}
gitBranch={gitBranch}
notice={
providersLoaded &&
hasConnectedProvider === false &&
onOpenSetup &&
onOpenModelSettings ? (
<WelcomeSetupNotice
onOpenModelSettings={onOpenModelSettings}
onOpenSetup={onOpenSetup}
/>
) : undefined
}
onListGitBranches={listGitBranches}
onStartChat={setPromptInput}
onSwitchGitBranch={switchGitBranch}
@@ -1453,11 +1627,6 @@ function ChatThreadPane({
if (deletingSession) {
return;
}
if (!open) {
console.error(
`[webview:delete] cancelled sessionId=${activeSessionToDelete ?? "null"}`,
);
}
setDeleteConfirmOpen(open);
}}
>
@@ -13,7 +13,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { type CSSProperties, useEffect, useMemo, useState } from "react";
import { type CSSProperties, memo, useEffect, useMemo, useState } from "react";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import {
agentEntryState,
@@ -62,7 +62,7 @@ type AgentHeaderProps = {
onOpenParentSession?: (parentSessionId: string) => void | Promise<void>;
};
export function AgentHeader({
function AgentHeaderImpl({
title,
canEditTitle,
renamingTitle,
@@ -179,7 +179,7 @@ export function AgentHeader({
className={cn(
"min-w-0 truncate text-sm font-medium text-foreground",
canEditTitle &&
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
"rounded px-1 py-0.5 transition-colors hover:bg-surface-hover",
)}
disabled={renamingTitle}
onClick={(event) => {
@@ -265,7 +265,7 @@ export function AgentHeader({
) : (
<Button
aria-label="New session"
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
onClick={() => onNewThread?.()}
size="icon-sm"
variant="ghost"
@@ -279,6 +279,11 @@ export function AgentHeader({
);
}
// Memoized: the header sits above the streaming conversation and would
// otherwise re-render on every stream flush; its props are kept
// referentially stable by the chat pane.
export const AgentHeader = memo(AgentHeaderImpl);
/**
* Route from a child agent run back to the session that spawned it, in the
* header slot the "new session" button occupies elsewhere.
@@ -304,7 +309,7 @@ function SubagentSessionBadge({
return (
<Button
aria-label={hint}
className="h-7 shrink-0 gap-1 rounded-md text-xs font-normal text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="h-7 shrink-0 gap-1 rounded-md text-xs font-normal text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
disabled={!onOpenParentSession}
id="subagent-session-badge"
onClick={() => void onOpenParentSession?.(parentSession.sessionId)}
@@ -529,7 +534,7 @@ function AgentRosterRow({
return (
<li>
<button
className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/60"
className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
onClick={onSelect}
title="Open this agent's session"
type="button"
@@ -77,7 +77,7 @@ import {
CUSTOMIZATION_SECTIONS,
SETTINGS_SECTIONS,
type SettingsSection,
} from "@/components/views/settings/settings-view";
} from "@/components/views/settings/sections";
import { useAccount } from "@/contexts/account-context";
import type {
SessionThread,
@@ -163,7 +163,7 @@ function SettingsSectionNavigation({
className={cn(
"min-w-0 justify-start",
activeSection === section &&
"bg-sidebar-accent text-sidebar-accent-foreground",
"bg-surface-hover text-sidebar-foreground",
collapsed && "size-9 justify-center px-0",
)}
key={section}
@@ -182,7 +182,7 @@ function SettingsSectionNavigation({
<nav
aria-label="Settings sections"
className={cn(
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
"flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden",
collapsed ? "w-full items-start" : "w-full",
)}
>
@@ -479,7 +479,7 @@ export function AgentSidebar({
<DropdownMenuTrigger asChild>
<Button
aria-label="Filter sessions"
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground"
variant="ghost"
size="icon"
>
@@ -533,7 +533,7 @@ export function AgentSidebar({
<DropdownMenuTrigger asChild>
<Button
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground"
size="icon"
title={sortMode === "time" ? "Sort by time" : "Sort by project"}
variant="ghost"
@@ -589,7 +589,7 @@ export function AgentSidebar({
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
<div
className={cn(
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-[4.75rem]",
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-19",
isCollapsed && "px-0",
)}
data-tauri-drag-region
@@ -598,7 +598,7 @@ export function AgentSidebar({
<>
<Button
aria-label="Previous page"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
className="size-7 text-muted-foreground hover:bg-surface-hover"
disabled={!canNavigateBack}
onClick={navigateBack}
size="icon"
@@ -626,7 +626,7 @@ export function AgentSidebar({
<div
className={cn(
"flex h-10 shrink-0 items-center justify-between px-3",
"flex h-10 shrink-0 items-center justify-between px-2",
isCollapsed && "px-1.5",
)}
>
@@ -644,7 +644,7 @@ export function AgentSidebar({
<button
aria-label="Cline home"
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
"flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
isCollapsed && "size-9",
)}
onClick={openHome}
@@ -733,11 +733,11 @@ export function AgentSidebar({
</div>
) : (
<>
<div className="mt-5 shrink-0 px-3">
<div className="mt-5 shrink-0 pl-4 pr-2">
<div className="flex h-8 items-center justify-between gap-2">
<button
className={cn(
"min-w-0 truncate text-sm font-medium text-muted-foreground transition-colors hover:text-sidebar-foreground",
"min-w-0 truncate text-sm font-medium text-muted-foreground",
view === "sessions" && "text-sidebar-foreground",
)}
onClick={openSessions}
@@ -748,7 +748,7 @@ export function AgentSidebar({
<div className="flex shrink-0 items-center gap-0.5">
<Button
aria-label="Search sessions"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
className="m-0! size-8 p-0! text-muted-foreground hover:bg-surface-hover"
onClick={() => setSearchOpen((current) => !current)}
size="icon"
title="Search sessions"
@@ -777,7 +777,7 @@ export function AgentSidebar({
<div className="mt-1 min-h-0 w-full flex-1">
<ScrollArea className="h-full min-h-0 w-full min-w-0">
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-2">
{isLoadingHistory && threads.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
Loading session history...
@@ -899,16 +899,16 @@ export function AgentSidebar({
<button
aria-label="Account settings"
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
"flex min-w-0 flex-1 items-center gap-2.5 rounded-md p-2 text-left text-sidebar-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
view === "settings" &&
settingsSection === "Account" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
"bg-surface-hover text-sidebar-foreground",
)}
onClick={() => openSettingsSection("Account")}
title={user.email || undefined}
type="button"
>
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
{accountInitial}
</span>
<span className="flex min-w-0 flex-col leading-tight">
@@ -928,7 +928,7 @@ export function AgentSidebar({
"size-9 shrink-0 justify-center px-0",
view === "settings" &&
settingsSection !== "Account" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
"bg-surface-hover text-sidebar-foreground",
)}
onClick={openSettings}
title="Settings"
@@ -945,7 +945,7 @@ export function AgentSidebar({
"min-w-0 justify-start",
isCollapsed && "size-9 justify-center px-0",
view === "settings" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
"bg-surface-hover text-sidebar-foreground",
)}
onClick={openSettings}
title="Settings"
@@ -1022,7 +1022,7 @@ function ProjectSection({
<div className="mb-1 min-w-0">
<button
aria-expanded={!collapsed}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={onToggle}
title={label}
type="button"
@@ -1089,7 +1089,7 @@ function ThreadItem({
className={cn(
"grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
? "bg-surface-hover text-sidebar-foreground"
: "text-sidebar-foreground/80",
)}
>
@@ -1114,10 +1114,10 @@ function ThreadItem({
<HoverCardTrigger asChild>
<button
className={cn(
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
? "bg-surface-hover text-sidebar-foreground"
: "text-sidebar-foreground/80 hover:bg-surface-hover",
)}
disabled={pending}
onClick={onClick}
@@ -0,0 +1,79 @@
// @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 { DesktopErrorTelemetry } from "./desktop-error-telemetry";
const { reportError } = vi.hoisted(() => ({ reportError: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { reportError },
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
reportError.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
async function renderTelemetry() {
await act(async () => {
root.render(<DesktopErrorTelemetry />);
});
}
describe("DesktopErrorTelemetry", () => {
it("attributes uncaught errors to their source URL and position", async () => {
await renderTelemetry();
// A script URL answered with HTML surfaces exactly like this: a parse
// SyntaxError whose only pointer to the failing resource is filename.
window.dispatchEvent(
new ErrorEvent("error", {
message: "Uncaught SyntaxError: Unexpected token '<'",
filename: "tauri://localhost/_vercel/insights/script.js",
lineno: 1,
colno: 1,
}),
);
expect(reportError).toHaveBeenCalledWith(
expect.objectContaining({
operation: "webview.uncaught_error",
handled: false,
sourceUrl: "tauri://localhost/_vercel/insights/script.js",
lineno: 1,
colno: 1,
}),
);
});
it("omits attribution fields when the ErrorEvent carries none", async () => {
await renderTelemetry();
window.dispatchEvent(
new ErrorEvent("error", {
message: "boom",
error: new Error("boom"),
}),
);
expect(reportError).toHaveBeenCalledTimes(1);
const report = reportError.mock.calls[0]?.[0] as Record<string, unknown>;
expect(report.operation).toBe("webview.uncaught_error");
expect(report.sourceUrl).toBeUndefined();
expect(report.lineno).toBeUndefined();
expect(report.colno).toBeUndefined();
});
});
@@ -11,6 +11,12 @@ export function DesktopErrorTelemetry() {
error:
event.error ?? new Error(event.message || "Unknown webview error"),
handled: false,
// For script-load failures (e.g. a chunk URL answered with HTML)
// the ErrorEvent's filename is the only pointer to the failing
// resource — the error object has no stack in that case.
sourceUrl: event.filename || undefined,
lineno: event.lineno || undefined,
colno: event.colno || undefined,
});
};
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
function isEditable(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return Boolean(target.closest("input, textarea, [contenteditable='true']"));
}
function hasTextSelection(): boolean {
const selection = window.getSelection();
return Boolean(selection && !selection.isCollapsed);
}
/**
* Suppresses the WebView's built-in browser context menu (Back / Forward /
* Reload / Inspect Element) so right-clicking app chrome behaves like a
* native app instead of a web page.
*
* Radix context menus (e.g. on sidebar sessions) attach their own
* `contextmenu` handlers on their triggers and call `preventDefault`
* themselves, so they keep working. Editable fields and active text
* selections keep the default menu for spellcheck / copy / paste.
*/
export function NativeShell() {
useEffect(() => {
const handleContextMenu = (event: MouseEvent) => {
if (isEditable(event.target) || hasTextSelection()) {
return;
}
event.preventDefault();
};
// Non-capture: runs after component-level handlers, so custom menus
// that already prevented default are unaffected either way.
window.addEventListener("contextmenu", handleContextMenu);
return () => window.removeEventListener("contextmenu", handleContextMenu);
}, []);
return null;
}
@@ -16,7 +16,7 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"text-foreground [a&]:hover:bg-surface-hover [a&]:hover:text-foreground",
},
},
defaultVariants: {
@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5",
className,
)}
{...props}
@@ -5,7 +5,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3 shrink-0 [&_svg]:shrink-0 outline-none 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",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3 shrink-0 [&_svg]:shrink-0 outline-none 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",
{
variants: {
variant: {
@@ -13,25 +13,25 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background shadow-xs hover:bg-surface-hover hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-surface-hover",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"hover:bg-surface-hover hover:text-foreground dark:hover:bg-surface-hover",
link: "text-primary underline-offset-4 hover:underline",
sidebar:
"w-full text-left gap-2 rounded-none text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground self-start",
"w-full text-left gap-2 rounded-none text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground self-start",
sidebarItem:
"!h-auto w-full justify-start text-left gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
"!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 transition-colors hover:text-sidebar-foreground",
text: "bg-transparent text-sm font-medium text-muted-foreground hover:text-accent-foreground",
"!h-auto justify-start gap-1 px-3 py-1.5 text-xs font-medium 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]:px-3",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-3 gap-1.5 px-3 has-[>svg]:size-3",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
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",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 text-lg",
icon: "size-5",
"icon-sm": "size-3 p-1",
"icon-lg": "size-10",
@@ -107,13 +107,16 @@ function Calendar({
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
"rounded-l-md bg-surface-hover",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
range_end: cn(
"rounded-r-md bg-surface-hover",
defaultClassNames.range_end,
),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
"bg-surface-hover text-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
@@ -204,7 +207,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-surface-hover data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
@@ -1,236 +0,0 @@
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"section"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<section
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
data-slot="carousel"
{...props}
>
{children}
</section>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
@@ -1,349 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
const cssText = Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n");
return <style>{cssText}</style>;
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -146,7 +146,7 @@ function ComboboxItem({
return (
<ComboboxPrimitive.Item
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-highlighted:bg-surface-hover data-highlighted:text-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-slot="combobox-item"
@@ -182,7 +182,7 @@ function ComboboxLabel({
return (
<ComboboxPrimitive.GroupLabel
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
"text-muted-foreground px-2 py-1.5 text-sm pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
className,
)}
data-slot="combobox-label"
@@ -251,7 +251,7 @@ function ComboboxChip({
return (
<ComboboxPrimitive.Chip
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-sm font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
data-slot="combobox-chip"
@@ -146,7 +146,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[selected=true]:bg-surface-hover data-[selected=true]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -66,7 +66,7 @@ function ContextMenuSubTrigger({
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -126,7 +126,7 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -144,7 +144,7 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -169,7 +169,7 @@ function ContextMenuRadioItem({
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -69,7 +69,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-surface-hover data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -29,7 +29,7 @@ function ItemSeparator({
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-surface-hover-lighter [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
@@ -55,7 +55,7 @@ function MenubarTrigger({
return (
<MenubarPrimitive.Trigger
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
data-slot="menubar-trigger"
@@ -100,7 +100,7 @@ function MenubarItem({
return (
<MenubarPrimitive.Item
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-inset={inset}
@@ -121,7 +121,7 @@ function MenubarCheckboxItem({
<MenubarPrimitive.CheckboxItem
checked={checked}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-slot="menubar-checkbox-item"
@@ -145,7 +145,7 @@ function MenubarRadioItem({
return (
<MenubarPrimitive.RadioItem
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-slot="menubar-radio-item"
@@ -227,7 +227,7 @@ function MenubarSubTrigger({
return (
<MenubarPrimitive.SubTrigger
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
data-inset={inset}
@@ -59,7 +59,7 @@ function NavigationMenuItem({
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground focus:bg-surface-hover focus:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-surface-hover data-[state=open]:text-foreground data-[state=open]:focus:bg-surface-hover data-[state=open]:bg-surface-hover focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@@ -108,7 +108,7 @@ function NavigationMenuViewport({
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border shadow md:w-(--radix-navigation-menu-viewport-width)",
className,
)}
{...props}
@@ -125,7 +125,7 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
"data-[active=true]:focus:bg-surface-hover data-[active=true]:hover:bg-surface-hover data-[active=true]:bg-surface-hover data-[active=true]:text-foreground hover:bg-surface-hover hover:text-foreground focus:bg-surface-hover focus:text-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -141,7 +141,7 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
@@ -17,7 +17,7 @@ function ScrollArea({
{...props}
>
<ScrollAreaPrimitive.Viewport
className="focus-visible:ring-ring/50 size-full rounded-[inherit] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
data-slot="scroll-area-viewport"
>
{children}
@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -74,7 +74,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
)}
>
{children}
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-surface-hover focus:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -303,7 +303,7 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-120 group-data-[state=expanded]:ease-out group-data-[state=collapsed]:ease-in",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@@ -314,7 +314,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-120 group-data-[state=expanded]:ease-out group-data-[state=collapsed]:ease-in md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
@@ -430,7 +430,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onPointerDown={handlePointerDown}
title="Drag to resize or click to toggle sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
@@ -565,7 +565,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
@@ -613,13 +613,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-surface-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-surface-hover active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-surface-hover data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-surface-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: "hover:bg-surface-hover hover:text-sidebar-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-surface-hover hover:text-sidebar-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-border))]",
},
size: {
default: "h-8 text-sm",
@@ -700,7 +700,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground peer-hover/menu-button:text-sidebar-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
@@ -726,7 +726,7 @@ function SidebarMenuBadge({
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-hover/menu-button:text-sidebar-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
@@ -825,8 +825,8 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground active:bg-surface-hover active:text-sidebar-foreground [&>svg]:text-sidebar-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-surface-hover data-[active=true]:text-sidebar-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
className={cn("bg-surface-hover animate-pulse rounded-md", className)}
{...props}
/>
);
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap has-[[role=checkbox]]:pr-0 *:[[role=checkbox]]:translate-y-0.5",
className,
)}
{...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"p-2 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0 *:[[role=checkbox]]:translate-y-0.5",
className,
)}
{...props}
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-105",
className,
)}
{...props}
@@ -7,13 +7,13 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center cursor-pointer justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center cursor-pointer justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:opacity-50 data-[state=on]:bg-surface-hover data-[state=on]:text-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
"border border-input bg-transparent shadow-xs hover:bg-surface-hover hover:text-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
@@ -5,6 +5,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import {
MODEL_SELECTION_STORAGE_KEY,
parseModelSelectionStorage,
} from "@/lib/model-selection";
import {
buildUserInstructionSlashCommands,
ChatInputBar,
@@ -479,6 +483,282 @@ describe("ChatInputBar", () => {
expect(onRemovePromptInQueue).toHaveBeenCalledWith("queued-prompt-1");
});
it("displays a queued team command as its slash form, not the runtime envelope", async () => {
const onEditPromptInQueue = vi.fn();
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={onEditPromptInQueue}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onRemovePromptInQueue={vi.fn()}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[
{
id: "queued-team",
prompt:
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
steer: false,
},
]}
provider="cline"
reasoningEffort="low"
status="running"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
});
const queueToggle = [
...container.querySelectorAll<HTMLButtonElement>(
"button[aria-controls][aria-expanded]",
),
].find((button) => button.textContent?.includes("prompt queued"));
await act(async () => queueToggle?.click());
const queuedPrompts = document.getElementById(
queueToggle?.getAttribute("aria-controls") ?? "",
);
expect(queuedPrompts?.textContent).toContain("/team inspect the app");
expect(queuedPrompts?.textContent).not.toContain("<user_command");
// Editing prefills the slash form; the sidecar re-resolves it on save.
await act(async () => {
container
.querySelector<HTMLButtonElement>('[aria-label="Edit queued prompt"]')
?.click();
});
const editor = container.querySelector<HTMLTextAreaElement>(
'[aria-label="Edit queued prompt"]',
);
expect(editor?.value).toBe("/team inspect the app");
});
it("does not overwrite the remembered model when rendering a session's provider/model", async () => {
// Opening an existing session drives the composer's provider/model
// props to that session's config. That passive change must not
// replace the user's explicitly picked default for new sessions.
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify({
lastProvider: "cline",
lastModelByProvider: { cline: "test-model" },
}),
);
loadProviderModelCatalogMock.mockResolvedValue({
providers: [],
enabledProviderIds: ["openrouter"],
providerModels: {
openrouter: ["old-session-model", "user-picked-model"],
},
providerReasoningModels: { openrouter: [] },
});
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="old-session-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
onRemovePromptInQueue={vi.fn()}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
provider="openrouter"
reasoningEffort="low"
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking={false}
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
await vi.waitFor(() => {
expect(loadProviderModelsMock).toHaveBeenCalledWith("openrouter");
});
// The composer displays the session's model...
const modelTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label^="Model:"]',
);
expect(modelTrigger?.textContent).toContain("old-session-model");
// ...but the remembered selection for new sessions stays intact.
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
),
).toEqual({
lastProvider: "cline",
lastModelByProvider: { cline: "test-model" },
});
// An explicit pick in the model dropdown DOES update the remembered
// selection.
await act(async () => modelTrigger?.click());
const panel = document.querySelector('[role="dialog"]');
const option = [
...(panel?.querySelectorAll<HTMLButtonElement>("button") ?? []),
].find((entry) => entry.textContent?.includes("user-picked-model"));
expect(option).toBeTruthy();
await act(async () => option?.click());
expect(
parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
),
).toEqual({
lastProvider: "openrouter",
lastModelByProvider: {
cline: "test-model",
openrouter: "user-picked-model",
},
});
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
});
it("attaches clipboard images on paste instead of inserting text", async () => {
const onAttachFiles = vi.fn();
const onPromptInputChange = vi.fn();
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={onAttachFiles}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={onPromptInputChange}
onProviderChange={vi.fn()}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onRemovePromptInQueue={vi.fn()}
onSend={vi.fn()}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
promptDraft={{ version: 0, value: "" }}
promptsInQueue={[]}
provider="cline"
reasoningEffort="low"
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
const promptInput = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
expect(promptInput).not.toBeNull();
const pasteWithClipboard = async (items: unknown[]) => {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
value: { items, getData: () => "" },
});
await act(async () => {
promptInput?.dispatchEvent(event);
await Promise.resolve();
});
return event;
};
const png = new File(["fake"], "image.png", { type: "image/png" });
const imagePaste = await pasteWithClipboard([
{ kind: "file", type: "image/png", getAsFile: () => png },
]);
expect(onAttachFiles).toHaveBeenCalledTimes(1);
const attached = onAttachFiles.mock.calls[0][0] as File[];
expect(attached).toHaveLength(1);
expect(attached[0].name).toMatch(/^pasted-image-.+\.png$/);
expect(imagePaste.defaultPrevented).toBe(true);
// Plain-text pastes stay untouched so normal text pasting keeps working.
const textPaste = await pasteWithClipboard([
{ kind: "string", type: "text/plain", getAsFile: () => null },
]);
expect(onAttachFiles).toHaveBeenCalledTimes(1);
expect(textPaste.defaultPrevented).toBe(false);
});
});
describe("ChatInputBar token ring", () => {
@@ -1,16 +1,11 @@
"use client";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
import {
ArrowUp,
Brain,
ChevronDown,
CircleStop,
Cpu,
Paperclip,
X,
} from "lucide-react";
CLINE_DEFAULT_MODEL_ID,
formatDisplayUserInput,
} from "@cline/shared/browser";
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -29,6 +24,7 @@ import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { formatCostUsd } from "@/hooks/use-session-history";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
import { desktopClient } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
@@ -82,6 +78,11 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
{ name: "team", description: "Start the task with an agent team" },
];
// Last known user commands, kept across composer instances so reopening the
// slash menu paints instantly (stale-while-revalidate); the fetch that
// follows still picks up newly installed skills and workflows.
let cachedSlashCommands: SlashCommand[] | null = null;
export function buildUserInstructionSlashCommands(
response: UserInstructionConfigResponse,
): SlashCommand[] {
@@ -251,7 +252,8 @@ type ChatInputBarProps = {
mode: "act" | "plan";
thinking: ChatSessionConfig["thinking"];
reasoningEffort: ChatSessionConfig["reasoningEffort"];
gitBranch: string;
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
gitBranch: string | null;
promptDraft: PromptDraft;
onPromptInputChange: (value: string) => void;
onProviderChange: (provider: string) => void;
@@ -283,7 +285,7 @@ type ChatInputBarProps = {
};
};
export function ChatInputBar({
function ChatInputBarImpl({
variant = "conversation",
status,
provider,
@@ -410,7 +412,7 @@ export function ChatInputBar({
: null;
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
BUILTIN_SLASH_COMMANDS,
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
);
const [slashLoading, setSlashLoading] = useState(false);
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
@@ -454,18 +456,22 @@ export function ChatInputBar({
}
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
// Focus the composer on mount/variant change and when text is injected
// from outside (quick actions, queue undo). Deliberately NOT on every
// keystroke: refocusing an already-focused textarea per keypress causes
// caret flicker and forced layout while typing.
useEffect(() => {
const input = promptInputRef.current;
if (!input) return;
if (!input || document.activeElement === input) return;
// The textarea is controlled, so its live value mirrors promptInput;
// reading it here keeps keystrokes out of this effect's dependencies.
if (
variant === "conversation" ||
(variant === "welcome" &&
promptInput.trim().length > 0 &&
document.activeElement !== input)
(variant === "welcome" && input.value.trim().length > 0)
) {
input.focus();
}
}, [promptInput, variant]);
}, [variant]);
useEffect(() => {
setCursorIndex((prev) => Math.min(prev, promptInput.length));
@@ -574,15 +580,18 @@ export function ChatInputBar({
return;
}
let cancelled = false;
setSlashLoading(true);
// Only show the loading row when there is nothing cached to show.
setSlashLoading(cachedSlashCommands === null);
desktopClient
.invoke<UserInstructionConfigResponse>("list_user_instruction_configs")
.then((response) => {
if (cancelled) return;
setSlashCommands([
const next = [
...BUILTIN_SLASH_COMMANDS,
...buildUserInstructionSlashCommands(response),
]);
];
cachedSlashCommands = next;
setSlashCommands(next);
})
.catch(() => {
// Keep built-in commands on error.
@@ -632,6 +641,19 @@ export function ChatInputBar({
[activeSlash, promptInput, setPromptInput],
);
// Queued prompts are stored in their runtime form (a /team command is
// persisted as its <user_command> envelope), so fold them back to the
// slash form for display and editing. Saving an edit re-resolves the
// slash form through the sidecar, so the round trip is lossless.
const displayPromptsInQueue = useMemo(
() =>
promptsInQueue.map((item) => ({
...item,
prompt: formatDisplayUserInput(item.prompt),
})),
[promptsInQueue],
);
return (
<div
className={cn(
@@ -644,7 +666,7 @@ export function ChatInputBar({
{/* Input area */}
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
<AgentPromptQueue
items={promptsInQueue}
items={displayPromptsInQueue}
onEdit={onEditPromptInQueue}
onRemove={onRemovePromptInQueue}
onSteer={onSteerPromptInQueue}
@@ -657,7 +679,7 @@ export function ChatInputBar({
role="listbox"
>
{filteredSlashCommands.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
<div className="px-3 py-2 text-sm text-muted-foreground">
{slashLoading
? "Loading commands..."
: "No matching commands"}
@@ -668,10 +690,10 @@ export function ChatInputBar({
<button
aria-selected={index === slashSelectedIndex}
className={cn(
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
"flex w-full flex-col rounded-md px-3 py-2 text-left text-sm ",
index === slashSelectedIndex
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
? "bg-surface-hover text-foreground"
: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
)}
key={cmd.name}
id={`slash-command-option-${index}`}
@@ -703,7 +725,7 @@ export function ChatInputBar({
role="listbox"
>
{mentionFiles.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
<div className="px-3 py-2 text-sm text-muted-foreground">
{mentionLoading ? "Searching files..." : "No matching files"}
</div>
) : (
@@ -712,10 +734,10 @@ export function ChatInputBar({
<button
aria-selected={index === mentionSelectedIndex}
className={cn(
"block w-full rounded-md px-3 py-2 text-left text-xs transition-colors",
"block w-full rounded-md px-3 py-2 text-left text-sm ",
index === mentionSelectedIndex
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
? "bg-surface-hover text-foreground"
: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
)}
key={filePath}
id={`mention-file-option-${index}`}
@@ -737,7 +759,7 @@ export function ChatInputBar({
)}
<div
className={cn(
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-[border-color,box-shadow] focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
variant === "welcome" &&
"min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
)}
@@ -776,6 +798,15 @@ export function ChatInputBar({
}
onBlur={() => setPromptInputFocused(false)}
onFocus={() => setPromptInputFocused(true)}
onPaste={(e) => {
const images = imageFilesFromClipboard(e.clipboardData);
if (images.length > 0) {
// Attach the image instead of pasting its fallback
// text representation (e.g. a file path or URL).
e.preventDefault();
onAttachFiles(images);
}
}}
onKeyDown={(e) => {
// Slash command menu takes priority when open.
if (slashOpen && filteredSlashCommands.length > 0) {
@@ -836,6 +867,11 @@ export function ChatInputBar({
setDismissedMentionKey(mentionKey);
return;
}
if (e.key === "Escape" && canAbort) {
e.preventDefault();
onAbort();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) {
@@ -869,29 +905,31 @@ export function ChatInputBar({
<button
aria-label="Stop agent"
className={cn(
"bg-foreground p-0 text-background transition-colors hover:bg-primary/80",
"bg-foreground p-1.5 text-background hover:bg-destructive",
variant === "welcome" ? "rounded-md" : "rounded-full",
)}
onClick={onAbort}
title="Stop the agent (Esc)"
type="button"
>
<CircleStop className="size-2" />
<CircleStop className="size-3" />
</button>
)}
{(!isBusy || canSend) && (
<button
aria-label="Send message"
className={cn(
"p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
"p-1.5 disabled:cursor-not-allowed disabled:opacity-50",
variant === "welcome"
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
: "rounded-full bg-primary text-background hover:bg-primary/80",
)}
disabled={!canSend}
onClick={handleSend}
title="Send (Enter)"
type="button"
>
<ArrowUp className="size-2" />
<ArrowUp className="size-3" />
</button>
)}
</div>
@@ -901,13 +939,13 @@ export function ChatInputBar({
<div className="mt-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
<span
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-1 text-xs text-foreground"
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-1 text-sm text-foreground"
key={attachment.id}
>
{attachment.isImage ? "image:" : "file:"} {attachment.name}
<button
aria-label={`Remove ${attachment.name}`}
className="rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
className="rounded-sm p-0.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
onClick={() => onRemoveAttachment(attachment.id)}
type="button"
>
@@ -920,11 +958,11 @@ export function ChatInputBar({
</div>
{/* Composer settings */}
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-2 py-2 text-sm text-muted-foreground">
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
<button
aria-label="Attach files"
className="rounded-md p-0 pl-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="rounded-md p-2 text-muted-foreground hover:bg-surface-hover"
onClick={() => fileInputRef.current?.click()}
type="button"
>
@@ -946,7 +984,7 @@ export function ChatInputBar({
<button
aria-pressed={mode === "plan"}
className={cn(
"rounded px-2 py-1 transition-colors",
"rounded px-2 py-1 ",
mode === "plan"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
@@ -961,7 +999,7 @@ export function ChatInputBar({
<button
aria-pressed={mode === "act"}
className={cn(
"rounded px-2 py-1 transition-colors",
"rounded px-2 py-1 ",
mode === "act"
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
@@ -993,7 +1031,7 @@ export function ChatInputBar({
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
className="gap-1.5 border-0 px-2 text-sm shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 bg-transparent! hover:bg-surface-hover!"
size="sm"
title={
modelSupportsReasoning === false
@@ -1049,6 +1087,11 @@ export function ChatInputBar({
);
}
// Memoized: the chat pane re-renders on every stream flush (message deltas,
// status, usage); the composer only cares about the props it receives, which
// the pane keeps referentially stable.
export const ChatInputBar = memo(ChatInputBarImpl);
// Memoized: the selectors load/hold the full provider-model catalog, so they
// should not re-render for every keystroke in the composer textarea.
const ModelSelector = memo(function ModelSelector({
@@ -1215,26 +1258,38 @@ const ModelSelector = memo(function ModelSelector({
});
}, []);
useEffect(() => {
setLastSelection((prev) => {
if (!normalizedProvider || !model) {
return prev;
// The remembered selection (what new sessions default to) is only written
// from the explicit picker handlers below. Mirroring every provider/model
// prop change here would also capture passive changes — most notably
// opening an existing session, whose config drives these props — silently
// replacing the user's chosen default with whatever model that session
// happened to use.
const rememberSelection = useCallback(
(providerId: string, modelId: string | undefined) => {
const normalizedId = normalizeProviderId(providerId);
if (!normalizedId) {
return;
}
if (
prev.lastProvider === normalizedProvider &&
prev.lastModelByProvider[normalizedProvider] === model
) {
return prev;
}
return {
lastProvider: normalizedProvider,
lastModelByProvider: {
...prev.lastModelByProvider,
[normalizedProvider]: model,
},
};
});
}, [model, normalizedProvider]);
setLastSelection((prev) => {
if (
prev.lastProvider === normalizedId &&
(!modelId || prev.lastModelByProvider[normalizedId] === modelId)
) {
return prev;
}
return {
lastProvider: normalizedId,
lastModelByProvider: modelId
? {
...prev.lastModelByProvider,
[normalizedId]: modelId,
}
: prev.lastModelByProvider,
};
});
},
[],
);
useEffect(() => {
try {
@@ -1295,17 +1350,13 @@ const ModelSelector = memo(function ModelSelector({
onProviderChange(value);
const rememberedModel = lastSelection.lastModelByProvider[value];
const providerModelIds = visibleProviderModels[value] ?? [];
if (
rememberedModel &&
providerModelIds.includes(rememberedModel) &&
rememberedModel !== model
) {
onModelChange(rememberedModel);
return;
}
const firstModel = providerModelIds[0];
if (firstModel && firstModel !== model) {
onModelChange(firstModel);
const nextModel =
rememberedModel && providerModelIds.includes(rememberedModel)
? rememberedModel
: providerModelIds[0];
rememberSelection(value, nextModel);
if (nextModel && nextModel !== model) {
onModelChange(nextModel);
}
},
[
@@ -1313,9 +1364,17 @@ const ModelSelector = memo(function ModelSelector({
model,
onModelChange,
onProviderChange,
rememberSelection,
visibleProviderModels,
],
);
const handleModelSelect = useCallback(
(value: string) => {
rememberSelection(resolvedProvider, value);
onModelChange(value);
},
[onModelChange, rememberSelection, resolvedProvider],
);
const renderProviderSelect = (triggerClassName: string) => (
<SearchCombobox
ariaLabel="Provider"
@@ -1340,7 +1399,7 @@ const ModelSelector = memo(function ModelSelector({
disabled={isBusy || modelsForProvider.length === 0}
emptyText="No models found."
onValueChange={(value) => {
onModelChange(value);
handleModelSelect(value);
if (closeMobileMenu) setMobileOpen(false);
}}
options={modelsForProvider.map((value) => ({ label: value, value }))}
@@ -1352,12 +1411,12 @@ const ModelSelector = memo(function ModelSelector({
);
return (
<div className="relative min-w-0 shrink-0 text-[11px]">
<div className="relative min-w-0 shrink-0 text-sm">
<button
aria-expanded={mobileOpen}
aria-haspopup="dialog"
aria-label="Model and provider"
className="hidden size-7 items-center justify-center rounded-md text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 max-[560px]:inline-flex"
className="hidden size-7 items-center justify-center rounded-md text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 max-[560px]:inline-flex"
disabled={isBusy || providers.length === 0}
onClick={() => setMobileOpen((current) => !current)}
title={`${resolvedProvider || "Provider"} / ${resolvedModel || "Model"}`}
@@ -1376,19 +1435,19 @@ const ModelSelector = memo(function ModelSelector({
/>
<div className="absolute bottom-full left-0 z-50 mb-2 hidden w-64 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-border bg-popover p-3 shadow-xl max-[560px]:block">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Provider
</div>
{renderProviderSelect(
"w-full max-w-none justify-between text-xs",
"w-full max-w-none justify-between text-sm",
)}
</div>
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Model
</div>
{renderModelSelect(
"w-full max-w-none justify-between text-xs",
"w-full max-w-none justify-between text-sm",
true,
)}
</div>
@@ -1397,9 +1456,9 @@ const ModelSelector = memo(function ModelSelector({
) : null}
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
{renderProviderSelect("max-w-28 text-[11px]")}
<span className="text-muted-foreground/50">/</span>
{renderModelSelect("max-w-52 text-[11px]")}
{renderProviderSelect("max-w-28")}
<div className="bg-border-2 h-4 w-[0.1rem]"/>
{renderModelSelect("max-w-52")}
</div>
</div>
);
@@ -1448,7 +1507,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
<PopoverTrigger asChild>
<Button
aria-label={`Context window: ${totalTokens.toLocaleString()} of ${contextWindow.toLocaleString()} tokens used (${percent}%)`}
className="size-7 shrink-0 p-0 text-muted-foreground data-[state=open]:bg-accent opacity-65 hover:opacity-100"
className="size-7 shrink-0 p-0 text-muted-foreground data-[state=open]:bg-surface-hover opacity-65 hover:opacity-100"
id="token-usage"
size="icon-sm"
type="button"
@@ -1496,7 +1555,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
<div className="px-3 py-3">
<div className="flex items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground">Context window</span>
<span className="font-mono text-xs text-foreground">
<span className="font-mono text-sm text-foreground">
{contextUsageLabel}
</span>
</div>
@@ -1528,7 +1587,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
}}
/>
</div>
<div className="mt-3 space-y-2 text-xs">
<div className="mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Input tokens</span>
<span className="font-mono text-foreground">
@@ -219,6 +219,131 @@ describe("ChatMessages tool disclosures", () => {
expect(container.textContent?.match(/Read 2 files/g)).toHaveLength(1);
});
it("summarizes spawned teammates and expands their agent IDs", async () => {
await renderMessages(
["reviewer", "tester", "writer"].map(
(agentId, index): ChatMessage => ({
id: `spawn-${agentId}`,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "team_spawn_teammate",
input: { agentId, rolePrompt: "Help the team" },
result: { agentId, status: "spawned" },
}),
createdAt: index + 1,
}),
),
);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Spawned 3 teammates"),
);
expect(trigger).toBeDefined();
await act(async () => trigger?.click());
expect(container.textContent).toContain("reviewer");
expect(container.textContent).toContain("tester");
expect(container.textContent).toContain("writer");
});
it("summarizes assigned team tasks with mode, agent, and status", async () => {
await renderMessages(
["reviewer", "tester"].map(
(agentId, index): ChatMessage => ({
id: `run-${agentId}`,
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "team_run_task",
input: { agentId, runMode: "async", task: "Investigate" },
result: { agentId, mode: "async", status: "queued" },
}),
createdAt: index + 1,
}),
),
);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Assigned 2 team tasks"),
);
expect(trigger).toBeDefined();
await act(async () => trigger?.click());
expect(container.textContent).toContain("async reviewer queued");
expect(container.textContent).toContain("async tester queued");
});
it("summarizes awaited teammate reports with their statuses", async () => {
await renderMessages([
{
id: "await-runs",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "team_await_runs",
input: {},
result: [
{ id: "run-1", agentId: "reviewer", status: "completed" },
{ id: "run-2", agentId: "tester", status: "failed" },
],
}),
createdAt: 1,
},
]);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Waited for teammates"),
);
expect(trigger).toBeDefined();
await act(async () => trigger?.click());
expect(container.textContent).toContain("reviewer completed");
expect(container.textContent).toContain("tester failed");
});
it("counts every returned task in team task list summaries", async () => {
await renderMessages([
{
id: "list-team-tasks",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "team_task",
input: { action: "list" },
result: {
action: "list",
tasks: [
{ id: "task-1", title: "Review", status: "pending" },
{ id: "task-2", title: "Test", status: "in_progress" },
{ id: "task-3", title: "Document", status: "completed" },
],
},
}),
createdAt: 1,
},
]);
expect(container.textContent).toContain("Listed 3 team tasks");
});
it("uses failure-oriented labels for failed team tools", async () => {
await renderMessages([
{
id: "failed-spawn",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "team_spawn_teammate",
input: { agentId: "reviewer", rolePrompt: "Review" },
result: { error: "already exists" },
isError: true,
}),
createdAt: 1,
},
]);
expect(container.textContent).toContain("Failed to spawn teammate");
expect(container.textContent).not.toContain("Spawned 1 teammate");
});
it("preserves interleaved tool activity order", async () => {
const read = (
id: string,
@@ -894,39 +1019,41 @@ describe("ChatMessages reasoning disclosure", () => {
const railClasses = (element: Element | null) =>
[...(element?.classList ?? [])]
.filter((name) =>
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max)-/.test(name),
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max-w)-/.test(name),
)
.sort();
expect(railClasses(reasoningContent).length).toBeGreaterThan(0);
expect(railClasses(toolContent)).toEqual(railClasses(reasoningContent));
// Both panels are capped on both axes so neither can stretch the column.
// Reasoning remains capped; tool output grows into the conversation scroller.
expect(
[...(reasoningContent?.classList ?? [])].some((name) =>
name.startsWith("max-h-"),
),
).toBe(true);
expect(
[...(toolContent?.classList ?? [])].some((name) =>
name.startsWith("max-h-"),
),
).toBe(false);
for (const panel of [reasoningContent, toolContent]) {
const classes = [...(panel?.classList ?? [])];
expect(classes.some((name) => name.startsWith("max-h-"))).toBe(true);
expect(classes.some((name) => name.startsWith("max-w-"))).toBe(true);
expect(
[...(panel?.classList ?? [])].some((name) => name.startsWith("max-w-")),
).toBe(true);
}
// Reasoning wraps, so it scrolls Y only; tool output scrolls both axes.
// Reasoning scrolls internally; tool output leaves scrolling to the conversation.
expect(reasoningContent?.classList.contains("overflow-y-auto")).toBe(true);
expect(reasoningContent?.classList.contains("overflow-x-hidden")).toBe(
true,
);
expect(reasoningContent?.classList.contains("overflow-auto")).toBe(false);
expect(toolContent?.classList.contains("overflow-auto")).toBe(true);
expect(toolContent?.classList.contains("overflow-x-hidden")).toBe(false);
expect(toolContent?.classList.contains("overflow-auto")).toBe(false);
// The X axis is only reachable if the detail rows keep their lines intact.
// Detail rows use the shared wrapping behavior instead of horizontal scrolling.
const details = toolContent?.querySelector(".cline-chat-tool-details");
expect(details?.classList.contains("whitespace-pre")).toBe(true);
// The X axis stays live but loses its bar; reasoning has no X bar to hide.
expect(toolContent?.classList.contains("cline-chat-scroll-x-bare")).toBe(
true,
);
expect(
reasoningContent?.classList.contains("cline-chat-scroll-x-bare"),
).toBe(false);
expect(details?.classList.contains("whitespace-pre")).toBe(false);
expect(details?.classList.contains("whitespace-pre-wrap")).toBe(true);
});
it("keeps the reasoning panel inside the shape the hover-suppress rule targets", async () => {
@@ -275,8 +275,6 @@ function groupChatMessages(messages: ChatMessage[]): ChatRenderItem[] {
const IS_DEBUG = process.env.NODE_ENV === "test";
const STREAMING_TITLE_CLASS = "cline-chat-streaming-title";
/** Keeps a scroller's X axis live while hiding its horizontal bar (globals.css). */
const SCROLL_X_BARE_CLASS = "cline-chat-scroll-x-bare";
/**
* Expanded reasoning and tool panels hang off a shared left rail: the border
@@ -285,12 +283,11 @@ const SCROLL_X_BARE_CLASS = "cline-chat-scroll-x-bare";
* use this verbatim, and it overrides the panel chrome (border box, radius,
* background, inset) that `agent-chat.css` gives each of them by default.
*
* Both panels are capped on both axes so a long thought or a wide command list
* can never stretch the conversation column; each panel then picks which axes
* scroll (reasoning wraps and scrolls Y only, tools scroll both).
* Reasoning stays capped and scrollable, while tool output grows into the
* conversation scroller and wraps to avoid a nested scrolling region.
*/
const EXPANDED_PANEL_RAIL_CLASS =
"ml-1 mt-0 max-h-48 max-w-full rounded-none border-0 border-l border-border bg-transparent py-1 px-2 text-sm opacity-70 hover:opacity-100 focus-within:opacity-100";
"ml-1 mt-0 max-w-full rounded-none border-0 border-l border-border bg-transparent py-1 px-2 text-sm opacity-70 hover:opacity-100 focus-within:opacity-100";
function ChatMessagesImpl({
sessionId,
@@ -310,17 +307,34 @@ function ChatMessagesImpl({
onForkSession,
}: ChatMessagesProps) {
const hasMessages = messages.length > 0;
const lastErrorMessage = [...messages]
.reverse()
.find((message) => message.role === "error");
// Scanned from the tail without copying: this component re-renders on
// every stream flush, so a reversed array clone per render would churn
// with transcript length.
const { lastConversationMessage, lastErrorMessage } = useMemo(() => {
let conversationMessage: ChatMessage | undefined;
let errorMessage: ChatMessage | undefined;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (!conversationMessage && message.role !== "status") {
conversationMessage = message;
}
if (!errorMessage && message.role === "error") {
errorMessage = message;
}
if (conversationMessage && errorMessage) {
break;
}
}
return {
lastConversationMessage: conversationMessage,
lastErrorMessage: errorMessage,
};
}, [messages]);
const shouldShowErrorBanner =
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
// Core reports "running" as soon as the turn is dispatched, well before the
// first streamed chunk arrives, so keep the thinking indicator up until the
// model produces output (or something else needs the user's attention).
const lastConversationMessage = [...messages]
.reverse()
.find((message) => message.role !== "status");
const isAwaitingFirstOutput =
status === "running" &&
!streamingMessageId &&
@@ -373,6 +387,31 @@ function ChatMessagesImpl({
const showIdleDetails =
!hasMessages && !isSessionSwitching && !showSwitchTransition;
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
// Built once per pendingAskQuestions change instead of per render: the
// list re-renders on every stream flush and these rows carry JSX.
const askQuestionItems = useMemo(
() =>
pendingAskQuestions.map((item) => ({
description: (
<>
Request {item.requestId}
{item.context?.iteration != null
? ` · Iteration ${item.context.iteration}`
: ""}
</>
),
id: item.requestId,
meta: (
<>
<Clock3 className="h-3 w-3" />
{formatApprovalTimestamp(item.createdAt)}
</>
),
options: item.options,
question: item.question,
})),
[pendingAskQuestions],
);
const previousTimestampByMessage = useMemo(
() => buildPreviousTimestampMap(messages),
[messages],
@@ -583,6 +622,14 @@ function ChatMessagesImpl({
},
[],
);
// Stable identity so memoized MessageBubbles skip re-rendering on stream
// flushes; an inline lambda here would invalidate every bubble per flush.
const requestRestoreCheckpoint = useCallback(
(messageId: string, runCount: number) => {
setCheckpointConfirmation({ messageId, runCount });
},
[],
);
const handleExpandImage = useCallback(
(image: ChatMessageImage) => {
@@ -658,28 +705,10 @@ function ChatMessagesImpl({
requestErrors={toolApprovalErrors}
/>
) : null}
{pendingAskQuestions.length > 0 ? (
{askQuestionItems.length > 0 ? (
<AgentAskQuestion
errors={askQuestionErrors}
items={pendingAskQuestions.map((item) => ({
description: (
<>
Request {item.requestId}
{item.context?.iteration != null
? ` · Iteration ${item.context.iteration}`
: ""}
</>
),
id: item.requestId,
meta: (
<>
<Clock3 className="h-3 w-3" />
{formatApprovalTimestamp(item.createdAt)}
</>
),
options: item.options,
question: item.question,
}))}
items={askQuestionItems}
onAnswer={handleAskQuestionAnswer}
pendingAnswers={askQuestionActions}
/>
@@ -727,13 +756,7 @@ function ChatMessagesImpl({
editError={editErrors[message.id]}
editPending={editingMessageId === message.id}
onRestoreCheckpoint={
onRestoreCheckpoint
? (messageId, runCount) =>
setCheckpointConfirmation({
messageId,
runCount,
})
: undefined
onRestoreCheckpoint ? requestRestoreCheckpoint : undefined
}
restoreDisabled={
!onRestoreCheckpoint ||
@@ -818,7 +841,7 @@ function ChatMessagesImpl({
</div>
) : null}
{shouldShowErrorBanner ? (
<div className="mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
) : null}
@@ -1326,7 +1349,7 @@ function ReasoningBlock({
// Prose reflows, so the X axis is pinned shut: `overflow-y-auto`
// alone would compute overflow-x to `auto` and let a long
// unbreakable token add a horizontal scrollbar.
"overflow-x-hidden overflow-y-auto",
"max-h-48 overflow-x-hidden overflow-y-auto",
"text-sm leading-relaxed text-muted-foreground",
)}
>
@@ -1354,6 +1377,7 @@ type ToolSummary = {
key: string;
count: number;
noun: string;
pluralNoun?: string;
completedVerb: string;
progressVerb: string;
};
@@ -1605,14 +1629,390 @@ function pluralize(
return `${count} ${count === 1 ? singular : plural}`;
}
function resultRecords(result: unknown): Record<string, unknown>[] {
const normalized = normalizeDisplayValue(result);
if (Array.isArray(normalized)) {
return normalized.map(asRecord).filter((item) => item !== null);
}
const record = asRecord(normalized);
return record ? [record] : [];
}
function recordString(
record: Record<string, unknown> | null | undefined,
key: string,
fallback = "",
): string {
const value = record?.[key];
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function teamSummary(
toolName: string,
input: unknown,
result: unknown,
inProgress: boolean,
isError: boolean,
): ToolSummary | null {
if (!toolName.startsWith("team_")) return null;
if (isError) {
const failureLabels: Record<string, string> = {
team_attach_outcome_fragment: "Failed to attach outcome fragment",
team_await_runs: "Failed while waiting for teammates",
team_broadcast: "Failed to broadcast message to teammates",
team_cancel_run: "Failed to cancel teammate run",
team_cleanup: "Failed to clean up team",
team_create_outcome: "Failed to create team outcome",
team_finalize_outcome: "Failed to finalize team outcome",
team_list_outcomes: "Failed to list team outcomes",
team_list_runs: "Failed to list teammate runs",
team_mission_log: "Failed to update mission log",
team_read_mailbox: "Failed to read team mailbox",
team_review_outcome_fragment: "Failed to review outcome fragment",
team_run_task: "Failed to assign team task",
team_send_message: "Failed to send message",
team_shutdown_teammate: "Failed to stop teammate",
team_spawn_teammate: "Failed to spawn teammate",
team_status: "Failed to check team status",
team_task: "Failed to update team task",
};
return {
label: failureLabels[toolName] ?? `Failed ${toolName}`,
details: [],
};
}
const inputRecord = asRecord(input);
const records = resultRecords(result);
const resultRecord = records[0];
const aggregate = (
key: string,
noun: string,
completedVerb: string,
progressVerb: string,
details: string[],
pluralNoun?: string,
count = 1,
): ToolSummary => ({
label: `${inProgress ? progressVerb : completedVerb} ${pluralize(
count,
noun,
pluralNoun,
)}`,
aggregate: {
key,
count,
noun,
pluralNoun,
completedVerb,
progressVerb,
},
details,
});
const agentId = recordString(
resultRecord,
"agentId",
recordString(inputRecord, "agentId"),
);
switch (toolName) {
case "team_spawn_teammate":
return aggregate(
"team-spawn",
"teammate",
"Spawned",
"Spawning",
agentId ? [agentId] : [],
);
case "team_run_task": {
const mode = recordString(
resultRecord,
"mode",
recordString(inputRecord, "runMode", "sync"),
);
const status = inProgress
? "assigning"
: recordString(resultRecord, "status", "assigned");
return aggregate(
"team-run-task",
"team task",
"Assigned",
"Assigning",
[mode, agentId, status].filter(Boolean).join(" ")
? [[mode, agentId, status].filter(Boolean).join(" ")]
: [],
"team tasks",
);
}
case "team_await_runs": {
const details = records.map((run) =>
[
recordString(run, "agentId", recordString(run, "id")),
recordString(run, "status"),
]
.filter(Boolean)
.join(" "),
);
return {
label: inProgress ? "Waiting for teammates" : "Waited for teammates",
details,
};
}
case "team_shutdown_teammate":
return aggregate(
"team-shutdown",
"teammate",
"Stopped",
"Stopping",
agentId ? [agentId] : [],
);
case "team_status": {
const members = Array.isArray(resultRecord?.members)
? resultRecord.members.map(asRecord).filter((item) => item !== null)
: [];
return {
label: inProgress ? "Checking team status" : "Checked team status",
details: members.map((member) =>
[recordString(member, "agentId"), recordString(member, "status")]
.filter(Boolean)
.join(" "),
),
};
}
case "team_task": {
const action = recordString(
inputRecord,
"action",
recordString(resultRecord, "action", "update"),
);
const verbs: Record<string, [string, string]> = {
create: ["Created", "Creating"],
list: ["Listed", "Listing"],
claim: ["Claimed", "Claiming"],
complete: ["Completed", "Completing"],
block: ["Blocked", "Blocking"],
};
const [completedVerb, progressVerb] = verbs[action] ?? [
"Updated",
"Updating",
];
const tasks = Array.isArray(resultRecord?.tasks)
? resultRecord.tasks.map(asRecord).filter((item) => item !== null)
: records;
const details = tasks.map((task) =>
[
recordString(
task,
"taskId",
recordString(task, "id", recordString(inputRecord, "taskId")),
),
recordString(task, "title", recordString(inputRecord, "title")),
recordString(task, "status"),
]
.filter(Boolean)
.join(" "),
);
return aggregate(
`team-task-${action}`,
"team task",
completedVerb,
progressVerb,
details,
undefined,
action === "list" ? tasks.length : 1,
);
}
case "team_list_runs":
return {
label: inProgress
? "Listing teammate runs"
: `Listed ${pluralize(records.length, "teammate run")}`,
details: records.map((run) =>
[recordString(run, "agentId"), recordString(run, "status")]
.filter(Boolean)
.join(" "),
),
};
case "team_cancel_run":
return {
label: inProgress
? "Cancelling teammate run"
: "Cancelled teammate run",
details: [
[
recordString(
resultRecord,
"runId",
recordString(inputRecord, "runId"),
),
recordString(resultRecord, "status"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_send_message": {
const recipient = recordString(
resultRecord,
"toAgentId",
recordString(inputRecord, "toAgentId"),
);
return aggregate(
"team-send-message",
"message",
"Sent",
"Sending",
[recipient, recordString(inputRecord, "subject")].filter(Boolean).length
? [
[recipient, recordString(inputRecord, "subject")]
.filter(Boolean)
.join(" "),
]
: [],
);
}
case "team_broadcast": {
const delivered = resultRecord?.delivered;
return {
label: inProgress
? "Broadcasting message to teammates"
: `Broadcast message to ${pluralize(typeof delivered === "number" ? delivered : 0, "teammate")}`,
details: recordString(inputRecord, "subject")
? [recordString(inputRecord, "subject")]
: [],
};
}
case "team_read_mailbox":
return {
label: inProgress
? "Reading team mailbox"
: `Read ${pluralize(records.length, "team message")}`,
details: records.map((message) =>
[
recordString(message, "fromAgentId"),
recordString(message, "subject"),
]
.filter(Boolean)
.join(" "),
),
};
case "team_mission_log":
return {
label: inProgress ? "Updating mission log" : "Updated mission log",
details: [
[
recordString(inputRecord, "kind"),
recordString(inputRecord, "summary"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_cleanup":
return {
label: inProgress ? "Cleaning up team" : "Cleaned up team",
details: recordString(resultRecord, "status")
? [recordString(resultRecord, "status")]
: [],
};
case "team_create_outcome":
return {
label: inProgress ? "Creating team outcome" : "Created team outcome",
details: [
[
recordString(resultRecord, "outcomeId"),
recordString(inputRecord, "title"),
recordString(resultRecord, "status"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_attach_outcome_fragment":
return {
label: inProgress
? "Attaching outcome fragment"
: "Attached outcome fragment",
details: [
[
recordString(inputRecord, "section"),
recordString(resultRecord, "status"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_review_outcome_fragment":
return {
label: inProgress
? "Reviewing outcome fragment"
: "Reviewed outcome fragment",
details: [
[
recordString(inputRecord, "fragmentId"),
typeof inputRecord?.approved === "boolean"
? inputRecord.approved
? "approved"
: "rejected"
: recordString(resultRecord, "status"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_finalize_outcome":
return {
label: inProgress
? "Finalizing team outcome"
: "Finalized team outcome",
details: [
[
recordString(
resultRecord,
"outcomeId",
recordString(inputRecord, "outcomeId"),
),
recordString(resultRecord, "status"),
]
.filter(Boolean)
.join(" "),
].filter(Boolean),
};
case "team_list_outcomes":
return {
label: inProgress
? "Listing team outcomes"
: `Listed ${pluralize(records.length, "team outcome")}`,
details: records.map((outcome) =>
[
recordString(outcome, "title", recordString(outcome, "id")),
recordString(outcome, "status"),
]
.filter(Boolean)
.join(" "),
),
};
default:
return null;
}
}
function buildToolSummary(
toolName: string,
input: unknown,
result: unknown,
inProgress: boolean,
isError = false,
): ToolSummary {
const normalized = normalizeToolName(toolName);
const inputObject = asRecord(input);
const teamToolSummary = teamSummary(
normalized,
input,
result,
inProgress,
isError,
);
if (teamToolSummary) return teamToolSummary;
if (normalized === "read_files") {
const files = extractReadFilePaths(input);
@@ -1835,7 +2235,13 @@ function buildToolPresentation(message: ChatMessage): ToolPresentation {
(Boolean(payload) && payload?.result == null && !payload?.isError);
const kind = classifyTool(toolName);
const summary = payload
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
? buildToolSummary(
toolName,
payload.input,
payload.result,
inProgress,
Boolean(payload.isError),
)
: buildToolSummaryFromMeta(toolName, kind, inProgress);
return { message, payload, toolName, kind, inProgress, summary };
}
@@ -1890,7 +2296,11 @@ function buildGroupedToolLabel(presentations: ToolPresentation[]): string {
const verb = aggregate.inProgress
? aggregate.progressVerb
: aggregate.completedVerb;
return `${verb} ${pluralize(aggregate.count, aggregate.noun)}`;
return `${verb} ${pluralize(
aggregate.count,
aggregate.noun,
aggregate.pluralNoun,
)}`;
})
.join(". ");
}
@@ -1966,21 +2376,9 @@ const ToolMessageBlock = memo(
showDisclosureIcon={false}
status={hasError ? "error" : isRunning ? "running" : "success"}
/>
{/* `overflow-auto` (not just `-y`) overrides the `overflow-x: hidden`
that agent-chat.css pins on this panel; the bare-scrollbar class
then hides the horizontal bar without disabling the axis. */}
<ToolActivityContent
className={cn(
EXPANDED_PANEL_RAIL_CLASS,
"overflow-auto",
SCROLL_X_BARE_CLASS,
)}
>
<ToolActivityContent className={EXPANDED_PANEL_RAIL_CLASS}>
{details.length > 0 ? (
// Commands and paths stay on one line and scroll with the panel;
// the stylesheet's `overflow-wrap: anywhere` would otherwise break
// them mid-token and leave the X axis unreachable.
<ToolActivityDetails className="w-max whitespace-pre">
<ToolActivityDetails className="whitespace-pre-wrap">
{details.map(({ detail, key }) => (
<div key={key}>{detail}</div>
))}
@@ -1993,16 +2391,14 @@ const ToolMessageBlock = memo(
? `${preview.toolName} input`
: "Input"}
</div>
{/* Drop the stylesheet's own 13rem scroller so the panel above
is the single scroll container on both axes. */}
<ToolActivityCode className="max-h-none overflow-visible text-sm">
<ToolActivityCode className="text-sm">
{preview.value}
</ToolActivityCode>
</div>
))}
{resultPreviews.map((preview) => (
<div
className="mt-1 text-destructive"
className="mt-1 break-words text-destructive"
key={`result_${preview.key}`}
>
{presentations.length > 1 ? `${preview.toolName}: ` : null}
@@ -100,7 +100,7 @@ export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
{" "}
<button
aria-label="Close diff view"
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="rounded-md p-1 text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
onClick={onClose}
type="button"
>
@@ -198,7 +198,7 @@ function DiffFileSection({
return (
<div className="border-b border-border">
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-accent/50 transition-colors">
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-surface-hover-lighter transition-colors">
<button
className="flex min-w-0 shrink items-center gap-2 text-left"
onClick={onToggle}
@@ -216,7 +216,7 @@ function DiffFileSection({
<button
aria-label={`Copy file path for ${file.path}`}
className={cn(
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-surface-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
copied ? "opacity-100 text-primary" : "opacity-0",
)}
onClick={() => void handleCopyPath()}
@@ -242,7 +242,7 @@ function DiffFileSection({
<DropdownMenuTrigger asChild>
<button
aria-label={`Open ${file.path} in editor`}
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground"
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-surface-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-surface-hover data-[state=open]:text-foreground"
disabled={opening}
title="Open in editor"
type="button"
@@ -323,7 +323,7 @@ function DiffHunk({ hunk }: { hunk: SessionFileDiff["hunks"][number] }) {
});
return (
<div className="overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
<div className="cline-chat-selectable overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
{oldLineEntries.map((entry) => (
<div className="flex bg-destructive/10" key={entry.key}>
<span className="hidden w-12 shrink-0 select-none items-center justify-end border-r border-border px-2 text-muted-foreground/40 sm:flex">
@@ -30,6 +30,7 @@ afterEach(async () => {
async function renderWelcomeScreen({
workspaceRoot,
workspaces,
gitBranch = "main",
onStartChat = vi.fn(),
selectChat = vi.fn(async () => true),
onListGitBranches = vi.fn(async () => ({
@@ -39,6 +40,7 @@ async function renderWelcomeScreen({
}: {
workspaceRoot: string;
workspaces: string[];
gitBranch?: string | null;
onStartChat?: (prompt: string) => void;
selectChat?: () => Promise<boolean>;
onListGitBranches?: () => Promise<{
@@ -63,7 +65,7 @@ async function renderWelcomeScreen({
active
body={null}
composer={null}
gitBranch="main"
gitBranch={gitBranch}
onListGitBranches={onListGitBranches}
onStartChat={onStartChat}
onSwitchGitBranch={vi.fn(async () => true)}
@@ -103,6 +105,100 @@ describe("WelcomeScreen", () => {
);
});
it("shows code-centric suggestions only inside a git repository", async () => {
await renderWelcomeScreen({
gitBranch: "main",
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
expect(container.textContent).toContain("Review changes");
expect(container.textContent).toContain("Check for build errors");
expect(container.textContent).not.toContain("Summarize this folder");
});
it("offers general-purpose suggestions for a plain (non-git) folder", async () => {
const onStartChat = vi.fn();
await renderWelcomeScreen({
gitBranch: "no-git",
onStartChat,
workspaceRoot: "/home/beatrix/recipes",
workspaces: ["/home/beatrix/recipes"],
});
// No developer vocabulary for a documents folder.
expect(container.textContent).not.toContain("Review changes");
expect(container.textContent).not.toContain("build errors");
expect(container.textContent).toContain("Summarize this folder");
expect(container.textContent).toContain("Organize these files");
expect(container.textContent).toContain("Draft a document");
await clickButton("Summarize this folder");
expect(onStartChat).toHaveBeenCalledWith(
"Look through the files in this folder and give me a plain-language summary of what's here.",
);
});
it("shows no suggestions for a folder while branch discovery is pending", async () => {
// Initial load and workspace switches report null until the folder is
// classified; guessing a card set here would misclassify git repos.
await renderWelcomeScreen({
gitBranch: null,
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
expect(container.textContent).not.toContain("Review changes");
expect(container.textContent).not.toContain("Check for build errors");
expect(container.textContent).not.toContain("Summarize this folder");
expect(container.textContent).not.toContain("Draft a document");
});
it("resolves pending branch discovery to the matching card set", async () => {
await renderWelcomeScreen({
gitBranch: null,
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
await renderWelcomeScreen({
gitBranch: "main",
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
expect(container.textContent).toContain("Review changes");
expect(container.textContent).toContain("Check for build errors");
expect(container.textContent).not.toContain("Summarize this folder");
});
it("offers folderless suggestions even while branch state is pending", async () => {
// Switching to "Just chat" resets branch discovery to pending; the
// chat cards never depend on git state, so they show immediately.
await renderWelcomeScreen({
gitBranch: null,
workspaceRoot: "",
workspaces: [],
});
expect(container.textContent).toContain("Draft a document");
expect(container.textContent).toContain("Research a topic");
expect(container.textContent).toContain("Plan something");
});
it("offers folderless suggestions when no workspace is selected", async () => {
await renderWelcomeScreen({
gitBranch: "no-git",
workspaceRoot: "",
workspaces: [],
});
expect(container.textContent).not.toContain("Review changes");
expect(container.textContent).not.toContain("Summarize this folder");
expect(container.textContent).toContain("Draft a document");
expect(container.textContent).toContain("Research a topic");
expect(container.textContent).toContain("Plan something");
});
it("renders every known project in the opened workspace menu", async () => {
const workspaces = Array.from(
{ length: 6 },
@@ -1,5 +1,6 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import {
AgentAurora,
AgentHeroHeading,
@@ -7,12 +8,13 @@ import {
AgentQuickActions,
} from "@cline/ui";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
/** Code-centric starters, shown only when the folder is a git repository. */
const DEVELOPER_QUICK_ACTIONS: AgentQuickAction[] = [
{
id: "review-changes",
label: "Review changes",
@@ -27,10 +29,94 @@ const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
},
];
/**
* General-purpose starters for a plain (non-git) folder phrased around the
* files the agent can see, with no developer vocabulary.
*/
const FOLDER_QUICK_ACTIONS: AgentQuickAction[] = [
{
id: "summarize-folder",
label: "Summarize this folder",
description: "Get a plain-language overview of the files here.",
value:
"Look through the files in this folder and give me a plain-language summary of what's here.",
},
{
id: "organize-files",
label: "Organize these files",
description: "Tidy up names and structure, with your approval.",
value:
"Help me organize this folder: suggest a tidy structure and clearer file names, and check with me before moving anything.",
},
{
id: "draft-document",
label: "Draft a document",
description: "Start a new doc with a first draft you can edit.",
value:
"Help me draft a new document in this folder. Ask me a few questions about what it should cover, then write a first draft.",
},
];
/** Starters for chat with no folder selected at all. */
const CHAT_QUICK_ACTIONS: AgentQuickAction[] = [
{
id: "draft-document",
label: "Draft a document",
description: "Start a new doc with a first draft you can edit.",
value:
"Help me draft a document. Ask me a few questions about what it should cover, then write a first draft.",
},
{
id: "research-topic",
label: "Research a topic",
description: "Gather the key facts and sum them up.",
value:
"Research a topic for me: ask me what I want to learn about, then summarize the key points in plain language.",
},
{
id: "plan-something",
label: "Plan something",
description: "Break a goal into clear, doable steps.",
value:
"Help me plan something. Ask me what I'm trying to get done, then break it into clear steps.",
},
];
/**
* Picks starter suggestions that match what the user actually opened: code
* cards only make sense inside a git repo; a plain folder gets file-oriented
* cards; no folder at all gets folderless general-purpose cards.
*
* `gitBranch` is `null` while branch discovery for the selected folder is
* still pending; no cards are suggested until the folder is classified so a
* git repo never flashes the plain-folder set (or vice versa).
*/
export function defaultQuickActionsForContext({
workspaceRoot,
gitBranch,
}: {
workspaceRoot: string;
gitBranch: string | null;
}): AgentQuickAction[] {
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
if (isChatWorkspace) {
return CHAT_QUICK_ACTIONS;
}
if (gitBranch === null) {
return [];
}
if (gitBranch !== "no-git") {
return DEVELOPER_QUICK_ACTIONS;
}
return FOLDER_QUICK_ACTIONS;
}
export function WelcomeScreen({
active,
body,
composer,
notice,
onStartChat,
quickActions,
gitBranch,
@@ -40,9 +126,12 @@ export function WelcomeScreen({
active: boolean;
body: ReactNode;
composer: ReactNode;
/** Rendered above the composer on the welcome state (e.g. setup notice). */
notice?: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: AgentQuickAction[];
gitBranch: string;
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
gitBranch: string | null;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
@@ -54,8 +143,11 @@ export function WelcomeScreen({
pickWorkspaceDirectory,
selectChat,
} = useWorkspace();
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
const defaultActions = useMemo(
() => defaultQuickActionsForContext({ workspaceRoot, gitBranch }),
[workspaceRoot, gitBranch],
);
const actions = quickActions.length > 0 ? quickActions : defaultActions;
useEffect(() => {
if (active) void refreshWorkspaces();
@@ -85,7 +177,7 @@ export function WelcomeScreen({
)}
>
{active ? (
<>
<div className="cline-view-enter">
<AgentHeroHeading />
<div className="mt-11 flex min-w-0 items-center">
@@ -101,16 +193,22 @@ export function WelcomeScreen({
workspaces={workspaces}
/>
</div>
</>
</div>
) : null}
<div
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
className={
active
? "hidden"
: "cline-view-enter h-full min-h-0 overflow-hidden"
}
key="conversation-body"
>
{body}
</div>
{active && notice ? notice : null}
<div
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
key="persistent-composer"
@@ -121,7 +219,7 @@ export function WelcomeScreen({
{active ? (
<AgentQuickActions
actions={actions}
className="mt-11"
className="cline-view-enter mt-11"
onSelect={(action) => onStartChat(action.value)}
/>
) : null}
@@ -0,0 +1,56 @@
"use client";
import { Cable } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Shown on the welcome screen when no model provider has credentials yet.
* Without it the composer looks fully functional and the first prompt dies
* with an opaque failure the single worst moment of the first-run flow.
*/
export function WelcomeSetupNotice({
onOpenSetup,
onOpenModelSettings,
}: {
onOpenSetup: () => void;
onOpenModelSettings: () => void;
}) {
return (
// <output> carries an implicit "status" role, announcing the notice to
// assistive tech when it appears.
<output className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3 backdrop-blur-sm">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Cable className="size-4" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Connect a model to start building
</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">
Sign in with Cline or add an API key it takes under a minute.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
className="rounded-full"
onClick={onOpenSetup}
size="sm"
type="button"
>
Connect a model
</Button>
<Button
className="rounded-full"
onClick={onOpenModelSettings}
size="sm"
type="button"
variant="ghost"
>
Model settings
</Button>
</div>
</output>
);
}
@@ -0,0 +1,229 @@
// @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 { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function renderControls({
onSwitchWorkspace = vi.fn(async () => true),
onPickWorkspaceDirectory = vi.fn(async (): Promise<string | null> => null),
}: {
onSwitchWorkspace?: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
} = {}): Promise<void> {
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={onSwitchWorkspace}
workspaceRoot="/projects/project-1"
workspaces={["/projects/project-1"]}
/>,
);
await Promise.resolve();
});
}
async function clickButton(text: string): Promise<void> {
const button = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
await act(async () => {
button?.click();
await Promise.resolve();
});
}
async function openWorkspaceMenu(): Promise<void> {
await clickButton("project-1");
}
async function typeInSearch(value: string): Promise<void> {
const input = container.querySelector<HTMLInputElement>("input");
expect(input).toBeDefined();
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, value);
input?.dispatchEvent(new Event("input", { bubbles: true }));
await Promise.resolve();
});
}
describe("WelcomeWorkspaceControls manual path entry", () => {
it("offers to open a typed absolute path and switches to it", async () => {
const onSwitchWorkspace = vi.fn(async () => true);
await renderControls({ onSwitchWorkspace });
await openWorkspaceMenu();
await typeInSearch("/home/user/personal-stuff");
await clickButton("Open folder \u201c/home/user/personal-stuff\u201d");
expect(onSwitchWorkspace).toHaveBeenCalledWith("/home/user/personal-stuff");
});
it("shows a visible error when the typed path cannot be opened", async () => {
const onSwitchWorkspace = vi.fn(async () => false);
await renderControls({ onSwitchWorkspace });
await openWorkspaceMenu();
await typeInSearch("/does/not/exist");
await clickButton("Open folder \u201c/does/not/exist\u201d");
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
});
it("does not offer path entry for plain search text", async () => {
await renderControls();
await openWorkspaceMenu();
await typeInSearch("project");
const pathOption = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes("Open folder \u201c"));
expect(pathOption).toBeUndefined();
});
it("keeps typed path and errors when the workspace catalog refreshes mid-open", async () => {
// The workspace catalog re-derives on a timer (session-history refresh),
// handing the picker a new onRefreshWorkspaces identity. That must not
// wipe the menu's typed path or a visible error while it is open.
const onSwitchWorkspace = vi.fn(async () => false);
const render = async () => {
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={onSwitchWorkspace}
workspaceRoot="/projects/project-1"
workspaces={["/projects/project-1"]}
/>,
);
await Promise.resolve();
});
};
await render();
await openWorkspaceMenu();
await typeInSearch("/does/not/exist");
await clickButton("Open folder \u201c/does/not/exist\u201d");
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
// Re-render with fresh callback identities, as the page does when the
// session history poll lands.
await render();
const input = container.querySelector<HTMLInputElement>("input");
expect(input?.value).toBe("/does/not/exist");
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
});
it("surfaces picker failures from Open folder instead of a silent no-op", async () => {
const onPickWorkspaceDirectory = vi.fn(async () => {
throw new Error(
"No system folder picker found (zenity or kdialog). Type or paste a folder path in the workspace selector instead.",
);
});
await renderControls({ onPickWorkspaceDirectory });
await openWorkspaceMenu();
await clickButton("Open folder...");
expect(container.textContent).toContain("No system folder picker found");
});
});
async function renderBranchChipControls(overrides: {
currentBranch: string;
}): Promise<void> {
await act(async () => {
root.render(
<WelcomeWorkspaceControls
currentBranch={overrides.currentBranch}
onListGitBranches={vi.fn(async () => ({
current: overrides.currentBranch,
branches:
overrides.currentBranch === "no-git"
? []
: [overrides.currentBranch],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSelectChat={vi.fn(async () => true)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/home/beatrix/recipes"
workspaces={["/home/beatrix/recipes"]}
/>,
);
await Promise.resolve();
});
}
describe("WelcomeWorkspaceControls branch chip", () => {
it("hides the branch chip entirely for a plain (non-git) folder", async () => {
await renderBranchChipControls({ currentBranch: "no-git" });
expect(container.textContent).toContain("recipes");
// No git terminology may leak for non-developers: previously this
// rendered a chip reading "No branch".
expect(container.textContent).not.toContain("No branch");
expect(container.textContent).not.toContain("no-git");
const buttons = [...container.querySelectorAll("button")];
expect(buttons).toHaveLength(1);
});
it("keeps the branch switcher chip for git repositories", async () => {
await renderBranchChipControls({ currentBranch: "main" });
expect(container.textContent).toContain("recipes");
expect(container.textContent).toContain("main");
const buttons = [...container.querySelectorAll("button")];
expect(buttons).toHaveLength(2);
});
it("offers Open folder wording instead of Add project", async () => {
await renderBranchChipControls({ currentBranch: "no-git" });
await clickButton("recipes");
await vi.waitFor(() => {
expect(container.textContent).toContain("Open folder...");
});
expect(container.textContent).not.toContain("Add project");
});
});
@@ -13,7 +13,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
import {
looksLikeFolderPath,
normalizeWorkspacePath,
} from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
@@ -36,7 +39,7 @@ function workspaceName(path: string): string {
}
const TRIGGER_CLASS =
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const PANEL_CLASS =
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
@@ -91,6 +94,7 @@ function WorkspacePicker({
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
const [selectingChat, setSelectingChat] = useState(false);
const [error, setError] = useState<string | null>(null);
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
@@ -100,11 +104,20 @@ function WorkspacePicker({
);
// Refresh the catalog and clear the filter each time the menu opens.
// The refresh callback lives in a ref: its identity changes whenever the
// workspace catalog re-derives (e.g. periodic session-history refresh), and
// re-running this effect mid-open would wipe the user's typed path and any
// visible error message.
const refreshWorkspacesRef = useRef(onRefreshWorkspaces);
useEffect(() => {
refreshWorkspacesRef.current = onRefreshWorkspaces;
}, [onRefreshWorkspaces]);
useEffect(() => {
if (!open) return;
setSearch("");
void onRefreshWorkspaces();
}, [open, onRefreshWorkspaces]);
setError(null);
void refreshWorkspacesRef.current();
}, [open]);
// The active workspace can be an excluded path (restored session, process
// cwd fallback); register it explicitly so it stays visible while active.
@@ -131,20 +144,34 @@ function WorkspacePicker({
return;
}
if (switching) return;
setError(null);
setSwitching(true);
const switched = await onSwitchWorkspace(next);
setSwitching(false);
if (switched) onClose();
if (switched) {
onClose();
return;
}
setError(
`Couldn't open "${next}". Check that the folder exists and try again.`,
);
};
const handleAddWorkspace = async () => {
if (picking || selectingChat || switching) return;
setError(null);
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(
isChatWorkspace ? undefined : workspaceRoot || undefined,
);
if (picked?.trim()) await handleSelect(picked.trim());
} catch (pickError) {
setError(
pickError instanceof Error && pickError.message.trim()
? pickError.message
: "The folder picker could not be opened. Type a folder path above instead.",
);
} finally {
setPicking(false);
}
@@ -174,22 +201,40 @@ function WorkspacePicker({
title={workspaceLabel}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">{workspaceLabel}</span>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate text-sm">{workspaceLabel}</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search workspaces"
onChange={(value) => {
setSearch(value);
setError(null);
}}
placeholder="Search workspaces, or type a folder path"
value={search}
/>
<div className="p-1.5">
{looksLikeFolderPath(search) && (
<Button
className="mb-0.5 flex h-auto w-full items-center justify-start gap-2 rounded-md p-2 text-left"
disabled={switching}
onClick={() => void handleSelect(search)}
variant="ghost"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-foreground">
Open folder {search.trim()}
</span>
</Button>
)}
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No workspaces found
{looksLikeFolderPath(search)
? "Press the option above to open this folder"
: "No workspaces found — type a full folder path to add one"}
</div>
) : (
filteredWorkspaces.map((path) => {
@@ -199,7 +244,9 @@ function WorkspacePicker({
<Button
className={cn(
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
isActive ? "bg-accent" : "hover:bg-accent/50",
isActive
? "bg-surface-hover"
: "hover:bg-surface-hover-lighter",
)}
disabled={switching}
key={path}
@@ -228,7 +275,7 @@ function WorkspacePicker({
variant="ghost"
>
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
{picking ? "Opening folder picker..." : "Open folder..."}
</Button>
<Button
className="w-full justify-start text-xs text-muted-foreground"
@@ -240,6 +287,11 @@ function WorkspacePicker({
<FilePlus2 className="size-3" />
{selectingChat ? "Switching to chat..." : "Just chat"}
</Button>
{error && (
<div className="mt-1 rounded-md bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{error}
</div>
)}
</div>
</div>
)}
@@ -258,6 +310,7 @@ function BranchPicker({
open: boolean;
onToggle: () => void;
onClose: () => void;
/** Always a real branch name: the parent only mounts this for git repos. */
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
@@ -285,8 +338,7 @@ function BranchPicker({
};
}, [open, onListGitBranches]);
const hasGit = currentBranch !== "no-git";
const branchLabel = hasGit ? currentBranch : "No branch";
const branchLabel = currentBranch;
const filteredBranches = branches.filter((branch) =>
branch.toLowerCase().includes(search.toLowerCase()),
@@ -314,8 +366,8 @@ function BranchPicker({
title={branchLabel}
type="button"
>
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{branchLabel}</span>
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm">{branchLabel}</span>
</button>
{open && (
@@ -342,8 +394,8 @@ function BranchPicker({
className={cn(
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-accent"
: "hover:bg-accent/50",
? "bg-surface-hover"
: "hover:bg-surface-hover-lighter",
)}
disabled={switching}
key={branch}
@@ -386,7 +438,8 @@ export function WelcomeWorkspaceControls({
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
onSelectChat: () => Promise<boolean>;
currentBranch: string;
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
currentBranch: string | null;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
@@ -427,7 +480,12 @@ export function WelcomeWorkspaceControls({
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
{!isChatWorkspace ? (
{/* Git is a developer affordance: a plain (non-git) folder gets no
branch chrome at all instead of a confusing "No branch" chip.
Pending discovery (null) is treated the same until it resolves. */}
{!isChatWorkspace &&
currentBranch !== null &&
currentBranch !== "no-git" ? (
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
@@ -154,6 +154,81 @@ describe("WorkspaceSelector", () => {
});
});
it("hides git chrome for a plain folder and keeps folder language", async () => {
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="no-git"
onListGitBranches={vi.fn(async () => ({
current: "no-git",
branches: [],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => false)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/home/beatrix/recipes"
workspaces={["/home/beatrix/recipes"]}
/>,
);
});
const trigger =
container.querySelector<HTMLButtonElement>("#git-branch-btn");
expect(trigger?.textContent).toContain("recipes");
// The "no-git" sentinel and the branch separator are developer jargon
// that must never leak into the chip for a plain folder.
expect(trigger?.textContent).not.toContain("no-git");
expect(trigger?.textContent).not.toContain("/home");
expect(trigger?.getAttribute("aria-label")).toBe("Folder recipes");
await click(trigger as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("Workspaces");
});
expect(container.textContent).not.toContain("Branches");
expect(container.textContent).not.toContain(
"Create and checkout new branch",
);
expect(container.textContent).not.toContain("No branches found");
expect(container.textContent).toContain("Open folder...");
expect(
container.querySelector('input[placeholder="Search workspaces"]'),
).not.toBeNull();
});
it("keeps the branch switcher for git repositories", async () => {
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main", "feature/review"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/workspace/one"
workspaces={["/workspace/one"]}
/>,
);
});
const trigger =
container.querySelector<HTMLButtonElement>("#git-branch-btn");
expect(trigger?.textContent).toContain("one");
expect(trigger?.textContent).toContain("main");
await click(trigger as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("Branches");
});
expect(container.textContent).toContain("feature/review");
expect(container.textContent).toContain("Create and checkout new branch");
});
it("labels the SDK chat workspace as Chat without listing the raw path", async () => {
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
await act(async () => {
@@ -1,7 +1,14 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
import {
Check,
Folder,
FolderCode,
GitBranch,
Plus,
Search,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -11,7 +18,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
import {
looksLikeFolderPath,
normalizeWorkspacePath,
} from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
@@ -38,7 +48,8 @@ export function WorkspaceSelector({
onCreateGitBranch,
disabled = false,
}: {
currentBranch: string;
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
currentBranch: string | null;
workspaceRoot: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
workspaces: string[];
@@ -58,6 +69,7 @@ export function WorkspaceSelector({
const [pickingWorkspace, setPickingWorkspace] = useState(false);
const [showWorkspacePathInput, setShowWorkspacePathInput] = useState(false);
const [workspacePathInput, setWorkspacePathInput] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [showCreateBranch, setShowCreateBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
@@ -76,6 +88,10 @@ export function WorkspaceSelector({
() => normalizeWorkspacePath(workspaceRoot),
[workspaceRoot],
);
// Git chrome (branch label, branch list, create-branch) is a developer
// affordance; a plain folder shows only folder language. Pending branch
// discovery (null) is presented like a plain folder until it resolves.
const hasGit = currentBranch !== null && currentBranch !== "no-git";
const openMenu = async () => {
if (disabled) {
@@ -85,6 +101,7 @@ export function WorkspaceSelector({
setSearch("");
setShowWorkspacePathInput(false);
setWorkspacePathInput("");
setWorkspaceError(null);
setShowCreateBranch(false);
setNewBranchName("");
setLoadingBranches(true);
@@ -125,19 +142,25 @@ export function WorkspaceSelector({
) {
return;
}
setWorkspaceError(null);
setSwitchingWorkspace(true);
const switched = await onSwitchWorkspace(next);
setSwitchingWorkspace(false);
if (switched) {
setOpen(false);
setSearch("");
return;
}
setWorkspaceError(
`Couldn't open "${next}". Check that the folder exists and try again.`,
);
};
const handleSwitchWorkspacePath = async () => {
if (pickingWorkspace || switchingWorkspace) {
return;
}
setWorkspaceError(null);
if (onPickWorkspaceDirectory) {
setPickingWorkspace(true);
try {
@@ -145,10 +168,17 @@ export function WorkspaceSelector({
if (picked?.trim()) {
await handleWorkspaceSelect(picked.trim());
}
return;
} catch (pickError) {
// No usable native picker — fall through to manual path entry.
setWorkspaceError(
pickError instanceof Error && pickError.message.trim()
? pickError.message
: "The folder picker could not be opened. Type a folder path instead.",
);
} finally {
setPickingWorkspace(false);
}
return;
}
setShowWorkspacePathInput(true);
setWorkspacePathInput(workspaceRoot);
@@ -215,7 +245,11 @@ export function WorkspaceSelector({
>
<Button
variant="ghost"
aria-label={`Workspace ${workspaceName}, branch ${currentBranch}`}
aria-label={
hasGit
? `Workspace ${workspaceName}, branch ${currentBranch}`
: `Folder ${workspaceName}`
}
className="flex max-w-full min-w-0 items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
disabled={disabled || switching}
id="git-branch-btn"
@@ -230,21 +264,30 @@ export function WorkspaceSelector({
void openMenu();
}}
>
<GitBranch className="size-3" />
{hasGit ? (
<GitBranch className="size-3" />
) : (
<Folder className="size-3" />
)}
<span className="max-w-20 shrink-0 truncate max-[560px]:sr-only">
{workspaceName}
</span>
<span className="shrink-0 text-muted-foreground/60 max-[560px]:sr-only">
/
</span>
<span className="min-w-0 truncate max-[560px]:sr-only">
{currentBranch}
</span>
{hasGit ? (
<>
<span className="shrink-0 text-muted-foreground/60 max-[560px]:sr-only">
/
</span>
<span className="min-w-0 truncate max-[560px]:sr-only">
{currentBranch}
</span>
</>
) : null}
</Button>
</span>
</TooltipTrigger>
<TooltipContent align="end" side="top" sideOffset={6}>
{workspaceRoot || workspaceName} / {currentBranch}
{workspaceRoot || workspaceName}
{hasGit ? ` / ${currentBranch}` : ""}
</TooltipContent>
</Tooltip>
@@ -274,7 +317,11 @@ export function WorkspaceSelector({
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search workspaces & branches"
placeholder={
hasGit
? "Search workspaces & branches"
: "Search workspaces"
}
className="flex-1 h-auto border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
/>
</div>
@@ -291,10 +338,27 @@ export function WorkspaceSelector({
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Workspaces
</div>
{looksLikeFolderPath(search) && (
<Button
variant="ghost"
disabled={switchingWorkspace}
onClick={() => {
void handleWorkspaceSelect(search);
}}
className="mb-0.5 flex h-auto w-full items-center justify-start gap-2 rounded-md p-2 text-left"
>
<FolderCode className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-foreground">
Open folder {search.trim()}
</span>
</Button>
)}
<div 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">
No workspaces found
{looksLikeFolderPath(search)
? "Press the option above to open this folder"
: "No workspaces found — type a full folder path to add one"}
</div>
) : (
filteredWorkspaces.map((wp) => {
@@ -311,7 +375,9 @@ export function WorkspaceSelector({
}}
className={cn(
"flex items-center justify-between h-auto rounded-md p-2 text-left w-full",
isActive ? "bg-accent" : "hover:bg-accent/50",
isActive
? "bg-surface-hover"
: "hover:bg-surface-hover-lighter",
)}
>
<div className="flex items-center gap-2 min-w-0 w-full">
@@ -339,7 +405,7 @@ export function WorkspaceSelector({
>
{pickingWorkspace
? "Opening folder picker..."
: "Switch workspace path..."}
: "Open folder..."}
</Button>
{showWorkspacePathInput ? (
<div className="mt-1 flex items-center gap-1">
@@ -374,104 +440,113 @@ export function WorkspaceSelector({
</Button>
</div>
) : null}
</div>
{/* Branches section */}
<div className="p-1.5">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Branches
</div>
<div 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
</div>
) : (
filteredBranches.map((branch) => (
<Button
variant="ghost"
key={branch}
disabled={switching}
onClick={() => {
void handleSelectBranch(branch);
}}
className={cn(
"flex items-start gap-2 h-auto rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-accent"
: "hover:bg-accent/50",
)}
>
<GitBranch className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">
{branch}
</span>
{currentBranch === branch && (
<Check className="h-3 w-3 text-foreground ml-auto shrink-0" />
)}
</div>
</div>
</Button>
))
)}
</div>
</div>
{/* Create branch */}
<div className="border-t border-border p-1.5">
{showCreateBranch ? (
<div className="flex flex-col gap-2 p-2">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateBranch();
if (e.key === "Escape") {
setShowCreateBranch(false);
setNewBranchName("");
}
}}
placeholder="Branch name"
className="h-8 text-xs"
/>
<div className="flex items-center gap-2">
<Button
onClick={() => void handleCreateBranch()}
disabled={!newBranchName.trim() || switching}
size="sm"
className="flex-1 text-xs"
>
Create
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowCreateBranch(false);
setNewBranchName("");
}}
className="flex-1 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
{workspaceError && (
<div className="mt-1 rounded-md bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{workspaceError}
</div>
) : (
<Button
variant="ghost"
onClick={() => setShowCreateBranch(true)}
size="sm"
className="justify-start w-full text-xs text-muted-foreground"
>
<Plus className="size-3" />
Create and checkout new branch...
</Button>
)}
</div>
{/* Branches section (git repos only) */}
{hasGit ? (
<div className="p-1.5">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Branches
</div>
<div 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
</div>
) : (
filteredBranches.map((branch) => (
<Button
variant="ghost"
key={branch}
disabled={switching}
onClick={() => {
void handleSelectBranch(branch);
}}
className={cn(
"flex items-start gap-2 h-auto rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-surface-hover"
: "hover:bg-surface-hover-lighter",
)}
>
<GitBranch className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">
{branch}
</span>
{currentBranch === branch && (
<Check className="h-3 w-3 text-foreground ml-auto shrink-0" />
)}
</div>
</div>
</Button>
))
)}
</div>
</div>
) : null}
{/* Create branch (git repos only) */}
{hasGit ? (
<div className="border-t border-border p-1.5">
{showCreateBranch ? (
<div className="flex flex-col gap-2 p-2">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateBranch();
if (e.key === "Escape") {
setShowCreateBranch(false);
setNewBranchName("");
}
}}
placeholder="Branch name"
className="h-8 text-xs"
/>
<div className="flex items-center gap-2">
<Button
onClick={() => void handleCreateBranch()}
disabled={!newBranchName.trim() || switching}
size="sm"
className="flex-1 text-xs"
>
Create
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowCreateBranch(false);
setNewBranchName("");
}}
className="flex-1 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
onClick={() => setShowCreateBranch(true)}
size="sm"
className="justify-start w-full text-xs text-muted-foreground"
>
<Plus className="size-3" />
Create and checkout new branch...
</Button>
)}
</div>
) : null}
</>
)}
</div>
@@ -396,7 +396,7 @@ function MarketplaceEntryCard({
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
size="sm"
size="xs"
type="button"
variant={installed ? "destructive" : "default"}
>
@@ -462,7 +462,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter">
{content}
</div>
);
@@ -473,7 +473,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -22,11 +22,16 @@ import {
} from "@/components/ui/select";
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
writeModelSelectionStorageToWindow,
} from "@/lib/model-selection";
import {
CLINE_DASHBOARD_URL,
getProviderApiKeyUrl,
} from "@/lib/provider-key-urls";
import {
fetchProviderCatalog,
invalidateProviderCatalogCache,
@@ -153,11 +158,16 @@ function WelcomeStep({ onContinue }: { onContinue: () => void }) {
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-foreground">
Cline
</h1>
<p className="mt-2 text-[15px] text-muted-foreground">
<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">
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-[15px]"
className="mt-9 h-11 w-full rounded-full text-base"
onClick={onContinue}
type="button"
>
@@ -307,10 +317,15 @@ function ConnectStep({
// account context swallows errors, so an invalid key would
// otherwise onboard the user into a broken signed-in state.
try {
await desktopClient.invoke("cline_account", {
const verified = await desktopClient.invoke("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
// A typed not-authenticated result means the sidecar found no
// usable credential after the save — the key did not stick.
if (isClineAccountNotAuthenticatedResult(verified)) {
throw new Error("no Cline account credentials were found");
}
} catch (verifyError) {
// Roll back the persisted key so an unusable credential does
// not linger in provider settings.
@@ -341,6 +356,9 @@ function ConnectStep({
const selectedProvider =
providers.find((provider) => provider.id === selectedProviderId) ?? null;
const selectedProviderKeyUrl = selectedProvider
? getProviderApiKeyUrl(selectedProvider)
: null;
const connectProvider = useCallback(async () => {
if (!selectedProvider || !apiKey.trim()) {
@@ -395,7 +413,7 @@ function ConnectStep({
{/* Cline account */}
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<p className="text-[15px] font-semibold text-foreground">
<p className="text-base font-semibold text-foreground">
Sign in with Cline
</p>
<Badge className="bg-primary/15 text-primary" variant="secondary">
@@ -512,9 +530,14 @@ function ConnectStep({
{clineKeySaving ? "Connecting..." : "Connect"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Find your key in the Cline dashboard under Account.
</p>
<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"
onClick={() => void openExternalUrl(CLINE_DASHBOARD_URL)}
type="button"
>
Find your key in the Cline dashboard under Account
<ExternalLink className="size-3" />
</button>
{clineKeyError ? (
<p className="text-xs text-destructive" role="alert">
Failed to save API key: {clineKeyError}
@@ -538,7 +561,7 @@ function ConnectStep({
<KeyRound className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold text-foreground">
<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">
@@ -598,15 +621,14 @@ function ConnectStep({
value={apiKey}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
{selectedProvider?.docUrl ? (
{selectedProviderKeyUrl ? (
<button
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={() =>
void openExternalUrl(selectedProvider.docUrl ?? "")
}
onClick={() => void openExternalUrl(selectedProviderKeyUrl)}
type="button"
>
{selectedProvider.docLabel || "Get an API key"}
{selectedProvider?.docLabel ||
`Get ${selectedProvider ? `a ${selectedProvider.name}` : "an"} API key`}
<ExternalLink className="size-3.5" />
</button>
) : (
@@ -665,7 +687,7 @@ function DoneStep({
: "Your Cline account is connected. Pick a project and start your first session."}
</p>
<Button
className="mt-8 h-11 w-full rounded-full text-[15px]"
className="mt-8 h-11 w-full rounded-full text-base"
onClick={onFinish}
type="button"
>
@@ -21,7 +21,7 @@ export function PageFrame({
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
<div className={cn("max-w-344", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
@@ -54,13 +54,13 @@ export function PageHeader({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
<h1 className="truncate text-3xl font-semibold text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
<p className="mt-3 max-w-2xl text-base text-muted-foreground">
{description}
</p>
) : null}
@@ -317,10 +317,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-end justify-between gap-6 px-18 pb-7 pt-10 max-[1200px]:px-8 max-md:pl-12 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:pr-4 max-[720px]:pt-5">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.15] tracking-normal">
Sessions
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
<h1 className="text-3xl font-semibold">Sessions</h1>
<p className="mt-3 text-base leading-6 text-muted-foreground">
Recent sessions across clients and workspaces.
</p>
</div>
@@ -459,8 +457,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
// never reflows as long values wrap or hydrate in.
"grid h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] items-center gap-x-4 border-t px-4 text-sm transition-colors",
activeSessionId === thread.id
? "bg-accent/50"
: "hover:bg-accent/30",
? "bg-surface-hover"
: "hover:bg-surface-hover-lighter",
)}
key={thread.id}
>
@@ -605,7 +603,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
<DropdownMenuTrigger asChild>
<button
aria-label={`Session actions for ${thread.title}`}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={Boolean(pendingKind)}
type="button"
>
@@ -0,0 +1,135 @@
// @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 { AccountView } from "./account-view";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
openExternalUrl: vi.fn(),
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
invoke.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("AccountView signed-out state", () => {
it("renders the sign-in prompt from the typed result and stops fetching account data", async () => {
invoke.mockResolvedValue({
signedIn: false,
code: "ACCOUNT_NOT_AUTHENTICATED",
});
await act(async () => {
root.render(<AccountView />);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Sign in to Cline");
});
expect(container.textContent).not.toContain(
"No Cline account auth token found",
);
// The auth state gates the rest of the overview: signed out means the
// balance/organization commands are never fired.
const accountCalls = invoke.mock.calls.filter(
([command]) => command === "cline_account",
);
expect(accountCalls).toEqual([
["cline_account", { action: "clineAccount", operation: "fetchMe" }],
]);
});
it("signs out when the organization balance fetch reports the typed signed-out result", async () => {
// The token can expire between the initial account fetches and the
// organization-balance fetch; the typed result must sign the view out
// rather than being coerced into a signed-in view with no balance.
invoke.mockImplementation(
async (_command: string, args?: Record<string, unknown>) => {
switch (args?.operation) {
case "fetchMe":
return {
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [],
};
case "fetchBalance":
return { balance: 5_000_000 };
case "fetchUserOrganizations":
return [
{
organizationId: "org-1",
name: "Cline",
active: true,
roles: ["member"],
},
];
case "fetchOrganizationBalance":
return { signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" };
default:
return {};
}
},
);
await act(async () => {
root.render(<AccountView />);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Sign in to Cline");
});
expect(container.textContent).not.toContain("Beatrix");
});
it("renders account data when the session is signed in", async () => {
invoke.mockImplementation(
async (_command: string, args?: Record<string, unknown>) => {
switch (args?.operation) {
case "fetchMe":
return {
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [],
};
case "fetchBalance":
return { balance: 5_000_000 };
case "fetchUserOrganizations":
return [];
default:
return {};
}
},
);
await act(async () => {
root.render(<AccountView />);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Beatrix");
});
expect(container.textContent).not.toContain("Sign in to Cline");
});
});
@@ -26,6 +26,7 @@ import {
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";
@@ -172,6 +173,10 @@ export function AccountView() {
>([]);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState<string | null>(null);
// Signed out is an expected state carried by a typed sidecar result (or a
// definitive auth error from older sidecars), tracked separately from
// failures so it renders the sign-in prompt instead of an error card.
const [signedOut, setSignedOut] = useState(false);
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
@@ -215,16 +220,38 @@ export function AccountView() {
setOverviewLoading(true);
setOverviewError(null);
try {
const [userData, balanceData, orgsData] = await Promise.all([
fetchAccountUser(),
// Resolve the auth state first: when the session is signed out the
// remaining account commands would just fail the same way, so they
// are never fired.
const userData = await fetchAccountUser();
if (isClineAccountNotAuthenticatedResult(userData)) {
resetAccountData();
setSignedOut(true);
return;
}
const [balanceData, orgsData] = await Promise.all([
fetchAccountBalance(),
fetchAccountOrganizations(),
]);
if (
isClineAccountNotAuthenticatedResult(balanceData) ||
isClineAccountNotAuthenticatedResult(orgsData)
) {
resetAccountData();
setSignedOut(true);
return;
}
const nextActiveOrganization =
orgsData.find((organization) => organization.active) ?? null;
const organizationBalanceData = nextActiveOrganization
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
: null;
if (isClineAccountNotAuthenticatedResult(organizationBalanceData)) {
resetAccountData();
setSignedOut(true);
return;
}
setSignedOut(false);
setUser(userData);
setBalance(balanceData);
setOrganizationBalance(organizationBalanceData);
@@ -232,7 +259,11 @@ export function AccountView() {
} catch (err) {
resetAccountData();
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
if (isAccountAuthError(message)) {
setSignedOut(true);
} else {
setOverviewError(message);
}
} finally {
setOverviewLoading(false);
}
@@ -280,7 +311,8 @@ export function AccountView() {
});
resetAccountData();
setActiveTab("overview");
setOverviewError("No Cline account auth token found");
setOverviewError(null);
setSignedOut(true);
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
@@ -321,6 +353,14 @@ export function AccountView() {
)
: await fetchUsageTransactions();
if (usageGenerationRef.current !== generation) return;
// The token can expire mid-session: render the sign-in state
// instead of an error toast.
if (isClineAccountNotAuthenticatedResult(data)) {
resetAccountData();
setSignedOut(true);
setActiveTab("overview");
return;
}
setUsageTransactions(data);
setUsageLoaded(true);
} catch (err) {
@@ -332,7 +372,7 @@ export function AccountView() {
setUsageLoading(false);
}
}
}, [activeOrganization]);
}, [activeOrganization, resetAccountData]);
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
useEffect(() => {
@@ -354,6 +394,12 @@ export function AccountView() {
setBillingError(null);
try {
const data = await fetchPaymentTransactions();
if (isClineAccountNotAuthenticatedResult(data)) {
resetAccountData();
setSignedOut(true);
setActiveTab("overview");
return;
}
setPaymentTransactions(data);
setBillingLoaded(true);
} catch (err) {
@@ -362,7 +408,7 @@ export function AccountView() {
} finally {
setBillingLoading(false);
}
}, []);
}, [resetAccountData]);
useEffect(() => {
if (activeTab === "billing" && !billingLoaded) {
@@ -409,7 +455,7 @@ export function AccountView() {
<button
type="button"
onClick={onRetry}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
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 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Retry
@@ -449,7 +495,7 @@ export function AccountView() {
<button
type="button"
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
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 "
>
Create account
<ExternalLink className="h-4 w-4" />
@@ -481,7 +527,7 @@ export function AccountView() {
onClick={input.onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-lg border border-border px-4 py-3 text-left transition-colors",
input.active ? "cursor-default" : "hover:bg-accent/20",
input.active ? "cursor-default" : "hover:bg-surface-hover-lighter",
!input.active && switchTargetId !== null && "opacity-60",
)}
>
@@ -511,13 +557,13 @@ export function AccountView() {
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account</h2>
<h2 className="text-2xl font-semibold text-foreground">Account</h2>
{user && (
<button
type="button"
disabled={accountActionPending !== null}
onClick={() => void signOut()}
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-60"
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" />
@@ -561,22 +607,20 @@ export function AccountView() {
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError &&
(isAccountAuthError(overviewError)
? renderSignedOut()
: renderError(overviewError, loadOverview))}
{!overviewLoading && !overviewError && user && (
{!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-primary/20 text-2xl font-bold text-primary">
<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-base font-semibold text-foreground">
<h3 className="text-lg font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
@@ -590,7 +634,7 @@ export function AccountView() {
type="button"
title="Open dashboard"
onClick={() => void openExternalUrl(DASHBOARD_URL)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</button>
@@ -618,7 +662,7 @@ export function AccountView() {
: USER_CREDITS_URL,
)
}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
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
@@ -652,7 +696,7 @@ export function AccountView() {
onClick={() =>
void openExternalUrl(CREATE_ORGANIZATION_URL)
}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
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 "
>
<Plus className="h-3.5 w-3.5" />
Create
@@ -715,7 +759,7 @@ export function AccountView() {
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
@@ -769,7 +813,7 @@ export function AccountView() {
{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 transition-colors hover:bg-accent/20"
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" />

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