chore(desktop): sync main and cut 0.0.15-beta.1 (#13425)
* fix(vscode): continue the surviving session on resume instead of rebuilding with the original task text (#13175) * fix(vscode): stop resubmitting the original task text on bare resume (#12975) A bare Resume after Stop rebuilt the session from task history and injected historyItem.task into the resumption prompt as 'New instructions from the user'. The model treated the already-completed original request as fresh instructions and re-executed it (e.g. re-ran all terminal commands after stopping a queued follow-up turn). The preserved conversation history is the source of truth on resume, so the fallback prompt now just asks the model to reassess the history and continue, matching the legacy resume prompt which also never resent the original task. User-typed text still takes precedence when provided. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(vscode): continue the surviving idle session on follow-ups instead of rebuilding Stopping a turn keeps the session alive, but every idle follow-up (bare Resume after Stop, and typed follow-ups after a completed turn) tore that session down and rebuilt it from persisted task history before sending. Continue the matching idle session in place instead, the same way the CLI reuses the live session after an abort. Rebuilding from history now only happens when no live session matches the displayed task (task opened from history, extension host reload). A bare resume still needs a prompt to start a turn, so it sends the neutral [TASK RESUMPTION] prompt (shared with the rebuild fallback and hidden from the transcript); user-typed content is echoed and sent as-is. If the send lands while the abort is still settling, the runtime auto-queues it and drains once the abort completes. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * refactor(vscode): consolidate follow-up send paths in SdkFollowupCoordinator Now that idle follow-ups continue the live session in place, the two-mode sendToActiveSession helper was redundant: its non-queued branch duplicated continueIdleSession minus the bare-resume prompt. Split it into a single-purpose queueToActiveSession and fold the idle no-task send into continueIdleSession, flattening askResponse's decision tree to: queue onto a running turn, continue a matching live idle session, rebuild from history, or abandon. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * refactor(vscode): reuse the existing neutral resumption prompt for bare resumes Drop the newly invented long resumption wording in favor of the phrase that already existed as the no-history fallback and that the transcript hiding logic and test fixtures recognize: '[TASK RESUMPTION] Please continue where you left off.' The net change to resumeSessionFromTask against main is now just deleting the branch that resubmitted historyItem.task as new instructions. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(vscode): stop resubmitting the original task text on bare resume (#12975) A bare Resume after Stop rebuilds the session from task history and injected historyItem.task into the resumption prompt as 'New instructions from the user'. The model treated the already-completed original request as fresh instructions and re-executed it (e.g. re-ran all terminal commands after stopping a queued follow-up turn). Bare resumes now always use the neutral prompt that already existed as the no-history fallback; user-typed text still takes precedence. This matches the legacy resume prompt (responses.taskResumption), which only ever included user-supplied text as new instructions. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(vscode): hide synthetic prompts from the queued-prompt echo A send that races a settling abort is auto-queued by the runtime, so a bare Resume can reach the pending_prompt_submitted echo carrying the synthetic [TASK RESUMPTION] prompt. Echoing it leaked model-facing text as a visible user bubble and shifted the visible-user-message ordinals that edit/regenerate mapping relies on. Filter synthetic prompts with isSyntheticUserPrompt, keeping user attachments visible (matching isSyntheticSdkUserMessage semantics). Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(vscode): preserve LiteLLM input token limits (#13293) * fix(vscode): preserve LiteLLM input token limits * fix(vscode): prefer live LiteLLM model metadata * fix(vscode): generalize private catalog metadata * test(vscode): preserve llms exports in vscode lm mock * fix(vscode): point provider signup URLs at their API key pages (#13337) * fix(vscode): point Mistral signup URL at the general API keys console The Mistral provider's signup link led to the Codestral console, which issues Codestral-scoped keys that fail with 401 on api.mistral.ai — the endpoint the provider actually calls. Point it at the general API keys page instead. Fixes #13288 * fix(vscode): deep-link DeepSeek and Fireworks signup URLs to their API key pages Both pointed at marketing homepages; link straight to the key-creation pages instead, matching the rest of the registry and the desktop app's provider-key-urls map. * fix(ci): always build the legacy bundle from the legacy-extension branch (#13349) The combined-VSIX workflow took legacy-ref as a free-form dispatch input with no publish-time validation (next-ref has one: publish requires main). Any typed ref — a PR merge ref, an unprotected branch — would be built into the published VSIX by the environment-less build job, and the publish environment approver only ever sees an opaque prebuilt artifact, so the approval protected the marketplace PAT but not the shipped bytes. Remove the input entirely and hardcode the protected legacy-extension branch, which makes that branch's protection rules load-bearing for releases. The tested-sha pinning between test-legacy and build is unchanged. publish-extension skill dispatch command updated to match. * fix(ci): lock the legacy publish workflow to the legacy-extension branch (#13350) The branch dispatch input was a free-form string with no validation. Both jobs checked it out and ran full npm lifecycle scripts from it: the publish job next to VSCE_PAT/OVSX_PAT (and npm run publish:marketplace executes a script from that same ref with the PATs in env), and the test job with NO environment approval at all while inheriting the workflow-level contents/packages/checks/pull-requests write grants. A dispatch pointing at e.g. refs/pull/N/head would run outside-contributor code with the marketplace keys behind one approval, or with a repo-write token behind none. Remove the input and hardcode the protected legacy-extension branch, drop the workflow-level permissions to contents: read, and elevate only the publish job to contents: write (tag push + GitHub release). The branch input's default was legacy-extension, so normal publishes are unchanged. publish-extension skill dispatch command updated to match. * fix(vscode): SDK remote-config parity — refresh coordination, session gating, and fail-closed opt-out (#13226) * feat(desktop): native notifications (#13166) * feat(desktop): native notifications * macos target * fix(desktop): isolate macOS dev app identity * fix(desktop): address notification review feedback --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> * fix(vscode): clear task-scoped settings overlay when task view is cleared or switched (#13310) * fix(vscode): clear task-scoped settings overlay when task view is cleared or switched Toggling an auto-approve setting while a task is open writes autoApprovalSettings into the StateManager's task-settings overlay (updateAutoApprovalSettings -> setTaskSettings). The SDK controller never cleared that overlay on clearTask/showTaskWithId (the legacy controller did), so after New Task the stale overlay kept shadowing global settings in getGlobalSettingsKey(): toggle RPCs were accepted into global state, but every posted state still carried the overlay's old version, which the webview rejects as not newer - the auto-approve checkboxes froze forever. Restore legacy parity in SdkTaskControlCoordinator: drop the overlay (persisting pending writes first) in clearTask() and before installing a different task's proxy in showTaskWithId(). Fixes #13260 Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * changeset * test(vscode): add end-to-end regression test for auto-approve freeze after New Task Wires the real StateManager, the real updateAutoApprovalSettings handler, and the real SdkTaskControlCoordinator.clearTask() together with the webview's version gate modeled on ExtensionStateContext, pinning the end-to-end invariant behind #13260: checkbox toggles must keep reaching the webview after a mid-task toggle followed by New Task. Verified the test fails when the clearTaskSettings() call is removed from clearTask(). * fix implicit any in regression test --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(desktop): show provider web-search support under the settings toggle (#13328) * feat(desktop): show provider web-search support under the settings toggle The global Web search toggle silently does nothing unless the session's provider offers native web search, which made the setting read as if it worked with any provider. The desktop General settings row now explains that only providers with built-in web search honor it, and shows a live status line: which connected providers are ready to use it (no extra setup needed), or an amber warning with a link to the Models section when none of them support it. Support is resolved in the webview via a new providerOffersModelTool helper in @cline/llms (browser export), sharing the same builtin-manifest source of truth as the runtime's supportsModelTool attachment check. * fix(desktop): address review — refetch web-search status on catalog invalidation, clarify per-model support Greptile P2: the one-time catalog fetch could race an in-flight provider save and show stale status; the row now refetches when the provider catalog cache is invalidated (fired after saves complete). Greptile P1: the ready line implied every model on the provider works; Vertex excludes Claude routes, so the copy now scopes the promise to models that support it. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> * feat(ui/desktop): collapse finished runs into a work summary and remove hover-state dead space (#13315) * feat(ui): add WorkActivity collapsed-run summary and float message actions as a pill WorkActivity/WorkActivityTrigger/WorkActivityContent fold a finished agent run's working rows (tool calls, thinking traces, narration) behind a single "Worked for 4m 12s · 14 tool calls" disclosure built on the shared animated disclosure primitives, with formatWorkActivityLabel/formatWorkDuration exported for consumers. Message hover actions no longer rely on the transcript reserving blank space below each message: the action row is now a self-backed pill (border, blurred background, shadow) that floats over whatever follows, so conversations can pack rows tightly without hover chrome colliding with the next message. * feat(desktop): collapse finished runs into a work summary and tighten chat spacing collapseCompletedWork post-processes the grouped transcript: once a run ends on assistant text with no further tool calls, its working rows fold into one expandable WorkActivity row while the final answer stays visible. Runs are delimited by user messages; the trailing run only collapses when the session has stopped running and actually produced an answer, so live streams and cancelled/failed tails keep their rows. Assistant messages carrying images or media are treated as deliverables and never collapse. The conversation list gap drops from gap-8 to gap-4 now that hover actions are self-backed pills that need no reserved space, and user messages add their own top margin so turn boundaries stay visually distinct. * refactor(ui/desktop): work summary label wording, flat expansion, stable in-run rhythm Feedback round on #13315: - Label reads "Worked for 4m 12s and made 14 tool calls" instead of joining with a dot; without a duration it falls back to "Made N tool calls". - Expanded work rows render at transcript level — no rail or extra indent — since tool rows and thinking traces already carry their own nesting when expanded. The work content keeps the tight working-row rhythm. - Live working rows (thinking traces + tool calls) now group into a 'run' render item with the same tight 0.25rem rhythm, so there is no oversized gap under a "Thought for Ns" row and every row keeps its exact position when the finished run folds into the work summary. A trailing answer-in-progress stays outside the group at transcript level, and pure prose spans keep normal spacing. - The transient "Thinking..." indicator moves inside the transcript column and mirrors a trigger row's geometry, so the first real row replaces it in place with no jump. * style(ui/desktop): hover-pill metrics, right-pointing work chevron, scroll and spacing fixes Another feedback round on #13315: - Hover action pill: +2px internal padding, a trailing inset after the timestamp (it sat flush against the pill border), and more clearance between the message content and the pill (2px -> 6px; the hover bridge grows to match). - The work summary chevron points right while collapsed and continues counterclockwise to point up when expanded. - Conversation bottom padding drops pb-20 -> pb-8: the composer sits below the scroller, so the padding only needs to clear a pinned action pill. - Sending a message scrolls back to the bottom even if the reader had scrolled up (new AutoScrollOnSend on the user-message count, which ignores optimistic-bubble re-keying; @cline/ui now exports useConversation for this). - An assistant answer directly under its run's working rows pulls itself 0.5rem closer than the full transcript gap. * style(desktop): leave a visible gap between a pinned action pill and the composer pb-8 exactly matched the pill's ~40px footprint, so the last row's hover actions sat flush against the composer top; pb-12 restores ~8px of daylight. * style(desktop): widen the gap between the pinned action pill and the composer to ~24px pb-12 left only ~8px of daylight under the pill; pb-16 reads comfortable without reverting to pb-20's dead space. * fix(desktop): keep the thinking indicator at the working-row offset mid-run The indicator matched a trigger row's geometry but sat a full transcript gap (1rem) below the last working row, while the tool/thinking row replacing it joins the tight run group at 0.25rem — a visible upward jump. When the last transcript item is working rows (or streamed assistant output), the indicator now pulls up to the same tight offset; only at the start of a run, under the user message, does it keep the normal gap. * style(ui): calm the hover actions surface per team feedback Borderless rectangle instead of the bordered pill: radius drops to var(--radius), the side padding goes entirely (the icon buttons carry their own hit areas), and the vertical padding halves. Blurred background and shadow stay so it remains legible over following content. * feat(ui/desktop): full-band hover reveal and iOS-style disclosure easing The hover actions only appeared while the pointer was inside the message box itself. The invisible bridge under each message now spans the full height of the band the floating actions occupy (full row width), so hovering anywhere in that strip reveals them. Sibling row types (.cline-chat-tool, .cline-chat-work, and the desktop's run/tool groups) become position: relative so they paint above the bridge — their own content keeps its hover and clicks, and the bridge only wins in the band's genuinely empty space. All expandable rows (work summary, tool panels, thinking) open and close on a 240ms symmetric ease-in-out cubic-bezier instead of the 60ms snap, with chevron rotation on the same curve. Reduced-motion still disables both. * revert(ui/desktop): drop the full-band hover reveal; quicken disclosure easing to 180ms The full-band hover bridge (and the position: relative changes that made it safe) is reverted per feedback — back to the narrow bridge that only spans the gap under the message. The iOS-style ease-in-out on disclosures stays but speeds up from 240ms to 180ms. * fix(ui): recover live tool diffs that mount as a blank pierre skeleton Live-streamed edit rows could show an empty diff for the whole run, with the diff only appearing after the collapsed work row was expanded (fresh mount). Root cause, confirmed by driving a live session and inspecting the element: React StrictMode double-invokes @pierre/diffs' ref callback; the first instance's async highlight work aborts on its immediate cleanup, and the second instance adopts the abandoned half-rendered shadow tree as if it were complete prerendered output — zero height, no code, no theme stylesheet, permanently. A rendered diff always carries style[data-theme-css] in its shadow root, so ToolFileDiff now checks for it shortly after mount and remounts FileDiff (bounded attempts) when missing; the fresh host element takes the normal render path and recovers within ~400ms. Verified live: the diff now renders during the run. * fix(desktop): keep interrupted runs expanded even with partial trailing text The trailing-run collapse gated on 'ended with assistant text', which misread a Stop that landed mid-answer as a finished run and folded the tool calls the user wants to inspect. The gate is now the terminal status itself: only completed (or restored-idle) sessions collapse the trailing run; cancelled/failed/error tails keep their rows regardless of partial text. (Greptile P1 on #13315 — matches the PR's stated rule.) * feat(ui): share the markdown pipeline, chat polish, and ThinkingBlock across products (#13323) * feat(ui): share the markdown pipeline, chat polish CSS, and ThinkingBlock The desktop app and the cloud dashboard both consume @cline/ui yet rendered assistant output differently, because Markdown policy and the thinking-trace row lived app-side. This moves the shareable parts into the package: - components/markdown (new export): the lazy Shiki code highlighter (GitHub light/dark, pinned language set) and agentMarkdownControls — the standard Streamdown configuration. streamdown/shiki/@shikijs/* become optional peer dependencies, mirroring @pierre/diffs. - components/markdown.css: the desktop's chat polish moves in — chat-scale headings, outside list markers, single quiet code blocks with a hover-revealed copy control, table cards. Kept unlayered so it beats Streamdown's layered Tailwind utilities without !important. - ThinkingBlock + formatThoughtLabel in agent-chat: the standard thinking row (brain icon, Thinking/Thought-for-Ns label, streaming shimmer, rail presentation, capped scrollable body). The shimmer and the reasoning-hover-suppression rule move into agent-chat.css; triggers gain the color transition the desktop applied locally. Version bumps to 0.2.0-next.5 for the dashboard to pick up. * refactor(desktop): consume shared markdown and thinking primitives from @cline/ui The local Shiki highlighter, Streamdown controls, chat markdown polish CSS, streaming-title shimmer, and reasoning hover-suppression rule are deleted in favor of the @cline/ui versions (the highlighter test moves to the package's suite). ReasoningBlock becomes a thin wrapper that hands MemoizedMarkdown to the shared ThinkingBlock, and formatThoughtLabel re-exports from the package so grouping code and tests keep their import path. globals.css now imports @cline/ui/components/markdown.css (unlayered, so the polish keeps beating Streamdown's layered utilities); the app keeps only what is genuinely app-specific: link/image policy in markdown.tsx, selectability rules, accent palettes, and the view-enter transition. * style(ui/desktop): make thinking-trace prose legible Thinking body text rendered too faint: plain muted-foreground plus the desktop's font-thin weight. The shared thinking content now leans 75% of the way back toward the body text color (still slightly de-emphasized), and the desktop drops the thin font weight. * ci(ui-publish): build @cline/shared before ui typecheck (#13354) @cline/ui's generated-media imports @cline/shared/browser, which resolves to shared's dist output. The build-shared step sat after typecheck/test/build, so the first ui-publish dispatch since #13025 failed at Typecheck UI with TS2307. Move the step to right after install. * fix: run_commands object form without args routes through the shell instead of failing with ENOENT (#13336) * fix: run_commands object form without args routes through the shell The structured { command, args? } form of run_commands was always spawned directly with shell: false. When a model emitted a full command line in command with no args (e.g. { command: "echo hello" }), spawn failed with ENOENT for any command containing a space, breaking command execution for the whole session. Direct exec now only applies when a non-empty args list is provided; the object form without args is routed through getShellInvocation like the string form. Schema descriptions are tightened so models put arguments in args instead of embedding them in command. Fixes #13279 Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * chore: trim structured-command schema descriptions The union schema is only used for lenient validation of input the model already sent; its descriptions never reach a model prompt. Keep them short instead of restating executor behavior. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * chore: simplify direct-exec comment in shell executor Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * revert: keep original structured-command schema description The description never reaches a model prompt and the executor now handles both shapes, so the wording change was cosmetic noise. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix: gate direct exec on args key presence, not array length Review feedback: an explicit empty args array is intentionally structured input and stays direct exec; only an object with no args key is treated as a full shell command line. Matches the key-presence rule already used by the VS Code host's formatCommandForTerminal. Also replaces the empty-args shell test (which was PowerShell-incompatible) with a test pinning the direct-exec contract. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix: normalize Gemini custom base URLs for legacy host-root values (#13329) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * docs: add GLM-5.3 to ClinePass models and reference pricing (#13357) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(desktop): stream run command output (#13179) * feat(desktop): stream run command output * fix(sdk): clean up detached command logs * fix(sdk): reap detached logs after hub restarts * fix(sdk): preserve live detached command logs * fix(desktop): harden live command progress * fix(sdk): recover detached logs for local hosts * fix(desktop): reconcile command output tool rows * fix(sdk): retain logs for surviving commands * fix(core): prevent PID reuse from retaining detached logs * fix(core): preserve detached logs on probe failures * fix(core): retain detached logs during probe outages * fix(desktop): resolve leftover merge conflict in messages projection test Combine both sides of the assertion: main's incremented per-block createdAt projection and this branch's toolCallId/hookEventName meta. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(cli): make TUI dialog colors follow theme changes live (#13355) * fix(cli): make TUI dialog colors follow theme changes live Dialog content previously read the static palette constant, so open dialogs (including the theme picker itself) kept the default dark-blue accents while scrolling through theme previews. Add getDialogPalette / useDialogPalette, which resolve dialog colors from the active theme's dialog accents and re-render on every theme change, and migrate all dialog-rendered components to it. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(cli): derive dialog panel background from the active theme Dark themes now lift their own background one OKLAB step for the dialog surface, so panels keep the theme's hue instead of the library's fixed #262626. DialogThemeSync pushes the surface into the dialog container for new dialogs and repaints open panels, so the surface also follows live theme previews. Light themes keep the neutral dark panel to match the dark accent fallback and the light-on-dark dialog text. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix: skill slash commands load via the skills tool instead of expanding into the user message (#13327) * fix(desktop): show typed slash command instead of expanded skill markdown The sidecar expands /skill and /workflow tokens into their instructions before dispatching, so the runtime's persisted transcript only contains the expanded text. After a turn (and when reopening a session) the webview re-hydrates from that history and rendered the whole SKILL.md body as the user's message; queue events echoing the expanded prompt could also add a second user bubble, and fresh sessions were titled with the markdown's first line. The CLI never shows this because its TUI keeps the typed text in its own transcript and only sends the expanded prompt to the model. Mirror that separation inside the desktop sidecar's display boundaries: - history projection (readSessionMessages) inverts user text that starts with a configured command's instructions back to '/name remainder', which also repairs sessions recorded before this fix - queue snapshots and chat_queued_prompt_start events echo the typed prompt recorded at expansion time, so the webview's optimistic-bubble re-key matches again - an untitled session sent an expanded prompt gets titled from the typed command instead of the instructions' first line Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): don't overwrite a mid-turn rename with the typed-command title The untitled check ran before dispatch, so renaming a fresh slash-command session while its first turn was running got clobbered by the post-turn typed-command title. Re-check at write time and only replace a missing title or the one the runtime auto-derived from the expanded prompt. Also documents the inherent prefix-inversion ambiguity flagged in review: text hand-typed with a command's exact instructions persists byte-identically to that command's expansion, so stored history alone cannot distinguish them. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): stop expanding skill commands; let the skills tool load them Pasting the skill body into the prompt is why the transcript could ever show it: the desktop webview re-hydrates from the runtime's persisted history, so whatever the sidecar splices into the user message renders as if the user typed it. The runtime already registers the skills tool, whose description requires the model to invoke it whenever the user references a slash command — so send the typed /skill text through and let the tool deliver the instructions as a tool result (previously they arrived twice: pasted and via the tool). The persisted user message, session title, and queue entries are then simply the typed command, which deletes the typed-prompt registry, the queue event/snapshot rewriting, and the title machinery from the previous approach. Workflows are not served by the skills tool and keep textual expansion, so the read-time display inverter stays: it collapses expanded workflow prompts — and skill prompts persisted before this change — back to the typed /command in the history projection. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(core): option to keep skill slash commands typed for the skills tool resolveRuntimeSlashCommandFromWatcher (and the hub snapshot proxy) accept expandSkillCommands: hosts whose sessions register the skills tool pass false so the typed /skill goes through and the model loads the instructions as a tool result, keeping the persisted transcript as what the user typed. Workflows always expand — the tool does not serve them. isSkillsToolAvailable exposes the catalog check hosts use to decide (yolo preset and the skills tool toggle leave textual expansion as the only delivery path). Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(cli): skill slash commands load via the skills tool instead of expanding The TUI user-command wrap and buildUserInputMessage now keep a typed /skill as-is when the session's mode/toggles register the skills tool, matching the desktop app; workflows keep expanding, and yolo (zen) keeps expanding skills because its preset has no skills tool. This also fixes CLI resume/history surfaces showing the skill body: the persisted user message is now the typed command. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(vscode): keep configured skill slash commands typed for the skills tool expandSlashCommands no longer splices a configured skill's instructions into the model text; the SDK session's skills tool delivers them as a tool result (previously they arrived twice). Builtin pseudo-skills like /deep-planning are not served by that tool and keep expanding, as do workflows. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * refactor(desktop): use the shared skill-expansion option in the sidecar Replaces the sidecar's workflow-detection dance with core's expandSkillCommands option and gates on isSkillsToolAvailable, restoring textual expansion where the tool is missing (yolo mode or the skills tool toggle) — a gap in the previous desktop-only change. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * refactor(desktop): drop the display inverter for expanded transcripts Accepted trade-off to keep the change minimal: sessions recorded before skills switched to the skills tool, workflow sends (deprecated), and yolo-mode skill sends persist expanded instructions and now render that text as-is instead of being collapsed back to the typed /command at projection time. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Use fixed selection chevron in account dialog to match other dialogs (#13364) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): align system prompt with session mode (#13361) Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> * fix(desktop): finalize queued turns on chat_done with canonical history reconcile (#13330) Turns that settle through the event stream (queued prompts, including the first prompt of a fresh session) resolve their send() RPC early, so nothing cleared the streaming shimmer or reconciled live-streamed content against the persisted transcript at turn end. A turn whose deltas were incomplete stayed visually streaming forever and only healed when a later non-queued send rehydrated history. chat_done (and chat_session_ended / the queue-drain double check) now clears the active assistant streaming id and schedules a short-delayed read_session_messages + applyCanonicalHistory, guarded by turn epoch, session id, and in-flight send submissions so it never clobbers a newer turn or duplicates the blocking send path's own finalization. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * chore(desktop): release v0.0.14 * fix(clients): filter non-chat models from chat pickers (#13317) * fix(clients): filter non-chat models from chat pickers * fix(clients): align chat model eligibility * fix(desktop): strip user_input envelope when copying a user message (#13369) Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bee <abeatrix@users.noreply.github.com> * docs: show DeepSeek V4 peak and off-peak pricing (#13312) * docs: update DeepSeek V4 average pricing * docs: show DeepSeek peak and off-peak pricing * docs: add GLM-5.3 reference pricing (same as GLM-5.2) * docs: add GLM-5.3 to ClinePass models table * fix(llms): display billed gateway cost (#13385) * fix(shared): run PowerShell commands with fail-fast error semantics (#13358) * fix(shared): run PowerShell commands with fail-fast error semantics The run_commands PowerShell wrapper never set $ErrorActionPreference, so the default 'Continue' applied: a pipeline erroring per item (e.g. a malformed Where-Object over Get-ChildItem -Recurse) emitted one error record per enumerated file - tens of thousands of stderr records on large trees, looking like a hang - and could still resolve as SUCCESS with exit 0. Prepend $ErrorActionPreference='Stop'; to the script content executed by the ScriptBlock so the first error terminates the command with a non-zero exit and a single error message. Concatenated on the same line as the user command so error line numbers stay unshifted. Fixes #13285 Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(shared): set the fail-fast preference in the bootstrap scope Setting $ErrorActionPreference='Stop' by string-prepending it into the scriptblock source displaced a leading param(...) from its mandatory first-statement position, so scripts beginning with a param block failed with CommandNotFoundException. Preference variables are dynamically scoped, so setting Stop in the -Command bootstrap gives the invoked scriptblock identical fail-fast semantics while keeping the user script byte-identical (param works, error positions unshifted) and drops the doubled-quote escaping. * docs(shared): document the fail-fast tradeoffs in the PowerShell wrapper Stop promotes every non-terminating error, not only per-item pipeline floods: partial-result commands (recursive listings over access-denied junctions) now stop at their first error, and Windows PowerShell 5.1 turns in-script stderr redirection of succeeding native commands fatal. State this in the wrapper comment as a deliberate tradeoff, with the GitHub Actions precedent and the per-command opt-outs. --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com> * ci: stop over-long changelogs from silently dropping release Slack posts (#12955) Slack section blocks reject text longer than 3000 characters. The Slack action logs that rejection as ##[error] but does not fail the step, so an over-long changelog drops the release announcement while the run stays green — cline@3.0.50 (3272 chars) published to npm, tagged, and cut a GitHub release with no Slack post and nothing red to notice. Every publish workflow pasted the changelog section verbatim into one section block, so all six were exposed; the SDK, desktop, and extension sections were only 150-350 chars under the ceiling. Add a slack_content output alongside content: unchanged when the section fits, otherwise trimmed on a line boundary with a link to the full release notes. Only the Slack payload uses it — GitHub release bodies and the desktop updater manifest still get the whole section. * ci: tidy workflow cache config and job permissions (#13403) Publish workflows now always do clean npm installs (no dependency cache in their test gates), the e2e workflow's cache keys are exact-match only, and the e2e job drops an id-token permission it never used. * Rename desktop app from "Cline Code" to "Cline" (#13401) * Rename desktop app from Cline Code to Cline Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Format touched Rust test assertions Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(llms): surface provider-executed tool activity as observational events (#13300) * fix(llms): surface provider-executed tool activity as observational events Provider-executed tool parts (e.g. every tool the Claude Code CLI runs inside its own session) were dropped by the model-tool guard added for web search: only declared model tools were re-emitted, everything else hit continue with nothing yielded. Those sessions modified the workspace with no tool activity in runtime events, transcripts, or the UI. Route all providerExecuted parts onto the observational path instead: emit execution-tagged tool-call-delta and tool-result events, matched by tool-call ID for providers that omit the flag on the result half. They stay out of AgentRuntime's execution/approval loop, and the runtime already persists them as modelToolActivities and projects them for display. The AgentModelEvent tool-result variant widens toolName from ModelToolName to string to carry the provider's own tool names. * fix(agents): keep turns that are only provider-executed tool activity A turn consisting solely of observational tool activity has an empty assistant content array - the activity lives in message metadata, since projecting it into content would replay tool_use blocks the model never gets results for. The empty-content guard threw on such turns, erroring the run and losing the activity from the transcript. Count model-tool activity as content for the emptiness check (error finishes still throw); replay stays safe through the codec's empty-content placeholder. Also drop the trailing text delta from one gateway test so the tool-only stream shape stays covered end to end. * feat: allow agents to create scheduled tasks (#13331) * feat(core, desktop): add durable todo agenda * fix(desktop): secure todo approvals and track tool usage * fix(desktop): clean up failed approval delivery * fix(desktop): authenticate approval connections * fix(desktop): cancel approvals on broadcast failure * fix(desktop): authenticate development approvals * fix(desktop): harden development approvals * test(core): make task paths cross-platform * fix(desktop): serialize approval readiness * refactor(core): unify todo and schedule tools * feat(core): distinguish user todos from agent suggestions * fix(core): hide tasks tool in yolo mode * fix(core): enforce schedule workspace scope * fix(core): bind schedule scope to hub connection * fix(core): establish task scope at hub startup * fix(core): scope task automation by workspace * test(core): normalize workspace path expectations * test(core): serialize Windows CI workers * fix(core): reject unregistered schedule authority * fix(desktop): guard task execution commands * fix(core): avoid polynomial regex in mention parsing * fix(core): address schedule tool review feedback * fix(core): bind websocket clients to hub workspace * fix(core): flatten tasks tool input schema * fix(core): authorize multi-workspace hub clients * test(core): type hub transport authority mock * fix(cli): register a workspace client for remote schedule commands (#13398) --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> * fix(desktop): treat ClinePass as OAuth-managed in the chat credential gate (#13404) * fix(desktop): treat ClinePass as OAuth-managed in chat credential gate ClinePass shares the Cline account OAuth credentials (its auth handler stores under the "cline" provider), so the webview never sees a plain API key for it. The chat pre-flight check only exempted cline/oca/ openai-codex, so switching to ClinePass while signed in via OAuth blocked with "Missing API key" even though the sidecar resolves the stored access token fine (which is why the CLI worked). Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * style: format helpers.test.ts with biome Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(ui): stack code block lines when streamdown lineNumbers is off (#13412) streamdown renders each Shiki token line as a bare inline span with no newline text between non-empty lines, and only applies its block line class when lineNumbers is on. With lineNumbers off (the desktop app's config) every multi-line fenced block collapsed into one run-on line. Make the direct line spans under code-block-body display: block in the shared markdown.css; empty lines keep their height via their lone "\n" child under white-space: pre. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): work summary undercounts wall time when pre-tool thinking attaches to the answer (#13413) * fix(desktop): anchor work summary duration on the answer row, not attached pre-tool reasoning The collapsed 'Worked for Xs' row undercounted wall time whenever a turn's assistant message contained thinking + tool_use with no narration text: the canonical projection emitted the reasoning-only row after the tool row (both stamped before the tool executed), the webview attached that row to the final answer, and collapseCompletedWork used the answer's earliest attached reasoning timestamp as the end anchor - excluding the entire tool execution (e.g. 'Worked for 5s' for a turn with an 8s command). - webview: end the work span at the answer row's own timestamp, clamped to the last collapsed row so a fallback answer bubble with a synthetic early timestamp cannot shrink the duration either - sidecar: flush pending thinking before a tool_use row so rehydrated transcripts keep the live-stream order (thinking before its tool call) and pre-tool reasoning no longer rides on the next answer Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): keep interleaved thinking between the tool calls it separates Address Greptile review: when one assistant message interleaves thinking between multiple tool_use blocks, each reasoning segment now projects at its own position (attached to a text row from its own segment when present, otherwise as its own row) instead of merging into the first reasoning row, which displayed later thinking before a tool call it actually followed. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): remove settings gear hover state while Account screen is open (#13408) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): don't show "No sessions found" while session history is still loading (#13414) * fix(desktop): don't show 'No sessions found' while session history is still loading Replace the isLoadingHistory flag with hasLoadedHistory, set only once the backend has actually answered a list_discovered_sessions request. The sidebar and Sessions view now keep their loading state until that first definitive response, so the empty-state copy can no longer appear while history is still being fetched (or while a failed fetch is being retried). Also retry a failed initial fetch on the 2s event cadence instead of stranding the UI until the 12s periodic poll, which is what stretched the misleading empty state to ~10 seconds after a webview reload when the websocket lost the race with the page load. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): stop history fast-retry from re-arming after hook unmount A failed initial fetch that settles after the hook unmounted could schedule a new retry timer after cleanup had already cleared the refs, leaving the abandoned hook polling the backend every 2s. Guard scheduleRefresh with a disposed ref set by the mount effect's cleanup. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix @ file mentions breaking on paths with spaces (#13391) * Fix @ file mentions breaking on paths with spaces Quote mentions generated by getFileMentionFromPath (Add to Cline / Fix / Explain / Improve commands) when the relative path contains spaces, so the mention regex no longer truncates the path at the first space. Also quote the path part of workspace-prefixed mentions (workspace:/path with spaces) inserted from the @ context menu, which previously bypassed quoting because the value does not start with '/'. Fixes #13338 Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix import ordering in mentions test (biome organize imports) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Reduce fix to minimal scope Revert the webview quoting refactor and extra tests; keep only the getFileMentionFromPath quoting fix with a single regression test. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Normalize mention paths to posix separators for Windows Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix code actions failing with "command not found" on VS Code 1.134 (#13402) * Fix @ file mentions breaking on paths with spaces Quote mentions generated by getFileMentionFromPath (Add to Cline / Fix / Explain / Improve commands) when the relative path contains spaces, so the mention regex no longer truncates the path at the first space. Also quote the path part of workspace-prefixed mentions (workspace:/path with spaces) inserted from the @ context menu, which previously bypassed quoting because the value does not start with '/'. Fixes #13338 Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix import ordering in mentions test (biome organize imports) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Reduce fix to minimal scope Revert the webview quoting refactor and extra tests; keep only the getFileMentionFromPath quoting fix with a single regression test. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Normalize mention paths to posix separators for Windows Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix code actions failing with 'command not found' on VS Code 1.134 Code action commands carried arguments (expandedRange, diagnostics), which routes them through VS Code's CommandsConverter cache. VS Code 1.134 disposes the cached entries before the clicked action executes, so every lightbulb action failed with 'Actual command not found, wanted to execute cline.addToChat'. Drop the arguments so the command id is passed through directly, and recover the context in the handler instead: getContextForCommand now expands an empty selection by 3 surrounding lines (matching the old provider behavior) and gathers document diagnostics intersecting the range when none are passed explicitly. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Scope gathered diagnostics to the selection/cursor Match the old CodeActionContext.diagnostics behavior: only include diagnostics intersecting the range the action was requested for, not the surrounding lines the text gets expanded to. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Desktop: unify Plugins, MCP, and Skills into one Plugins hub with a dedicated Marketplace page (#13411) * Unify desktop plugins, apps, MCP, and skills into one Plugins hub with a Browse directory mode Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Open the marketplace directory as a modal over the Plugins hub instead of swapping the page Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Rename directory to Marketplace: Browse Marketplace button, Marketplace modal title with icon, search placeholder Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Fix search input focus ring clipped by the Marketplace modal scroll container Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Address Greptile review: keep selected tag chip visible when its count drops to zero, and remount installed tab when a marketplace install completes after the modal closed Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Track marketplace modal mutation flag in a ref so a close click racing a queued render cannot skip the inventory remount Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Make Marketplace its own settings page under Customizations and restore Channels as a standalone page Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Remove icon from Marketplace page header for consistency with other settings pages * Notify mounted inventory views when the marketplace invalidates the cache so late install completions refresh the Plugins hub --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(desktop/ui): recommended and free model tiers in the composer model selector (#13410) * feat(ui): sectioned model picker support in SearchCombobox Adds option sections with headers, badges (NEW/Free pills), keyboard navigation (arrows/Home/End/Enter with active-row tracking and aria-activedescendant), substring match highlighting, a configurable panel width, a trigger chevron, and a cleaner borderless search row. All additions are backwards compatible; bumps @cline/ui to 0.2.0-next.6. * feat(desktop): recommended and free model tiers in the composer picker The composer's model selector showed raw provider/model ids and listed the entire catalog alphabetized by id. It now labels providers and models by display name and, for the cline provider, leads with the Recommended and Free tiers from the recommended-models feed (NEW/Free badges, descriptions) ahead of an All models section — matching the CLI's featured picker and the kanban selector. cline-pass gets Subscribed/Free tiers. A new list_cline_recommended_models sidecar command exposes @cline/core's fetchClineRecommendedModels (display-ready names, bundled offline fallback); feed ids resolve against the catalog with a unique-slug fallback for Vercel/OpenRouter alias spellings, and unresolvable entries are dropped rather than rendered unselectable. * fix(desktop): widen the provider trigger for display names Provider labels are now display names (e.g. "Cline Usage-Billing"), which truncated badly at max-w-28. * chore(desktop): drop unused featured-models test helper * style(desktop): align workspace/branch picker search rows with the model picker The composer's workspace/branch popover and the welcome screen's workspace and branch pickers used a boxed inner search shell that now clashed with the model picker's borderless search row sitting next to them. Behavior unchanged. * feat(ui): center the selected option when SearchCombobox opens Opening a long list previously scrolled the selection just into view at the panel edge; it now lands centered, and keyboard/hover navigation falls back to minimal nearest-edge scrolling. * style(desktop): picker row contrast, transparent search fields, centered open The workspace/branch pickers' rows had a nearly invisible surface-hover-lighter hover; rows now hover with surface-hover and mark the current entry with the accent background plus check, matching the model picker. The search inputs drop the Input base class's dark:bg-input/30 tint that rendered a gray box inside the panel in dark mode. Opening a picker now centers the current workspace/branch via a shared scroll helper instead of starting at the top of the list. * fix(ui): visible option hover/selected states and no scroll-jump on hover The option row stacked bg-transparent with the conditional state backgrounds; at equal specificity the later-sorted bg-transparent utility won, so hover/selected rows rendered with no background at all. The background classes are now mutually exclusive. Mouse-driven active-row changes also reused the keyboard scroll-into- view effect: hovering a row at the panel edge scrolled it into view, which moved the list under the cursor and re-triggered hover — an endless jump. Scroll mode is now per-source: center on open, nearest for keyboard/typing, none for hover. * fix(desktop): show only subscribed and free tiers in the cline-pass picker The ClinePass offer is exactly the feed's subscribed + free tiers, but stale bundled/cached catalog entries (e.g. a nemotron model) leaked into an "All models" tier. Match the CLI's featured picker: hide catalog leftovers, and only fall back to the full catalog when the subscribed bucket is empty so a subscriber is never limited to free models offline. * fix(ui/desktop): strengthen the selected-row highlight in light mode The selected row used the semantic accent surface (violet step 3), which is nearly white in light mode. SearchCombobox and the desktop workspace/branch pickers now highlight the selected/current row with accent step 4 (with a fallback to --accent), which reads clearly in both themes without touching the shared --accent token that shadcn hover states depend on. * fix(desktop): fit full provider display names in the composer trigger "Cline Usage-Billing" — the default provider — truncated to "Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56, which fits the longest built-in provider names. * style(ui/desktop): animate picker panels open like the shadcn dropdowns The thinking-effort Select (shadcn/Radix) animates open while the model/provider/workspace/branch pickers popped in instantly. All picker panels now share the same open treatment — 150ms fade + slight zoom, sliding from the trigger side. SearchCombobox uses a self-contained CSS keyframe (consumers may not ship tw-animate-css); the desktop's custom panels use the app's tw-animate utilities. Both respect prefers-reduced-motion. * chore(desktop): drop stale eslint-disable comments in picker search rows This repo lints with biome; the jsx-a11y/no-autofocus disables were inert leftovers. Flagged in review. * fix(ui): hand focus back to the combobox trigger on selection, close on Tab Selecting an option (Enter or click) unmounted the focused search input without a new focus target, dropping keyboard users' focus to <body> — only Escape restored it. And since the search input is the panel's only tabbable element, Tab always moved focus outside the component while leaving the popup open behind the new focus target. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): keep the composer model selection inside the picker's visible offer The active/remembered model was validated against the provider's full catalog while the picker can intentionally hide models (the ClinePass offer is exactly its subscribed/free tiers), so a stale remembered model could become the selection while being absent from the dropdown. Remembered and default selections (including on provider switch) now resolve against the picker's visible options, and an explicitly configured model that falls outside the offer stays active but is surfaced under a 'Current model' section so the selection is always visible and re-selectable. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK (#13415) * feat(ui): sectioned model picker support in SearchCombobox Adds option sections with headers, badges (NEW/Free pills), keyboard navigation (arrows/Home/End/Enter with active-row tracking and aria-activedescendant), substring match highlighting, a configurable panel width, a trigger chevron, and a cleaner borderless search row. All additions are backwards compatible; bumps @cline/ui to 0.2.0-next.6. * feat(desktop): recommended and free model tiers in the composer picker The composer's model selector showed raw provider/model ids and listed the entire catalog alphabetized by id. It now labels providers and models by display name and, for the cline provider, leads with the Recommended and Free tiers from the recommended-models feed (NEW/Free badges, descriptions) ahead of an All models section — matching the CLI's featured picker and the kanban selector. cline-pass gets Subscribed/Free tiers. A new list_cline_recommended_models sidecar command exposes @cline/core's fetchClineRecommendedModels (display-ready names, bundled offline fallback); feed ids resolve against the catalog with a unique-slug fallback for Vercel/OpenRouter alias spellings, and unresolvable entries are dropped rather than rendered unselectable. * fix(desktop): widen the provider trigger for display names Provider labels are now display names (e.g. "Cline Usage-Billing"), which truncated badly at max-w-28. * chore(desktop): drop unused featured-models test helper * style(desktop): align workspace/branch picker search rows with the model picker The composer's workspace/branch popover and the welcome screen's workspace and branch pickers used a boxed inner search shell that now clashed with the model picker's borderless search row sitting next to them. Behavior unchanged. * feat(ui): center the selected option when SearchCombobox opens Opening a long list previously scrolled the selection just into view at the panel edge; it now lands centered, and keyboard/hover navigation falls back to minimal nearest-edge scrolling. * style(desktop): picker row contrast, transparent search fields, centered open The workspace/branch pickers' rows had a nearly invisible surface-hover-lighter hover; rows now hover with surface-hover and mark the current entry with the accent background plus check, matching the model picker. The search inputs drop the Input base class's dark:bg-input/30 tint that rendered a gray box inside the panel in dark mode. Opening a picker now centers the current workspace/branch via a shared scroll helper instead of starting at the top of the list. * fix(ui): visible option hover/selected states and no scroll-jump on hover The option row stacked bg-transparent with the conditional state backgrounds; at equal specificity the later-sorted bg-transparent utility won, so hover/selected rows rendered with no background at all. The background classes are now mutually exclusive. Mouse-driven active-row changes also reused the keyboard scroll-into- view effect: hovering a row at the panel edge scrolled it into view, which moved the list under the cursor and re-triggered hover — an endless jump. Scroll mode is now per-source: center on open, nearest for keyboard/typing, none for hover. * fix(desktop): show only subscribed and free tiers in the cline-pass picker The ClinePass offer is exactly the feed's subscribed + free tiers, but stale bundled/cached catalog entries (e.g. a nemotron model) leaked into an "All models" tier. Match the CLI's featured picker: hide catalog leftovers, and only fall back to the full catalog when the subscribed bucket is empty so a subscriber is never limited to free models offline. * fix(ui/desktop): strengthen the selected-row highlight in light mode The selected row used the semantic accent surface (violet step 3), which is nearly white in light mode. SearchCombobox and the desktop workspace/branch pickers now highlight the selected/current row with accent step 4 (with a fallback to --accent), which reads clearly in both themes without touching the shared --accent token that shadcn hover states depend on. * fix(desktop): fit full provider display names in the composer trigger "Cline Usage-Billing" — the default provider — truncated to "Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56, which fits the longest built-in provider names. * style(ui/desktop): animate picker panels open like the shadcn dropdowns The thinking-effort Select (shadcn/Radix) animates open while the model/provider/workspace/branch pickers popped in instantly. All picker panels now share the same open treatment — 150ms fade + slight zoom, sliding from the trigger side. SearchCombobox uses a self-contained CSS keyframe (consumers may not ship tw-animate-css); the desktop's custom panels use the app's tw-animate utilities. Both respect prefers-reduced-motion. * chore(desktop): drop stale eslint-disable comments in picker search rows This repo lints with biome; the jsx-a11y/no-autofocus disables were inert leftovers. Flagged in review. * refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK Review feedback on the composer picker: tier joining should live where the SDK serves model lists so each client doesn't fetch and join the recommended-models feed itself (the CLI and now the desktop each did). ProviderModel gains description and featured ({tier, rank, tags}); getLocalProviderModels overlays the feed's recommended/free tiers onto cline models and subscribed/free onto cline-pass via applyClineFeaturedModels, matching feed ids through the Vercel/OpenRouter alias rules. The feed access is a new cached wrapper (getCachedClineRecommendedModels, 5-minute TTL, in-flight dedupe) — this path runs on every picker open, and the bundled offline fallback is cached too so offline users don't re-pay the 5s timeout per list. The desktop webview now reads tiers straight off the models: the list_cline_recommended_models sidecar command, the webview feed fetch, and its unique-slug alias matching are all deleted. toProviderModel also carries ModelInfo.description generally. * fix(ui): hand focus back to the combobox trigger on selection, close on Tab Selecting an option (Enter or click) unmounted the focused search input without a new focus target, dropping keyboard users' focus to <body> — only Escape restored it. And since the search input is the panel's only tabbable element, Tab always moved focus outside the component while leaving the popup open behind the new focus target. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): keep the composer model selection inside the picker's visible offer The active/remembered model was validated against the provider's full catalog while the picker can intentionally hide models (the ClinePass offer is exactly its subscribed/free tiers), so a stale remembered model could become the selection while being absent from the dropdown. Remembered and default selections (including on provider switch) now resolve against the picker's visible options, and an explicitly configured model that falls outside the offer stays active but is surfaced under a 'Current model' section so the selection is always visible and re-selectable. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(core): stamp featured tiers onto the provider catalog synchronously listLocalProviders deliberately skipped the feed overlay so the catalog never blocks on the network — but that left the composer's very first picker open after a cold boot rendering an untiered flat list until the per-provider fetch landed. Blocking was never required: stamp tiers from a synchronous peek at data already in memory (the cached live feed when fresh, else the bundled fallback, whose recommended ids resolve against the bundled cline catalog). The per-provider model-list path still refreshes with live feed data moments later. * fix(core): harden featured-tier matching and the feed cache reset Review findings on the tier overlay: Vendor-prefix mismatches now match by unambiguous id slug (two-pass, so a catalog carrying both spellings of a model stamps one row, and a slug shared by two feed entries stamps nothing) — the bundled fallback feed's vendor-prefixed ids can otherwise miss cline-free/-prefixed catalog entries, leaving them untiered in degraded mode. resetClineRecommendedModelsCacheForTests now bumps a generation so an in-flight feed request resolving after a reset cannot repopulate the cache it just cleared. --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(desktop): recommended-feed badges and descriptions in provider settings (#13416) * feat(ui): sectioned model picker support in SearchCombobox Adds option sections with headers, badges (NEW/Free pills), keyboard navigation (arrows/Home/End/Enter with active-row tracking and aria-activedescendant), substring match highlighting, a configurable panel width, a trigger chevron, and a cleaner borderless search row. All additions are backwards compatible; bumps @cline/ui to 0.2.0-next.6. * feat(desktop): recommended and free model tiers in the composer picker The composer's model selector showed raw provider/model ids and listed the entire catalog alphabetized by id. It now labels providers and models by display name and, for the cline provider, leads with the Recommended and Free tiers from the recommended-models feed (NEW/Free badges, descriptions) ahead of an All models section — matching the CLI's featured picker and the kanban selector. cline-pass gets Subscribed/Free tiers. A new list_cline_recommended_models sidecar command exposes @cline/core's fetchClineRecommendedModels (display-ready names, bundled offline fallback); feed ids resolve against the catalog with a unique-slug fallback for Vercel/OpenRouter alias spellings, and unresolvable entries are dropped rather than rendered unselectable. * fix(desktop): widen the provider trigger for display names Provider labels are now display names (e.g. "Cline Usage-Billing"), which truncated badly at max-w-28. * chore(desktop): drop unused featured-models test helper * style(desktop): align workspace/branch picker search rows with the model picker The composer's workspace/branch popover and the welcome screen's workspace and branch pickers used a boxed inner search shell that now clashed with the model picker's borderless search row sitting next to them. Behavior unchanged. * feat(ui): center the selected option when SearchCombobox opens Opening a long list previously scrolled the selection just into view at the panel edge; it now lands centered, and keyboard/hover navigation falls back to minimal nearest-edge scrolling. * style(desktop): picker row contrast, transparent search fields, centered open The workspace/branch pickers' rows had a nearly invisible surface-hover-lighter hover; rows now hover with surface-hover and mark the current entry with the accent background plus check, matching the model picker. The search inputs drop the Input base class's dark:bg-input/30 tint that rendered a gray box inside the panel in dark mode. Opening a picker now centers the current workspace/branch via a shared scroll helper instead of starting at the top of the list. * fix(ui): visible option hover/selected states and no scroll-jump on hover The option row stacked bg-transparent with the conditional state backgrounds; at equal specificity the later-sorted bg-transparent utility won, so hover/selected rows rendered with no background at all. The background classes are now mutually exclusive. Mouse-driven active-row changes also reused the keyboard scroll-into- view effect: hovering a row at the panel edge scrolled it into view, which moved the list under the cursor and re-triggered hover — an endless jump. Scroll mode is now per-source: center on open, nearest for keyboard/typing, none for hover. * fix(desktop): show only subscribed and free tiers in the cline-pass picker The ClinePass offer is exactly the feed's subscribed + free tiers, but stale bundled/cached catalog entries (e.g. a nemotron model) leaked into an "All models" tier. Match the CLI's featured picker: hide catalog leftovers, and only fall back to the full catalog when the subscribed bucket is empty so a subscriber is never limited to free models offline. * fix(ui/desktop): strengthen the selected-row highlight in light mode The selected row used the semantic accent surface (violet step 3), which is nearly white in light mode. SearchCombobox and the desktop workspace/branch pickers now highlight the selected/current row with accent step 4 (with a fallback to --accent), which reads clearly in both themes without touching the shared --accent token that shadcn hover states depend on. * fix(desktop): fit full provider display names in the composer trigger "Cline Usage-Billing" — the default provider — truncated to "Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56, which fits the longest built-in provider names. * style(ui/desktop): animate picker panels open like the shadcn dropdowns The thinking-effort Select (shadcn/Radix) animates open while the model/provider/workspace/branch pickers popped in instantly. All picker panels now share the same open treatment — 150ms fade + slight zoom, sliding from the trigger side. SearchCombobox uses a self-contained CSS keyframe (consumers may not ship tw-animate-css); the desktop's custom panels use the app's tw-animate utilities. Both respect prefers-reduced-motion. * chore(desktop): drop stale eslint-disable comments in picker search rows This repo lints with biome; the jsx-a11y/no-autofocus disables were inert leftovers. Flagged in review. * refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK Review feedback on the composer picker: tier joining should live where the SDK serves model lists so each client doesn't fetch and join the recommended-models feed itself (the CLI and now the desktop each did). ProviderModel gains description and featured ({tier, rank, tags}); getLocalProviderModels overlays the feed's recommended/free tiers onto cline models and subscribed/free onto cline-pass via applyClineFeaturedModels, matching feed ids through the Vercel/OpenRouter alias rules. The feed access is a new cached wrapper (getCachedClineRecommendedModels, 5-minute TTL, in-flight dedupe) — this path runs on every picker open, and the bundled offline fallback is cached too so offline users don't re-pay the 5s timeout per list. The desktop webview now reads tiers straight off the models: the list_cline_recommended_models sidecar command, the webview feed fetch, and its unique-slug alias matching are all deleted. toProviderModel also carries ModelInfo.description generally. * feat(desktop): recommended-feed badges and descriptions in provider settings Review suggestion on #13410: the provider settings page has room for more model detail than the composer's picker. The cline/cline-pass provider cards now refresh their model list through list_provider_models (the catalog snapshot deliberately skips the recommended-feed overlay so the startup catalog fetch never blocks on the feed) and render Recommended/Free tier badges plus feed tags (NEW) next to the model name, with the model description underneath. The refreshed list also surfaces the live entries instead of the bundled snapshot. * fix(ui): hand focus back to the combobox trigger on selection, close on Tab Selecting an option (Enter or click) unmounted the focused search input without a new focus target, dropping keyboard users' focus to <body> — only Escape restored it. And since the search input is the panel's only tabbable element, Tab always moved focus outside the component while leaving the popup open behind the new focus target. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): keep the composer model selection inside the picker's visible offer The active/remembered model was validated against the provider's full catalog while the picker can intentionally hide models (the ClinePass offer is exactly its subscribed/free tiers), so a stale remembered model could become the selection while being absent from the dropdown. Remembered and default selections (including on provider switch) now resolve against the picker's visible options, and an explicitly configured model that falls outside the offer stays active but is surfaced under a 'Current model' section so the selection is always visible and re-selectable. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(desktop): scope the settings featured model list to its provider and revision The fetched featured list was unscoped component state: switching between cline and cline-pass reused the component instance, so the previous provider's models stayed visible while the new request was pending (or forever, when it failed), and the retained copy shadowed later provider.modelList updates — adding a second custom model submitted the stale list as the complete configuration and dropped the first addition. The fetched list now only applies to the provider and modelList revision it was fetched for (falling back to the catalog snapshot otherwise and refetching on membership changes), and add-model submits the union of the displayed and configured ids so an update can never silently unconfigure existing entries. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * feat(core): stamp featured tiers onto the provider catalog synchronously listLocalProviders deliberately skipped the feed overlay so the catalog never blocks on the network — but that left the composer's very first picker open after a cold boot rendering an untiered flat list until the per-provider fetch landed. Blocking was never required: stamp tiers from a synchronous peek at data already in memory (the cached live feed when fresh, else the bundled fallback, whose recommended ids resolve against the bundled cline catalog). The per-provider model-list path still refreshes with live feed data moments later. * fix(core): harden featured-tier matching and the feed cache reset Review findings on the tier overlay: Vendor-prefix mismatches now match by unambiguous id slug (two-pass, so a catalog carrying both spellings of a model stamps one row, and a slug shared by two feed entries stamps nothing) — the bundled fallback feed's vendor-prefixed ids can otherwise miss cline-free/-prefixed catalog entries, leaving them untiered in degraded mode. resetClineRecommendedModelsCacheForTests now bumps a generation so an in-flight feed request resolving after a reset cannot repopulate the cache it just cleared. --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * fix(ui): update packed-Tailwind smoke contract for the picker's max-h-64 (#13421) The ui-publish smoke check pins a set of Tailwind candidates the packed sources must emit; #13410 grew the SearchCombobox options list from max-h-56 to max-h-64, so the publish run failed on the stale candidate. All other pinned candidates verified against the current sources. * chore(desktop): cut 0.0.15-beta.1 * chore(desktop): reconcile Cargo.lock with merged dependencies * feat(desktop): refresh app icons and branding (#13400) * chore(desktop): restore ai dependency and refresh lockfile * chore(desktop): align beta cloud copy with the Cline rename * chore(desktop): merge refreshed app icons (#13400) and dedupe changelog --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> Co-authored-by: JasmineLCY <38378321+JasmineLCY@users.noreply.github.com> Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com> Co-authored-by: Max <maxpaulus43@gmail.com> Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bee <abeatrix@users.noreply.github.com> Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com> Co-authored-by: Ara <arafat.da.khan@gmail.com> Co-authored-by: Haley Park <haleypark.design@gmail.com>
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
@@ -14,8 +14,8 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
|
||||
## Release contract
|
||||
|
||||
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
|
||||
@@ -206,6 +206,8 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
|
||||
@@ -213,6 +215,32 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
|
||||
# and link out to the full notes. The GitHub release body stays whole.
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -248,7 +276,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline Code"
|
||||
PRODUCT="Cline"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Code Beta"
|
||||
PRODUCT="Cline Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
@@ -506,6 +506,33 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body and updater manifest stay whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
@@ -580,14 +607,14 @@ jobs:
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline Code desktop beta (auto-update feed)" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
else
|
||||
gh release create "$FEED" \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--title "Cline desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
@@ -601,7 +628,7 @@ jobs:
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
@@ -612,16 +639,16 @@ jobs:
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -150,14 +150,12 @@ jobs:
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -492,6 +490,35 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
{
|
||||
echo "slack_content<<CHANGELOG_EOF"
|
||||
echo "$SLACK_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
@@ -547,7 +574,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -58,14 +58,12 @@ jobs:
|
||||
with:
|
||||
ref: legacy-extension
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -266,6 +264,33 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -295,7 +320,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -234,6 +234,33 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -303,7 +330,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -80,8 +80,9 @@ jobs:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
# Nothing in this job uses OIDC, so it does not need an id-token
|
||||
# permission.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
@@ -93,6 +94,9 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache keys below are exact-match only (no restore-keys prefix
|
||||
# fallbacks); a miss just means a cold install, which is acceptable.
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
@@ -100,8 +104,6 @@ jobs:
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -110,8 +112,6 @@ jobs:
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
@@ -123,8 +123,6 @@ jobs:
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
|
||||
@@ -282,6 +282,33 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -333,7 +360,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockHubClientCommand = vi.hoisted(() => vi.fn());
|
||||
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
@@ -16,7 +17,17 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
NodeHubClient: class {
|
||||
command = mockHubClientCommand;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
mockNodeHubClientCtor(options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
close(): void {}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
@@ -74,7 +85,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -96,18 +107,21 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.list",
|
||||
payload: {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
},
|
||||
},
|
||||
// Schedule commands are workspace-scoped: the hub client must register
|
||||
// with a workspace context (and the hub auth token) before commanding.
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
authToken: "test-token",
|
||||
}),
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
@@ -115,7 +129,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -137,7 +151,7 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
expect(mockHubClientCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,7 +171,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -189,15 +203,19 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
cwd: "/tmp/workspace",
|
||||
authToken: "test-token",
|
||||
}),
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -215,7 +233,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -246,14 +264,11 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -292,7 +307,7 @@ describe("runScheduleCommand create", () => {
|
||||
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();
|
||||
expect(mockHubClientCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -300,7 +315,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
@@ -339,21 +354,17 @@ describe("runScheduleCommand create", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -370,7 +381,7 @@ describe("runScheduleCommand import", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -411,16 +422,12 @@ describe("runScheduleCommand import", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
},
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -444,7 +451,7 @@ describe("runScheduleCommand export", () => {
|
||||
prompt: "review status",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
};
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
@@ -484,14 +491,9 @@ describe("runScheduleCommand export", () => {
|
||||
|
||||
const written = await readFile(targetPath, "utf8");
|
||||
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.get",
|
||||
payload: { scheduleId: "sched_abc" },
|
||||
},
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
|
||||
scheduleId: "sched_abc",
|
||||
});
|
||||
} finally {
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
@@ -507,7 +509,7 @@ describe("runScheduleCommand export", () => {
|
||||
name: "Weekly Sync",
|
||||
cronPattern: "0 9 * * 1",
|
||||
};
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
sendHubCommand,
|
||||
NodeHubClient,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
@@ -11,28 +11,51 @@ import {
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export class HubScheduleClient {
|
||||
private hub: Promise<NodeHubClient> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
private readonly url: string,
|
||||
private readonly workspaceRoot: string,
|
||||
private readonly authToken?: string,
|
||||
) {}
|
||||
|
||||
close(): void {}
|
||||
close(): void {
|
||||
const hub = this.hub;
|
||||
this.hub = undefined;
|
||||
void hub?.then((client) => client.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Schedule commands are authorized against the workspace bound to the
|
||||
// connection's client registration, so all commands must share one
|
||||
// registered connection instead of fire-and-forget envelopes.
|
||||
private connectedHub(): Promise<NodeHubClient> {
|
||||
this.hub ??= (async () => {
|
||||
const client = new NodeHubClient({
|
||||
url: this.url,
|
||||
clientType: "cli-schedule",
|
||||
displayName: "Cline CLI scheduler",
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
authToken: this.authToken,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
client.close();
|
||||
this.hub = undefined;
|
||||
throw error;
|
||||
}
|
||||
return client;
|
||||
})();
|
||||
return this.hub;
|
||||
}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await sendHubCommand(this.endpoint, {
|
||||
clientId: "cline-schedule",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
const client = await this.connectedHub();
|
||||
const reply = await client.command(command as never, payload);
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -97,6 +120,7 @@ export class LocalScheduleClient {
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
private readonly commands = new HubScheduleCommandService(this.service);
|
||||
constructor(private readonly workspaceRoot: string) {}
|
||||
|
||||
close(): void {
|
||||
void this.service.dispose();
|
||||
@@ -106,12 +130,21 @@ export class LocalScheduleClient {
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await this.commands.handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
const reply = await this.commands.handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-schedule-local",
|
||||
workspaceContext: {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
@@ -185,24 +218,27 @@ export async function ensureSchedulerHub(
|
||||
if (!address?.trim()) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const requestedEndpoint = parseHubEndpointOverride(address);
|
||||
const { url: hubUrl } = await ensureCliHubServer(
|
||||
const { url: hubUrl, authToken } = await ensureCliHubServer(
|
||||
workspaceRoot,
|
||||
requestedEndpoint,
|
||||
);
|
||||
const endpoint = parseHubEndpointOverride(hubUrl);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(endpoint),
|
||||
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function handleDesktopCommand(
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
|
||||
@@ -27,13 +27,20 @@ function getCommands(): HubScheduleCommandService {
|
||||
async function clientCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await getCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
});
|
||||
const reply = await getCommands().handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-hub-schedules",
|
||||
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -70,14 +77,17 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<unknown> {
|
||||
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
|
||||
clientCommand(hubCommand, payload, workspaceRoot);
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
commandHub("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
commandHub("schedule.active"),
|
||||
commandHub("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
@@ -88,7 +98,7 @@ export async function handleRoutineScheduleCommand(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
const reply = await commandHub("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
@@ -115,7 +125,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
const created = await commandHub("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
@@ -149,7 +159,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
const reply = await commandHub("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
@@ -180,25 +190,25 @@ export async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
const reply = await commandHub("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
const reply = await commandHub("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
const existing = await commandHub("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
const reply = await commandHub("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
const reply = await commandHub("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
|
||||
@@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" };
|
||||
}
|
||||
return {
|
||||
key: client.clientId,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# Cline Code Desktop Changelog
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.14-beta.1
|
||||
## 0.0.15-beta.1
|
||||
|
||||
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
|
||||
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
|
||||
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
|
||||
- Onboarding now includes a GitHub integration step.
|
||||
- Early proof of concept for running sessions in SSH remote environments.
|
||||
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
|
||||
- Beta: hand off a local session to Cline Cloud with `/handoff` — the conversation, attached images, and an optional follow-up command move to a cloud workspace that keeps working after you close the app. A preflight confirms the repository, branch, and commit are pushed; the composer shows handoff progress and finishes with a receipt linking to the cloud session.
|
||||
- Beta: Cloud now lives in the existing Local / Remote environment menu. It is selectable when the Cloud sessions feature flag is on and shows as "Coming soon" otherwise. The separate Local / Cloud toggle is gone; repository and branch controls are unchanged.
|
||||
- If a handoff is interrupted — the app restarts, the network drops, or the branch moves mid-transfer — reopening the session recovers or cleanly retries it, and your typed draft and attachments are restored.
|
||||
- Includes the 0.0.14 stable release and everything on main since: the unified Plugins hub with a Marketplace page, recommended and free model tiers in the model picker, scheduled tasks for agents, and the app rename to "Cline" (this beta is now "Cline Beta").
|
||||
|
||||
## 0.0.14
|
||||
|
||||
@@ -33,6 +31,15 @@
|
||||
- Fixed misaligned columns in the Usage table, and added a See More link to the full usage dashboard.
|
||||
- Fixed routine dialog dropdowns not responding to mouse clicks.
|
||||
|
||||
## 0.0.14-beta.1
|
||||
|
||||
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
|
||||
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
|
||||
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
|
||||
- Onboarding now includes a GitHub integration step.
|
||||
- Early proof of concept for running sessions in SSH remote environments.
|
||||
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
|
||||
|
||||
## 0.0.13
|
||||
|
||||
- Added an app font size setting. A slider in Settings scales the interface, and your size is applied before the window paints, so launching no longer flashes at the old size first.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Desktop Experimental Branch & Beta Channel
|
||||
|
||||
How experimental desktop features are developed on the `desktop-experimental`
|
||||
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
|
||||
branch, shipped to users as **Cline Beta**, and graduated into `main`.
|
||||
The release mechanics (workflow internals, secrets) live in
|
||||
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
|
||||
and the `publish-desktop` skill
|
||||
@@ -12,8 +12,8 @@ this doc is the process.
|
||||
|
||||
The beta is a **separate app**, not a mode of the stable app:
|
||||
|
||||
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline Code` / `bot.cline.app`) — set by
|
||||
- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline` / `bot.cline.app`) — set by
|
||||
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
|
||||
is layered over `tauri.release.conf.json` at build time.
|
||||
- Both apps install and run **side by side**, so people can compare beta
|
||||
|
||||
@@ -6,8 +6,9 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin
|
||||
|
||||
From `apps/examples/desktop-app/`:
|
||||
|
||||
- `bun run dev:web` - Next.js UI only (`http://localhost:3125`)
|
||||
- `bun run dev:sidecar` - sidecar backend only
|
||||
- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential
|
||||
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build` - build web assets
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
@@ -108,7 +109,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
There is also a beta channel ("Cline Code Beta", a separate app that installs
|
||||
There is also a beta channel ("Cline Beta", a separate app that installs
|
||||
side by side with stable) cut from the `desktop-experimental` branch and
|
||||
served by the rolling `desktop-beta` release — the same never-delete rule
|
||||
applies to it. The experimental-branch process and beta release flow live in
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.14-beta.1",
|
||||
"version": "0.0.15-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"predev:headless": "bun run build:ui",
|
||||
"dev:headless": "bun run scripts/dev-headless.ts",
|
||||
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
@@ -36,6 +38,7 @@
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"ai": "^7.0.58",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
@@ -71,7 +74,6 @@
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"ai": "^7.0.58",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const approvalToken = randomUUID();
|
||||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||||
|
||||
async function reserveAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to reserve a sidecar port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function spawn(command: string[], env: Record<string, string>) {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd: import.meta.dir + "/..",
|
||||
env: { ...process.env, ...env },
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function stopChildren(): void {
|
||||
for (const child of children) {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", stopChildren);
|
||||
process.on("SIGTERM", stopChildren);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sidecarPort = await reserveAvailablePort();
|
||||
const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`;
|
||||
const sidecar = spawn(["bun", "run", "sidecar/index.ts"], {
|
||||
CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken,
|
||||
CLINE_SIDECAR_PORT: String(sidecarPort),
|
||||
});
|
||||
const web = spawn(
|
||||
["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"],
|
||||
{ NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint },
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([sidecar.exited, web.exited]);
|
||||
stopChildren();
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -121,7 +121,7 @@ const main = () => {
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline Code v${version}`;
|
||||
: `Cline v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
|
||||
@@ -15,7 +15,7 @@ const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_NAME = "Cline";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
|
||||
@@ -53,7 +53,7 @@ const sessionManager = await ClineCore.create({
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
@@ -195,7 +195,8 @@ provider credentials remain in the sidecar.
|
||||
## Dev Workflow
|
||||
|
||||
```bash
|
||||
bun run dev:sidecar # Start sidecar on port 3126
|
||||
bun run dev:web # Start Next.js on port 3125
|
||||
bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential
|
||||
bun run dev:sidecar # Start only the sidecar (no browser approval surface)
|
||||
bun run dev:web # Start only Next.js (no authenticated approval connection)
|
||||
bun run dev # Both concurrently
|
||||
```
|
||||
|
||||
@@ -2597,7 +2597,7 @@ export class CloudSessionManager {
|
||||
url: toWebSocketUrl(this.options.apiBaseUrl, outerSessionId),
|
||||
clientId: `code-cloud-${outerSessionId}`,
|
||||
clientType: "code-cloud-sidecar",
|
||||
displayName: "Cline Code cloud session",
|
||||
displayName: "Cline cloud session",
|
||||
workspaceRoot: CLOUD_WORKSPACE_ROOT,
|
||||
cwd: CLOUD_WORKSPACE_ROOT,
|
||||
resolveConnectionHeaders: async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createUserInstructionConfigService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
fetchClineRecommendedModels,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
listHookConfigFiles,
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
findSessionRuntimeBinding,
|
||||
getRuntimeBinding,
|
||||
resolveSidecarAskQuestion,
|
||||
sendEventToClient,
|
||||
} from "./context";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
@@ -145,6 +147,7 @@ import type {
|
||||
ChatSessionCommandRequest,
|
||||
JsonRecord,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
import { pickWorkspaceDirectory } from "./workspace-picker";
|
||||
@@ -1111,6 +1114,43 @@ async function handleRoutineScheduleCommand(
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agenda task queue helpers (in-process via shared hub server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGENDA_TASK_COMMANDS = new Set([
|
||||
"task.create",
|
||||
"task.list",
|
||||
"task.get",
|
||||
"task.update",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.get",
|
||||
"task.automation.set",
|
||||
]);
|
||||
|
||||
const AGENDA_TASK_EXECUTION_COMMANDS = new Set([
|
||||
"task.create",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.set",
|
||||
]);
|
||||
|
||||
async function handleAgendaTaskCommand(
|
||||
ctx: SidecarContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command(command as never, args);
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return reply.payload ?? {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User instruction config listing through the core config service.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1519,7 +1559,7 @@ export async function handleCommand(
|
||||
ctx: SidecarContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
options?: { connection?: object },
|
||||
options?: { connection?: SidecarWebSocketClient },
|
||||
): Promise<unknown> {
|
||||
// ── SSH remote environments ──────────────────────────────────────
|
||||
if (command === "list_remote_environments") {
|
||||
@@ -1856,8 +1896,16 @@ export async function handleCommand(
|
||||
// ── Tool approvals (in-memory) ────────────────────────────────────
|
||||
if (command === "poll_tool_approvals") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
const connection = options?.connection;
|
||||
if (!connection?.data?.canApproveTools) {
|
||||
throw new Error("tool approvals require a trusted desktop connection");
|
||||
}
|
||||
return Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.item.sessionId === sessionId)
|
||||
.filter(
|
||||
(a) =>
|
||||
(!a.owner || a.owner === connection) &&
|
||||
a.item.sessionId === sessionId,
|
||||
)
|
||||
.map((a) => a.item);
|
||||
}
|
||||
if (command === "respond_tool_approval") {
|
||||
@@ -1866,20 +1914,36 @@ export async function handleCommand(
|
||||
if (!sessionId || !requestId) {
|
||||
throw new Error("sessionId and requestId are required");
|
||||
}
|
||||
const pending = ctx.pendingApprovals.get(requestId);
|
||||
if (pending) {
|
||||
await pending.resolve({
|
||||
approved: Boolean(args?.approved),
|
||||
...(typeof args?.reason === "string" && args.reason.trim().length > 0
|
||||
? { reason: args.reason.trim() }
|
||||
: {}),
|
||||
});
|
||||
const connection = options?.connection;
|
||||
if (!connection?.data?.canApproveTools) {
|
||||
throw new Error("tool approvals require a trusted desktop connection");
|
||||
}
|
||||
const pending = ctx.pendingApprovals.get(requestId);
|
||||
// Ownerless approvals are cloud-session relays: any trusted surface may
|
||||
// answer them (the pod outlives individual desktop connections).
|
||||
if (!pending || (pending.owner && pending.owner !== connection)) {
|
||||
throw new Error("tool approval does not belong to this connection");
|
||||
}
|
||||
if (pending.item.sessionId !== sessionId) {
|
||||
throw new Error("tool approval does not belong to this session");
|
||||
}
|
||||
// Cloud approvals resolve asynchronously (they relay the answer to the
|
||||
// pod) and may throw; keep the entry pending if the relay fails.
|
||||
await pending.resolve({
|
||||
approved: Boolean(args?.approved),
|
||||
...(typeof args?.reason === "string" && args.reason.trim().length > 0
|
||||
? { reason: args.reason.trim() }
|
||||
: {}),
|
||||
});
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
const remaining = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.item.sessionId === sessionId)
|
||||
.filter(
|
||||
(a) =>
|
||||
(!a.owner || a.owner === connection) &&
|
||||
a.item.sessionId === sessionId,
|
||||
)
|
||||
.map((a) => a.item);
|
||||
broadcastEvent(ctx, "tool_approval_state", {
|
||||
sendEventToClient(ctx, connection, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: remaining,
|
||||
});
|
||||
@@ -2265,6 +2329,11 @@ export async function handleCommand(
|
||||
{ loadLatest: providerId === "cline" },
|
||||
);
|
||||
}
|
||||
if (command === "list_cline_recommended_models") {
|
||||
// Tiered picker data (recommended / free / clinePass) with
|
||||
// display-ready names; falls back to a bundled list offline.
|
||||
return await fetchClineRecommendedModels();
|
||||
}
|
||||
if (command === "create_mode_session") {
|
||||
const mode = ProviderSessionModeSchema.parse(args?.mode);
|
||||
const manager = createDesktopProviderSettingsManager();
|
||||
@@ -2882,6 +2951,17 @@ export async function handleCommand(
|
||||
return await handleRoutineScheduleCommand(ctx, command, args);
|
||||
}
|
||||
|
||||
// ── Agenda task queue ─────────────────────────────────────────────
|
||||
if (AGENDA_TASK_COMMANDS.has(command)) {
|
||||
if (
|
||||
AGENDA_TASK_EXECUTION_COMMANDS.has(command) &&
|
||||
!options?.connection?.data?.canApproveTools
|
||||
) {
|
||||
throw new Error("task execution requires a trusted desktop connection");
|
||||
}
|
||||
return await handleAgendaTaskCommand(ctx, command, args);
|
||||
}
|
||||
|
||||
// ── User instruction configs ──────────────────────────────────────
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx);
|
||||
|
||||
@@ -15,6 +15,11 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
const updateCapabilitiesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@ai-sdk/provider-utils", () => ({
|
||||
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -40,6 +45,7 @@ vi.mock("@cline/core", async () => {
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
updateCapabilities = updateCapabilitiesMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
};
|
||||
@@ -69,6 +75,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
updateCapabilitiesMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
@@ -78,6 +85,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
updateCapabilitiesMock.mockResolvedValue(undefined);
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
@@ -85,7 +93,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers Code App capability factory with core", async () => {
|
||||
it("registers the desktop capability factory with core", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
@@ -107,7 +115,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -118,7 +126,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -521,7 +529,11 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -627,7 +639,11 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -640,7 +656,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hub: expect.objectContaining({
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -659,9 +675,12 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
|
||||
expect(approval).toBeInstanceOf(Promise);
|
||||
const pending = await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
const pending = await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
expect(pending).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
@@ -687,15 +706,32 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
|
||||
const [{ requestId }] = pending as Array<{ requestId: string }>;
|
||||
await handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "sess-1",
|
||||
requestId,
|
||||
approved: true,
|
||||
});
|
||||
const untrustedClient = { send: vi.fn() };
|
||||
ctx.wsClients.add(untrustedClient);
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: untrustedClient },
|
||||
),
|
||||
).rejects.toThrow("trusted desktop connection");
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await expect(approval).resolves.toEqual({ approved: true });
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -703,6 +739,13 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
// Cloud-session approvals are relayed from a pod without a local
|
||||
// owner; any trusted surface may answer them.
|
||||
ctx.pendingApprovals.set("cloud-approval", {
|
||||
item: {
|
||||
requestId: "cloud-approval",
|
||||
@@ -717,19 +760,262 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "ses-cloud",
|
||||
requestId: "cloud-approval",
|
||||
approved: true,
|
||||
}),
|
||||
handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{
|
||||
sessionId: "ses-cloud",
|
||||
requestId: "cloud-approval",
|
||||
approved: true,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).rejects.toThrow("hub disconnected");
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "ses-cloud",
|
||||
}),
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "ses-cloud" },
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]);
|
||||
});
|
||||
|
||||
it("rejects and removes an approval when initial delivery fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const failedClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(failedClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(failedClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an owned approval when a later broadcast fails", async () => {
|
||||
const {
|
||||
broadcastEvent,
|
||||
createSidecarContext,
|
||||
createSidecarRuntimeCapabilities,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
|
||||
broadcastEvent(ctx, "task.updated", { taskId: "task-1" });
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects sibling approvals when a targeted state update fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
const capabilities = createSidecarRuntimeCapabilities(ctx);
|
||||
const request = (toolCallId: string) =>
|
||||
capabilities.requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId,
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
const firstApproval = request("tool-call-1");
|
||||
const siblingApproval = request("tool-call-2");
|
||||
const [{ requestId }] = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await expect(firstApproval).resolves.toEqual({ approved: true });
|
||||
await expect(siblingApproval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes approval readiness updates and publishes the latest state", async () => {
|
||||
const {
|
||||
createSidecarContext,
|
||||
initializeSessionManager,
|
||||
syncSidecarApprovalReadiness,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
updateCapabilitiesMock.mockReset();
|
||||
|
||||
let finishDisconnectedUpdate: (() => void) | undefined;
|
||||
updateCapabilitiesMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDisconnectedUpdate = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const disconnected = syncSidecarApprovalReadiness(ctx);
|
||||
await vi.waitFor(() =>
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([]),
|
||||
);
|
||||
ctx.wsClients.add({
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
});
|
||||
const connected = syncSidecarApprovalReadiness(ctx);
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishDisconnectedUpdate?.();
|
||||
await Promise.all([disconnected, connected]);
|
||||
expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards Hub-owned task session approvals to the live desktop", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
let onHubEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
subscribeMock.mockImplementation((handler) => {
|
||||
onHubEvent = handler;
|
||||
return () => {};
|
||||
});
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
onHubEvent?.({
|
||||
event: "approval.requested",
|
||||
sessionId: "task-session-1",
|
||||
payload: {
|
||||
approvalId: "hub-approval-1",
|
||||
agendaTaskId: "task-1",
|
||||
agentId: "task-agent-1",
|
||||
conversationId: "task-conversation-1",
|
||||
iteration: 2,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "write_to_file",
|
||||
inputJson: JSON.stringify({ path: "src/a.ts" }),
|
||||
policy: { autoApprove: false },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1));
|
||||
const pendingItems = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "task-session-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
const pending = pendingItems[0];
|
||||
if (!pending) throw new Error("expected a pending task approval");
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{
|
||||
sessionId: "task-session-1",
|
||||
requestId: pending.requestId,
|
||||
approved: true,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hubCommandMock).toHaveBeenCalledWith(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId: "hub-approval-1",
|
||||
approved: true,
|
||||
reason: undefined,
|
||||
},
|
||||
"task-session-1",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -754,6 +1040,115 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proxies Agenda task commands through the connected shared Hub", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const task = {
|
||||
taskId: "task-1",
|
||||
title: "Review the PR",
|
||||
status: "pending_approval",
|
||||
};
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { tasks: [task] },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
}),
|
||||
).resolves.toEqual({ tasks: [task] });
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
});
|
||||
|
||||
hubCommandMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { task: { ...task, status: "in_progress", revision: 4 } },
|
||||
});
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"task.run",
|
||||
{
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
task: { ...task, status: "in_progress", revision: 4 },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.run", {
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"task.create",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.set",
|
||||
])("rejects untrusted %s commands before they reach the shared Hub", async (command) => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const untrustedClient = {
|
||||
data: { canApproveTools: false },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, command, {}, { connection: untrustedClient }),
|
||||
).rejects.toThrow("task execution requires a trusted desktop connection");
|
||||
expect(hubCommandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards Hub task events that do not have a session", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() } as never);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readEvents(ctx)).toEqual([
|
||||
{
|
||||
type: "event",
|
||||
event: {
|
||||
name: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { type AgentEvent, isGeneratedMedia } from "@cline/shared";
|
||||
import {
|
||||
type AgentEvent,
|
||||
HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
isGeneratedMedia,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
@@ -30,6 +34,7 @@ import type {
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
|
||||
@@ -38,6 +43,7 @@ const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -61,10 +67,81 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
client.send(encoded);
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendEventToClient(
|
||||
ctx: SidecarContext,
|
||||
client: SidecarWebSocketClient,
|
||||
name: string,
|
||||
payload: unknown,
|
||||
): boolean {
|
||||
try {
|
||||
client.send(encodeSidecarEvent(name, payload));
|
||||
return true;
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSidecarToolApprovalsForOwner(
|
||||
ctx: SidecarContext,
|
||||
owner: SidecarWebSocketClient,
|
||||
): void {
|
||||
for (const [requestId, pending] of ctx.pendingApprovals) {
|
||||
if (pending.owner !== owner) continue;
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
pending.resolve({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncSidecarApprovalReadiness(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
|
||||
const update = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
// The approval capability rides on the shared local hub observer; the
|
||||
// multi-environment refactor keeps that client on the local binding.
|
||||
const hubClient =
|
||||
ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
approvalReadinessUpdates.set(ctx, update);
|
||||
return update.finally(() => {
|
||||
if (approvalReadinessUpdates.get(ctx) === update) {
|
||||
approvalReadinessUpdates.delete(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Session log appends are chained per session so writes stay ordered, but
|
||||
// they run asynchronously: a synchronous write per streamed token would stall
|
||||
// the sidecar event loop (and therefore every pending UI command) under load.
|
||||
@@ -683,6 +760,15 @@ function requestSidecarToolApproval(
|
||||
ctx: SidecarContext,
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> {
|
||||
const owner = [...ctx.wsClients].find(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
);
|
||||
if (!owner) {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
reason: "No trusted desktop approval surface is connected",
|
||||
});
|
||||
}
|
||||
return new Promise<ToolApprovalResult>((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const pending: PendingToolApproval = {
|
||||
@@ -697,16 +783,25 @@ function requestSidecarToolApproval(
|
||||
agentId: request.agentId,
|
||||
conversationId: request.conversationId,
|
||||
},
|
||||
owner,
|
||||
resolve,
|
||||
};
|
||||
ctx.pendingApprovals.set(requestId, pending);
|
||||
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((approval) => approval.item.sessionId === request.sessionId)
|
||||
.filter(
|
||||
(approval) =>
|
||||
approval.owner === owner &&
|
||||
approval.item.sessionId === request.sessionId,
|
||||
)
|
||||
.map((approval) => approval.item);
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
});
|
||||
if (
|
||||
!sendEventToClient(ctx, owner, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
})
|
||||
) {
|
||||
cancelSidecarToolApprovalsForOwner(ctx, owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -719,6 +814,25 @@ export function handleHubLiveEvent(
|
||||
},
|
||||
options: { relayRawAssistantText?: boolean } = {},
|
||||
): void {
|
||||
if (event.event === "approval.requested") {
|
||||
if (typeof event.payload?.agendaTaskId !== "string") return;
|
||||
void handleHubApprovalRequest(ctx, event).catch((error) => {
|
||||
ctx.logger?.error?.("Hub task approval forwarding failed", { error });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Task lifecycle events are Hub-wide invalidations and usually do not have a
|
||||
// session yet (pending and approved tasks explicitly predate their session).
|
||||
// Forward them before the session-only live-chat projection below so Agenda
|
||||
// surfaces stay current without polling.
|
||||
if (event.event.startsWith("task.")) {
|
||||
sendEvent(ctx, event.event, {
|
||||
...(event.payload ?? {}),
|
||||
...(event.sessionId ? { sessionId: event.sessionId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
return;
|
||||
@@ -918,6 +1032,72 @@ export function handleHubLiveEvent(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHubApprovalRequest(
|
||||
ctx: SidecarContext,
|
||||
event: {
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const sessionId = event.sessionId?.trim() || "";
|
||||
const approvalId =
|
||||
typeof event.payload?.approvalId === "string"
|
||||
? event.payload.approvalId.trim()
|
||||
: "";
|
||||
const toolCallId =
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId.trim()
|
||||
: "";
|
||||
const toolName =
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName.trim()
|
||||
: "";
|
||||
if (!sessionId || !approvalId || !toolCallId || !toolName) return;
|
||||
let input: unknown;
|
||||
try {
|
||||
input =
|
||||
typeof event.payload?.inputJson === "string"
|
||||
? JSON.parse(event.payload.inputJson)
|
||||
: undefined;
|
||||
} catch {
|
||||
input = undefined;
|
||||
}
|
||||
const result = await requestSidecarToolApproval(ctx, {
|
||||
sessionId,
|
||||
agentId:
|
||||
typeof event.payload?.agentId === "string" ? event.payload.agentId : "",
|
||||
conversationId:
|
||||
typeof event.payload?.conversationId === "string"
|
||||
? event.payload.conversationId
|
||||
: sessionId,
|
||||
iteration:
|
||||
typeof event.payload?.iteration === "number"
|
||||
? event.payload.iteration
|
||||
: 0,
|
||||
toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
policy:
|
||||
event.payload?.policy &&
|
||||
typeof event.payload.policy === "object" &&
|
||||
!Array.isArray(event.payload.policy)
|
||||
? (event.payload.policy as ToolApprovalRequest["policy"])
|
||||
: { autoApprove: false },
|
||||
});
|
||||
const client = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
|
||||
if (!client)
|
||||
throw new Error("Hub client disconnected before approval response");
|
||||
await client.command(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId,
|
||||
approved: result.approved,
|
||||
reason: result.reason,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
@@ -933,7 +1113,7 @@ export async function initializeSessionManager(
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -959,6 +1139,11 @@ export async function initializeSessionManager(
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: unsubscribe,
|
||||
});
|
||||
// Advertise the tool-approval surface once the local hub binding exists;
|
||||
// clients that connected before the hub came up are picked up here.
|
||||
await syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeBinding(
|
||||
@@ -1143,7 +1328,7 @@ export async function ensureSharedHubClient(
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ async function main() {
|
||||
void shutdown("code_sidecar_before_exit");
|
||||
});
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
const { port, approvalToken } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
observability.logger.log("Desktop sidecar ready", {
|
||||
port,
|
||||
mode: SIDECAR_MODE,
|
||||
@@ -153,12 +153,13 @@ async function main() {
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
|
||||
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
|
||||
wsEndpoint.searchParams.set("approval_token", approvalToken);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
endpoint,
|
||||
wsEndpoint,
|
||||
wsEndpoint: wsEndpoint.toString(),
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
})}\n`,
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("desktop observability", () => {
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
cline_type: "desktop",
|
||||
platform: "Cline Code",
|
||||
platform: "Cline",
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline Code",
|
||||
platform: "Cline",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { createFetchHandler, createWebSocketHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const TEST_APPROVAL_TOKEN = "test-approval-token";
|
||||
|
||||
function createTestServer() {
|
||||
return {
|
||||
port: 3126,
|
||||
@@ -17,7 +19,11 @@ function createTestServer() {
|
||||
}
|
||||
|
||||
function createHandler(onShutdown = vi.fn()) {
|
||||
return createFetchHandler({} as SidecarContext, onShutdown);
|
||||
return createFetchHandler(
|
||||
{} as SidecarContext,
|
||||
onShutdown,
|
||||
TEST_APPROVAL_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
function createTelemetryHandler(capture = vi.fn()) {
|
||||
@@ -102,6 +108,37 @@ describe("sidecar HTTP origin checks", () => {
|
||||
expect(server.upgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not grant approval authority to originless local clients", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request(
|
||||
`http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`,
|
||||
),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("grants approval authority to the trusted desktop webview", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request(
|
||||
`http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`,
|
||||
{
|
||||
headers: { origin: "tauri://localhost" },
|
||||
},
|
||||
),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows desktop webview origins in preflight responses", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
@@ -120,6 +157,20 @@ describe("sidecar HTTP origin checks", () => {
|
||||
"tauri://localhost",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not grant approval authority to a spoofed trusted origin", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/transport", {
|
||||
headers: { origin: "tauri://localhost" },
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session video artifacts", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
@@ -10,7 +11,12 @@ import {
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import { handleCommand } from "./commands";
|
||||
import { encodeSidecarEvent, sendEvent } from "./context";
|
||||
import {
|
||||
cancelSidecarToolApprovalsForOwner,
|
||||
encodeSidecarEvent,
|
||||
sendEvent,
|
||||
syncSidecarApprovalReadiness,
|
||||
} from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
@@ -26,7 +32,10 @@ import {
|
||||
|
||||
type SidecarServer = {
|
||||
port: number;
|
||||
upgrade(req: Request): boolean;
|
||||
upgrade(
|
||||
req: Request,
|
||||
options?: { data?: { canApproveTools?: boolean } },
|
||||
): boolean;
|
||||
};
|
||||
|
||||
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
|
||||
@@ -49,6 +58,19 @@ const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const APPROVAL_TOKEN_QUERY_PARAM = "approval_token";
|
||||
|
||||
function hasValidApprovalToken(url: URL, expectedToken: string): boolean {
|
||||
const candidate = url.searchParams.get(APPROVAL_TOKEN_QUERY_PARAM);
|
||||
if (!candidate) return false;
|
||||
const candidateBytes = Buffer.from(candidate);
|
||||
const expectedBytes = Buffer.from(expectedToken);
|
||||
return (
|
||||
candidateBytes.length === expectedBytes.length &&
|
||||
timingSafeEqual(candidateBytes, expectedBytes)
|
||||
);
|
||||
}
|
||||
|
||||
function artifactContentType(filename: string): string {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||
@@ -173,7 +195,9 @@ export function startServer(
|
||||
ctx: SidecarContext,
|
||||
preferredPort: number = SIDECAR_PORT,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
): { port: number } {
|
||||
approvalToken = process.env.CLINE_SIDECAR_APPROVAL_TOKEN?.trim() ||
|
||||
randomUUID(),
|
||||
): { port: number; approvalToken: string } {
|
||||
if (!BunRuntime) {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
@@ -188,7 +212,7 @@ export function startServer(
|
||||
server = BunRuntime.serve({
|
||||
hostname: SIDECAR_HOST,
|
||||
port: candidate,
|
||||
fetch: createFetchHandler(ctx, onShutdown),
|
||||
fetch: createFetchHandler(ctx, onShutdown, approvalToken),
|
||||
websocket: createWebSocketHandler(ctx),
|
||||
}) as SidecarServer;
|
||||
break;
|
||||
@@ -201,12 +225,13 @@ export function startServer(
|
||||
throw lastError ?? new Error("Failed to start sidecar server");
|
||||
}
|
||||
|
||||
return { port: server.port };
|
||||
return { port: server.port, approvalToken };
|
||||
}
|
||||
|
||||
export function createFetchHandler(
|
||||
ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
approvalToken = "",
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -299,7 +324,15 @@ export function createFetchHandler(
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req)
|
||||
server.upgrade(req, {
|
||||
data: {
|
||||
// Originless clients remain supported for local integrations, but only
|
||||
// the browser-hosted desktop UI may receive or resolve approvals.
|
||||
canApproveTools:
|
||||
Boolean(readOrigin(req)) &&
|
||||
hasValidApprovalToken(url, approvalToken),
|
||||
},
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -456,6 +489,7 @@ export function createWebSocketHandler(ctx: SidecarContext) {
|
||||
maxPayloadLength: MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES,
|
||||
open(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.add(ws);
|
||||
void syncSidecarApprovalReadiness(ctx).catch(() => {});
|
||||
sendEvent(ctx, "host_ready", {
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
@@ -496,6 +530,8 @@ export function createWebSocketHandler(ctx: SidecarContext) {
|
||||
},
|
||||
close(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.delete(ws);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, ws);
|
||||
void syncSidecarApprovalReadiness(ctx).catch(() => {});
|
||||
// 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.
|
||||
|
||||
@@ -88,6 +88,166 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects pre-tool thinking before the tool row it preceded", async () => {
|
||||
// A thinking model can issue a tool call without narration text:
|
||||
// content = [thinking, tool_use]. The thinking happened before the
|
||||
// tool executed, so it must project before the tool row — matching the
|
||||
// live-stream order and keeping the reasoning from attaching to the
|
||||
// next turn-ending answer (which would corrupt the work summary's
|
||||
// duration anchor in the webview).
|
||||
const sessionId = `thinking-tool-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_000;
|
||||
const assistantTimestamp = userTimestamp + 5_000;
|
||||
const resultTimestamp = userTimestamp + 13_000;
|
||||
const answerTimestamp = userTimestamp + 13_500;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Run the command" }],
|
||||
ts: userTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-tool",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Planning the command" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-use",
|
||||
name: "run_commands",
|
||||
input: { commands: ["sleep 8"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
{
|
||||
id: "tool-result-message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-use",
|
||||
content: "done",
|
||||
},
|
||||
],
|
||||
ts: resultTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-answer",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "The command finished." }],
|
||||
ts: answerTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user-message_text_0",
|
||||
role: "user",
|
||||
createdAt: userTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tool_reasoning_0",
|
||||
role: "assistant",
|
||||
reasoning: "Planning the command",
|
||||
createdAt: assistantTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tool_tool_use_1",
|
||||
role: "tool",
|
||||
createdAt: assistantTimestamp + 1,
|
||||
meta: expect.objectContaining({
|
||||
toolCallId: "tool-use",
|
||||
hookEventName: "history_tool_result",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-answer_text_0",
|
||||
role: "assistant",
|
||||
content: "The command finished.",
|
||||
createdAt: answerTimestamp,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps interleaved thinking between the tool calls it separates", async () => {
|
||||
// Interleaved thinking can produce [thinking, tool_use, thinking,
|
||||
// tool_use] in a single assistant message. Each thinking segment must
|
||||
// project at its own position — merging the second segment into the
|
||||
// first row would display it before a tool call it actually followed.
|
||||
const sessionId = `interleaved-thinking-projection-${Date.now()}`;
|
||||
const assistantTimestamp = 1_781_041_621_000;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-tools",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "First I need the date" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-a",
|
||||
name: "run_commands",
|
||||
input: { commands: ["date"] },
|
||||
},
|
||||
{ type: "thinking", thinking: "Now check the files" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-b",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_reasoning_0",
|
||||
role: "assistant",
|
||||
reasoning: "First I need the date",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_tool_use_1",
|
||||
role: "tool",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_reasoning_1",
|
||||
role: "assistant",
|
||||
reasoning: "Now check the files",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_tool_use_3",
|
||||
role: "tool",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
|
||||
@@ -484,6 +484,11 @@ export async function readSessionMessages(
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
let reasoningSegmentIndex = 0;
|
||||
// The text row pushed since the last reasoning flush. Reasoning that
|
||||
// streamed alongside it (the classic [thinking, text] shape) attaches
|
||||
// there instead of becoming a separate row.
|
||||
let reasoningTextTarget: JsonRecord | undefined;
|
||||
const outStartIndex = out.length;
|
||||
const flushTextParts = () => {
|
||||
if (textParts.length === 0) {
|
||||
@@ -494,7 +499,7 @@ export async function readSessionMessages(
|
||||
if (!joined.trim()) {
|
||||
return;
|
||||
}
|
||||
out.push({
|
||||
const textRow: JsonRecord = {
|
||||
id: `${messageIdBase}_text_${textSegmentIndex}`,
|
||||
sessionId,
|
||||
role,
|
||||
@@ -505,10 +510,50 @@ export async function readSessionMessages(
|
||||
// the run; later segments must not acquire a fallback ordinal in
|
||||
// the webview.
|
||||
meta: textMeta ?? (role === "user" ? { userRunSpan: 0 } : undefined),
|
||||
});
|
||||
};
|
||||
out.push(textRow);
|
||||
reasoningTextTarget = textRow;
|
||||
textSegmentIndex += 1;
|
||||
textMeta = undefined;
|
||||
};
|
||||
const flushReasoningParts = () => {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const redacted = reasoningRedacted;
|
||||
reasoningParts.length = 0;
|
||||
reasoningRedacted = false;
|
||||
// Consumed per flush: reasoning must only attach to a text row from
|
||||
// its own segment, never to one emitted before an earlier tool call.
|
||||
const target = reasoningTextTarget;
|
||||
reasoningTextTarget = undefined;
|
||||
if (!reasoning && !redacted) {
|
||||
return;
|
||||
}
|
||||
if (target) {
|
||||
if (reasoning) {
|
||||
const existing =
|
||||
typeof target.reasoning === "string" && target.reasoning
|
||||
? `${target.reasoning}\n`
|
||||
: "";
|
||||
target.reasoning = `${existing}${reasoning}`;
|
||||
}
|
||||
if (redacted) {
|
||||
target.reasoningRedacted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push({
|
||||
id: `${messageIdBase}_reasoning_${reasoningSegmentIndex}`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: redacted || undefined,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
reasoningSegmentIndex += 1;
|
||||
textMeta = undefined;
|
||||
};
|
||||
|
||||
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx += 1) {
|
||||
const block = contentBlocks[blockIdx];
|
||||
@@ -523,6 +568,13 @@ export async function readSessionMessages(
|
||||
const blockType = typeof record.type === "string" ? record.type : "";
|
||||
if (blockType === "tool_use") {
|
||||
flushTextParts();
|
||||
// Everything the model emitted in this message — thinking
|
||||
// included — happened before the tool executed. Flushing the
|
||||
// reasoning here keeps the thinking row ahead of the tool row
|
||||
// (matching the live-stream order) so the webview never attaches
|
||||
// pre-tool reasoning to a later answer, which would drag the
|
||||
// work summary's duration anchor back before the tool ran.
|
||||
flushReasoningParts();
|
||||
const toolName =
|
||||
typeof record.name === "string" ? record.name : "tool_call";
|
||||
const toolUseId = typeof record.id === "string" ? record.id : "";
|
||||
@@ -712,32 +764,7 @@ export async function readSessionMessages(
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (reasoningParts.length > 0 || reasoningRedacted) {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
if (reasoning) {
|
||||
target.reasoning = reasoning;
|
||||
}
|
||||
if (reasoningRedacted) {
|
||||
target.reasoningRedacted = true;
|
||||
}
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_reasoning`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
flushReasoningParts();
|
||||
if (textMeta && out[outStartIndex]) {
|
||||
out[outStartIndex].meta = {
|
||||
...(typeof out[outStartIndex].meta === "object"
|
||||
|
||||
@@ -106,6 +106,11 @@ export type ToolApprovalRequestItem = {
|
||||
|
||||
export type PendingToolApproval = {
|
||||
item: ToolApprovalRequestItem;
|
||||
// Approvals created for a trusted desktop connection carry that owner and
|
||||
// may only be listed/answered by it. Cloud-session approvals are relayed
|
||||
// from a pod without a local owner and stay answerable from any trusted
|
||||
// surface (and survive local disconnects).
|
||||
owner?: SidecarWebSocketClient;
|
||||
resolve: (result: ToolApprovalResult) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -129,6 +134,7 @@ export type PendingAskQuestion = {
|
||||
};
|
||||
|
||||
export type SidecarWebSocketClient = {
|
||||
data?: { canApproveTools?: boolean };
|
||||
send: (message: string) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Cline Code uses the microphone to transcribe speech into chat input.</string>
|
||||
<string>Cline uses the microphone to transcribe speech into chat input.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 439 B After Width: | Height: | Size: 839 B |
@@ -6,7 +6,7 @@ use std::sync::OnceLock;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const DEV_APP_DIRECTORY: &str = "notification-identity";
|
||||
const DEV_BUNDLE_NAME: &str = "Cline Code.app";
|
||||
const DEV_BUNDLE_NAME: &str = "Cline.app";
|
||||
const LAUNCH_SERVICES_REGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
||||
|
||||
static CONFIGURATION: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
@@ -200,17 +200,13 @@ mod tests {
|
||||
fs::write(&executable, b"test executable").unwrap();
|
||||
fs::write(&icon, b"test icon").unwrap();
|
||||
|
||||
let bundle = create_dev_application_bundle(
|
||||
&executable,
|
||||
&icon,
|
||||
"bot.cline.app.dev",
|
||||
"Cline Code Dev",
|
||||
)
|
||||
.unwrap();
|
||||
let bundle =
|
||||
create_dev_application_bundle(&executable, &icon, "bot.cline.app.dev", "Cline Dev")
|
||||
.unwrap();
|
||||
let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
|
||||
|
||||
assert!(plist.contains("<string>bot.cline.app.dev</string>"));
|
||||
assert!(plist.contains("<string>Cline Code Dev</string>"));
|
||||
assert!(plist.contains("<string>Cline Dev</string>"));
|
||||
assert_eq!(
|
||||
fs::read_link(bundle.join("Contents/MacOS/cline-app")).unwrap(),
|
||||
executable
|
||||
|
||||
@@ -209,7 +209,7 @@ fn running_sessions_text(running_sessions: u32) -> String {
|
||||
}
|
||||
|
||||
// app_name is package_info().name (the configured productName), so beta
|
||||
// builds ("Cline Code Beta") identify themselves in the tooltip too.
|
||||
// builds ("Cline Beta") identify themselves in the tooltip too.
|
||||
fn tray_tooltip_text(app_name: &str, running_sessions: u32) -> String {
|
||||
if running_sessions == 0 {
|
||||
app_name.to_string()
|
||||
@@ -815,7 +815,7 @@ async fn check_for_update_now(
|
||||
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
|
||||
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
|
||||
/// resource at icons/dock/<id>.png.
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "sunrise", "steel", "midnight"];
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
@@ -1369,7 +1369,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
.text(
|
||||
TRAY_OPEN_MENU_ID,
|
||||
// package_info().name is the configured productName, so beta
|
||||
// builds ("Cline Code Beta") identify themselves in the tray too.
|
||||
// builds ("Cline Beta") identify themselves in the tray too.
|
||||
format!(
|
||||
"{} v{}",
|
||||
app.package_info().name,
|
||||
@@ -1708,14 +1708,11 @@ mod tests {
|
||||
assert_eq!(running_sessions_text(0), "0 sessions running");
|
||||
assert_eq!(running_sessions_text(1), "1 session running");
|
||||
assert_eq!(running_sessions_text(3), "3 sessions running");
|
||||
assert_eq!(tray_tooltip_text("Cline Code", 0), "Cline Code");
|
||||
assert_eq!(tray_tooltip_text("Cline", 0), "Cline");
|
||||
assert_eq!(tray_tooltip_text("Cline", 3), "Cline — 3 sessions running");
|
||||
assert_eq!(
|
||||
tray_tooltip_text("Cline Code", 3),
|
||||
"Cline Code — 3 sessions running"
|
||||
);
|
||||
assert_eq!(
|
||||
tray_tooltip_text("Cline Code Beta", 2),
|
||||
"Cline Code Beta — 2 sessions running"
|
||||
tray_tooltip_text("Cline Beta", 2),
|
||||
"Cline Beta — 2 sessions running"
|
||||
);
|
||||
assert_eq!(tray_badge_text(0), None);
|
||||
assert_eq!(tray_badge_text(3), Some("3".to_string()));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code Beta",
|
||||
"productName": "Cline Beta",
|
||||
"identifier": "bot.cline.app.beta",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.14-beta.1",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.15-beta.1",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -22,7 +22,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Cline Code",
|
||||
"title": "Cline",
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code Dev",
|
||||
"productName": "Cline Dev",
|
||||
"identifier": "bot.cline.app.dev"
|
||||
}
|
||||
|
||||
@@ -59,26 +59,6 @@
|
||||
* @cline/ui (components/markdown.css and agent-chat.css), shared with the
|
||||
* cloud dashboard so both products render assistant output identically. */
|
||||
|
||||
.cline-thinking-slider-shimmer {
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
transparent 20%,
|
||||
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 42%,
|
||||
color-mix(in oklab, var(--primary-foreground) 70%, transparent) 50%,
|
||||
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 58%,
|
||||
transparent 80%
|
||||
);
|
||||
background-position: 180% 0;
|
||||
background-size: 220% 100%;
|
||||
animation: cline-thinking-slider-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cline-thinking-slider-shimmer {
|
||||
to {
|
||||
background-position: -180% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
@@ -99,10 +79,6 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-thinking-slider-shimmer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -912,6 +912,7 @@ export default function Home() {
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
realtimeVoiceControl={
|
||||
<RealtimeVoiceOverlay
|
||||
bridge={
|
||||
@@ -930,6 +931,11 @@ export default function Home() {
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
workspaceRoot={
|
||||
activeThread?.historySession?.workspaceRoot ||
|
||||
activeThread?.historySession?.cwd ||
|
||||
historyWorkspacePaths[0]
|
||||
}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
/>
|
||||
@@ -1851,7 +1857,7 @@ function ChatThreadPane({
|
||||
toast({
|
||||
title: "Opened handoff in your browser",
|
||||
description:
|
||||
"The cloud session could not be attached inside Cline Code.",
|
||||
"The cloud session could not be attached inside Cline.",
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@@ -2968,6 +2974,7 @@ function ChatThreadPane({
|
||||
) : undefined
|
||||
}
|
||||
onListGitBranches={listGitBranches}
|
||||
onOpenSession={onOpenSessionById}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
executionTarget={isCloudSession ? "cloud" : "local"}
|
||||
repoUrl={config.repoUrl ?? ""}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function AgendaTaskReviewDialog({
|
||||
task,
|
||||
open,
|
||||
pending,
|
||||
confirmLabel = "Approve",
|
||||
rejectLabel = "Reject",
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: {
|
||||
task: AgendaTaskRecord | null;
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
confirmLabel?: string;
|
||||
rejectLabel?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (task: AgendaTaskRecord) => void | Promise<void>;
|
||||
onReject?: (task: AgendaTaskRecord) => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="h-[min(720px,calc(100dvh-2rem))] w-[min(640px,calc(100vw-2rem))] max-w-none grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
{task ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the exact revision before it can start a new agent
|
||||
session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs">
|
||||
<ReviewField label="Revision" value={String(task.revision)} />
|
||||
<ReviewField label="Priority" value={`P${task.priority}`} />
|
||||
<ReviewField label="Type" value={task.type} />
|
||||
<ReviewField label="Mode" value={task.mode ?? "act"} />
|
||||
<ReviewField
|
||||
label="Scope"
|
||||
value={
|
||||
task.scope === "workspace"
|
||||
? (task.workspaceRoot ?? "workspace")
|
||||
: "General / chat workspace"
|
||||
}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Expires"
|
||||
value={new Date(task.expiresAt).toLocaleString()}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Available"
|
||||
value={new Date(task.availableAt).toLocaleString()}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Assignee"
|
||||
value={task.assignee ?? "Default agent"}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Model"
|
||||
value={
|
||||
task.modelSelection
|
||||
? `${task.modelSelection.providerId}/${task.modelSelection.modelId ?? "default"}`
|
||||
: "Cline default"
|
||||
}
|
||||
/>
|
||||
{task.cwd ? (
|
||||
<ReviewField label="Working directory" value={task.cwd} />
|
||||
) : null}
|
||||
<ReviewField
|
||||
label="Run limits"
|
||||
value={
|
||||
[
|
||||
task.maxIterations
|
||||
? `${task.maxIterations} iterations`
|
||||
: undefined,
|
||||
task.timeoutSeconds
|
||||
? `${task.timeoutSeconds}s timeout`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Hub defaults"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<ReviewText label="Description" value={task.description} />
|
||||
) : null}
|
||||
<ReviewText label="Instructions" value={task.instructions} />
|
||||
{task.systemPrompt ? (
|
||||
<ReviewText
|
||||
label="System prompt override"
|
||||
value={task.systemPrompt}
|
||||
/>
|
||||
) : null}
|
||||
{task.resourcePaths.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium">Files</h4>
|
||||
<ul className="space-y-1 rounded-md border bg-muted/20 p-3 font-mono text-[11px]">
|
||||
{task.resourcePaths.map((path) => (
|
||||
<li className="break-all" key={path}>
|
||||
{path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (onReject) void onReject(task);
|
||||
else onOpenChange(false);
|
||||
}}
|
||||
type="button"
|
||||
variant={onReject ? "destructive" : "outline"}
|
||||
>
|
||||
{onReject ? rejectLabel : "Not now"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => void onConfirm(task)}
|
||||
type="button"
|
||||
>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className="truncate font-medium capitalize" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewText({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium">{label}</h4>
|
||||
<div className="whitespace-pre-wrap rounded-md border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -15,8 +16,20 @@ import type {
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
createAgendaTask: vi.fn(),
|
||||
listAgendaTasks: vi.fn(),
|
||||
approveAgendaTask: vi.fn(),
|
||||
cancelAgendaTask: vi.fn(),
|
||||
runAgendaTask: vi.fn(),
|
||||
getAgendaAutomationPolicy: vi.fn(),
|
||||
setAgendaAutomationPolicy: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
}));
|
||||
const { invoke } = desktopMocks;
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -41,12 +54,13 @@ function makeSessionHistory(
|
||||
options: {
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
hasLoadedHistory?: boolean;
|
||||
} = {},
|
||||
): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
hasLoadedHistory: options.hasLoadedHistory ?? true,
|
||||
isLoadingMore: false,
|
||||
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
|
||||
loadMoreSessions,
|
||||
@@ -80,6 +94,20 @@ async function hover(element: Element): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function changeField(
|
||||
element: HTMLInputElement | HTMLTextAreaElement,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
const prototype = Object.getPrototypeOf(element) as object;
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
||||
setter?.call(element, value);
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
@@ -117,6 +145,29 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
desktopMocks.createAgendaTask.mockReset();
|
||||
desktopMocks.listAgendaTasks.mockReset();
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([]);
|
||||
desktopMocks.approveAgendaTask.mockReset();
|
||||
desktopMocks.cancelAgendaTask.mockReset();
|
||||
desktopMocks.runAgendaTask.mockReset();
|
||||
desktopMocks.getAgendaAutomationPolicy.mockReset();
|
||||
desktopMocks.getAgendaAutomationPolicy.mockResolvedValue({
|
||||
scopeKey: "global",
|
||||
mode: "manual",
|
||||
applyToAgentCreated: true,
|
||||
maxConcurrentRuns: 1,
|
||||
maxChainDepth: 3,
|
||||
maxStartsPerHour: 20,
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
});
|
||||
desktopMocks.setAgendaAutomationPolicy.mockReset();
|
||||
desktopMocks.subscribe.mockReset();
|
||||
desktopMocks.subscribe.mockImplementation(() => () => undefined);
|
||||
desktopMocks.subscribeTransportState.mockReset();
|
||||
desktopMocks.subscribeTransportState.mockImplementation(
|
||||
() => () => undefined,
|
||||
);
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
@@ -140,6 +191,251 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("shows an unread dot when a new Todo item arrives and clears it on open", async () => {
|
||||
const eventHandlers = new Map<string, () => void>();
|
||||
desktopMocks.subscribe.mockImplementation(
|
||||
(eventName: string, handler: () => void) => {
|
||||
eventHandlers.set(eventName, handler);
|
||||
return () => eventHandlers.delete(eventName);
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(desktopMocks.listAgendaTasks).toHaveBeenCalled(),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).toBeNull();
|
||||
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([makeAgendaTask()]);
|
||||
await act(async () => {
|
||||
eventHandlers.get("task.created")?.();
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows pending Agenda work and requires approval before run", async () => {
|
||||
const task = makeAgendaTask();
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([task]);
|
||||
desktopMocks.approveAgendaTask.mockResolvedValue({
|
||||
...task,
|
||||
status: "approved",
|
||||
revision: 2,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
workspaceRoot="/projects/current"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Review PR checks");
|
||||
expect(container.textContent).toContain("cline");
|
||||
expect(container.textContent).not.toContain("P1 · pending approval");
|
||||
expect(desktopMocks.listAgendaTasks).toHaveBeenCalledWith({
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
workspaceRoot: "/projects/current",
|
||||
limit: 200,
|
||||
});
|
||||
const approve = container.querySelector(
|
||||
'[aria-label="Approve Review PR checks"]',
|
||||
);
|
||||
expect(approve).not.toBeNull();
|
||||
expect(approve?.className).toContain("text-emerald-500!");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Cancel Review PR checks"]')
|
||||
?.className,
|
||||
).toContain("text-destructive!");
|
||||
expect(approve?.closest(".group")?.className).toContain("max-w-full");
|
||||
expect(
|
||||
buttonWithText("Review PR checks").querySelector(".truncate"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Run Review PR checks"]'),
|
||||
).toBeNull();
|
||||
|
||||
await click(buttonWithText("Review PR checks"));
|
||||
expect(desktopMocks.approveAgendaTask).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain(
|
||||
"Review CI and report failures.",
|
||||
);
|
||||
expect(buttonWithText("Reject", document)).toBeDefined();
|
||||
await click(buttonWithText("Approve", document));
|
||||
expect(desktopMocks.approveAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses each displayed Agenda revision when running or cancelling", async () => {
|
||||
const runnable = makeAgendaTask({
|
||||
taskId: "task-run",
|
||||
title: "Run task",
|
||||
status: "approved",
|
||||
revision: 4,
|
||||
});
|
||||
const cancellable = makeAgendaTask({
|
||||
taskId: "task-cancel",
|
||||
title: "Cancel task",
|
||||
status: "approved",
|
||||
revision: 9,
|
||||
});
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([runnable, cancellable]);
|
||||
desktopMocks.runAgendaTask.mockResolvedValue({
|
||||
task: { ...runnable, status: "in_progress" },
|
||||
});
|
||||
desktopMocks.cancelAgendaTask.mockResolvedValue({
|
||||
...cancellable,
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Run Run task"]') as Element,
|
||||
);
|
||||
expect(desktopMocks.runAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-run",
|
||||
expectedRevision: 4,
|
||||
});
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Cancel Cancel task"]') as Element,
|
||||
);
|
||||
expect(desktopMocks.cancelAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-cancel",
|
||||
expectedRevision: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a workspace task with the selected priority, expiry, and model", async () => {
|
||||
const created = makeAgendaTask({
|
||||
taskId: "task-created",
|
||||
title: "Investigate the regression",
|
||||
});
|
||||
desktopMocks.createAgendaTask.mockResolvedValue(created);
|
||||
window.localStorage.setItem(
|
||||
"cline.code.model-selection.v1",
|
||||
JSON.stringify({
|
||||
lastProvider: "openrouter",
|
||||
lastModelByProvider: { openrouter: "anthropic/claude-sonnet-4.6" },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
workspaceRoot="/projects/current"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
await click(
|
||||
container.querySelector('[aria-label="Create Todo item"]') as Element,
|
||||
);
|
||||
const title =
|
||||
document.querySelector<HTMLInputElement>("#agenda-task-title");
|
||||
const instructions = document.querySelector<HTMLTextAreaElement>(
|
||||
"#agenda-task-instructions",
|
||||
);
|
||||
expect(title).not.toBeNull();
|
||||
expect(instructions).not.toBeNull();
|
||||
await changeField(title as HTMLInputElement, "Investigate the regression");
|
||||
await changeField(
|
||||
instructions as HTMLTextAreaElement,
|
||||
"Inspect the failing build and implement a fix.",
|
||||
);
|
||||
await click(buttonWithText("Add to Agenda", document));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(desktopMocks.createAgendaTask).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const input = desktopMocks.createAgendaTask.mock.calls[0]?.[0];
|
||||
expect(input).toMatchObject({
|
||||
type: "todo",
|
||||
title: "Investigate the regression",
|
||||
instructions: "Inspect the failing build and implement a fix.",
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/current",
|
||||
priority: 3,
|
||||
modelSelection: {
|
||||
providerId: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
automationEligible: true,
|
||||
});
|
||||
expect(Date.parse(input.expiresAt)).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("filters scheduled sessions without changing their titles", async () => {
|
||||
const scheduled = {
|
||||
...makeThread("scheduled", 1),
|
||||
@@ -224,6 +520,56 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(sessionIsVisible("cli session 1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the loading state until the first history response arrives", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn(), {
|
||||
hasLoadedHistory: false,
|
||||
})}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Before the backend has answered, an empty list means "still loading",
|
||||
// never "no sessions": the definitive copy would read as lost history.
|
||||
expect(container.textContent).toContain("Loading session history...");
|
||||
expect(container.textContent).not.toContain("No sessions found in history");
|
||||
});
|
||||
|
||||
it("shows the empty state only after the backend answered with zero sessions", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn(), {
|
||||
hasLoadedHistory: true,
|
||||
})}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("No sessions found in history");
|
||||
expect(container.textContent).not.toContain("Loading session history...");
|
||||
});
|
||||
|
||||
it("builds the hover overview with branch and secondary metadata last", () => {
|
||||
const thread = {
|
||||
...makeThread("cline", 5),
|
||||
@@ -426,6 +772,44 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the settings gear hover state while the Account screen is open", async () => {
|
||||
invoke.mockResolvedValue(signedInUser);
|
||||
|
||||
const renderSidebar = async (settingsSection: "Account" | "General") => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection={settingsSection}
|
||||
view="settings"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
return vi.waitFor(() => {
|
||||
const button = container.querySelector('[aria-label="Settings"]');
|
||||
expect(button).not.toBeNull();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
};
|
||||
|
||||
const gearOnAccount = await renderSidebar("Account");
|
||||
expect(gearOnAccount.className).toContain("hover:bg-transparent");
|
||||
expect(gearOnAccount.className).not.toContain("bg-surface-hover");
|
||||
|
||||
const gearOnGeneral = await renderSidebar("General");
|
||||
expect(gearOnGeneral.className).not.toContain("hover:bg-transparent");
|
||||
expect(gearOnGeneral.className).toContain("bg-surface-hover");
|
||||
});
|
||||
|
||||
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
|
||||
const onHome = vi.fn();
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
@@ -551,7 +935,7 @@ describe("AgentSidebar session organization", () => {
|
||||
|
||||
const titleBar = container.querySelector("[data-tauri-drag-region]");
|
||||
expect(titleBar).not.toBeNull();
|
||||
expect(titleBar?.textContent).not.toContain("Cline Code");
|
||||
expect(titleBar?.textContent).not.toContain("Cline");
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Previous page"]') as Element,
|
||||
@@ -586,11 +970,13 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
|
||||
const logo = container.querySelector('[aria-label="Cline home"]');
|
||||
const showAgenda = container.querySelector('[aria-label="Show Agenda"]');
|
||||
const newSession = container.querySelector('[aria-label="New Session"]');
|
||||
const realtimeVoice = container.querySelector(
|
||||
'[data-testid="realtime-voice-control"]',
|
||||
);
|
||||
expect(logo).not.toBeNull();
|
||||
expect(showAgenda).not.toBeNull();
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(realtimeVoice?.parentElement).toBe(newSession?.parentElement);
|
||||
expect(newSession?.textContent).toBe("");
|
||||
@@ -716,3 +1102,29 @@ describe("AgentSidebar session organization", () => {
|
||||
).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
||||
function makeAgendaTask(
|
||||
overrides: Partial<AgendaTaskRecord> = {},
|
||||
): AgendaTaskRecord {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
type: "follow-up",
|
||||
status: "pending_approval",
|
||||
title: "Review PR checks",
|
||||
description: "Confirm that CI passed.",
|
||||
instructions: "Review CI and report failures.",
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/cline",
|
||||
resourcePaths: [],
|
||||
priority: 1,
|
||||
availableAt: "2026-08-13T00:00:00.000Z",
|
||||
expiresAt: "2099-08-20T00:00:00.000Z",
|
||||
automationEligible: true,
|
||||
revision: 1,
|
||||
createdBy: { kind: "agent" },
|
||||
updatedBy: { kind: "agent" },
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
AgendaTaskPriority,
|
||||
AgendaTaskRecord,
|
||||
AgendaTaskType,
|
||||
HubTaskCreateInput,
|
||||
} from "@cline/shared";
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownUp,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Cloud,
|
||||
Code,
|
||||
@@ -16,19 +24,23 @@ import {
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Play,
|
||||
Plug,
|
||||
Plus,
|
||||
Radio,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Store,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -38,6 +50,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
|
||||
import { AppUpdateIndicator } from "@/components/app-update-indicator";
|
||||
import { ClineLogo } from "@/components/cline-logo";
|
||||
import {
|
||||
@@ -58,6 +71,14 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -75,6 +96,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
@@ -82,6 +104,7 @@ import {
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/sections";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useAgendaAutomation, useAgendaTasks } from "@/hooks/use-agenda-tasks";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
@@ -94,6 +117,7 @@ import {
|
||||
} from "@/lib/app-channel";
|
||||
import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import {
|
||||
ALL_SESSION_SOURCES,
|
||||
filterSessionsBySource,
|
||||
@@ -113,6 +137,7 @@ type AppView = "chat" | "sessions" | "settings";
|
||||
const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type SidebarContent = "sessions" | "agenda";
|
||||
type DesktopProcessContext = {
|
||||
appVersion?: unknown;
|
||||
hub?: {
|
||||
@@ -146,8 +171,7 @@ const SETTINGS_SECTION_ICONS = {
|
||||
Remote: Network,
|
||||
Account: CircleUserRound,
|
||||
Plugins: Plug,
|
||||
Skills: Activity,
|
||||
MCP: Server,
|
||||
Marketplace: Store,
|
||||
Hooks: Code,
|
||||
Rules: FileText,
|
||||
Agents: Bot,
|
||||
@@ -220,6 +244,7 @@ export function AgentSidebar({
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
onNewThread,
|
||||
onOpenSessionById,
|
||||
onSettingsSectionChange,
|
||||
setView,
|
||||
settingsSection,
|
||||
@@ -227,6 +252,7 @@ export function AgentSidebar({
|
||||
activeSessionId,
|
||||
sessionHistory,
|
||||
realtimeVoiceControl,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
canNavigateBack?: boolean;
|
||||
canNavigateForward?: boolean;
|
||||
@@ -234,6 +260,7 @@ export function AgentSidebar({
|
||||
onNavigateBack?: () => void;
|
||||
onNavigateForward?: () => void;
|
||||
onNewThread?: () => void;
|
||||
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
setView: (view: AppView) => void;
|
||||
settingsSection: SettingsSection;
|
||||
@@ -241,6 +268,7 @@ export function AgentSidebar({
|
||||
activeSessionId?: string | null;
|
||||
sessionHistory: UseSessionHistoryResult;
|
||||
realtimeVoiceControl?: ReactNode;
|
||||
workspaceRoot?: string;
|
||||
}) {
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
@@ -255,7 +283,7 @@ export function AgentSidebar({
|
||||
const {
|
||||
deleteThread: deleteHistoryThread,
|
||||
forkThread: forkHistoryThread,
|
||||
isLoadingHistory,
|
||||
hasLoadedHistory,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
@@ -271,6 +299,10 @@ export function AgentSidebar({
|
||||
const [filter, setFilter] = useState<FilterOption>("All");
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES);
|
||||
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
|
||||
const [sidebarContent, setSidebarContent] =
|
||||
useState<SidebarContent>("sessions");
|
||||
const [hasNewTodoTasks, setHasNewTodoTasks] = useState(false);
|
||||
const knownAgendaTaskIdsRef = useRef<Set<string> | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showMoreCount, setShowMoreCount] = useState(
|
||||
@@ -289,6 +321,38 @@ export function AgentSidebar({
|
||||
>({});
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [hubStatus, setHubStatus] = useState<HubStatus | null>(null);
|
||||
const normalizedWorkspaceRoot = workspaceRoot?.trim() ?? "";
|
||||
const agendaWorkspaceRoot =
|
||||
normalizedWorkspaceRoot && !isChatWorkspacePath(normalizedWorkspaceRoot)
|
||||
? normalizedWorkspaceRoot
|
||||
: undefined;
|
||||
const agenda = useAgendaTasks(
|
||||
{
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
workspaceRoot: agendaWorkspaceRoot,
|
||||
limit: 200,
|
||||
},
|
||||
view !== "settings",
|
||||
);
|
||||
const agendaAutomation = useAgendaAutomation(view !== "settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings") {
|
||||
knownAgendaTaskIdsRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (agenda.isLoading) return;
|
||||
const currentTaskIds = new Set(agenda.tasks.map((task) => task.taskId));
|
||||
const knownTaskIds = knownAgendaTaskIdsRef.current;
|
||||
if (
|
||||
knownTaskIds !== null &&
|
||||
sidebarContent !== "agenda" &&
|
||||
agenda.tasks.some((task) => !knownTaskIds.has(task.taskId))
|
||||
) {
|
||||
setHasNewTodoTasks(true);
|
||||
}
|
||||
knownAgendaTaskIdsRef.current = currentTaskIds;
|
||||
}, [agenda.isLoading, agenda.tasks, sidebarContent, view]);
|
||||
|
||||
const loadProcessContext = useCallback(async () => {
|
||||
try {
|
||||
@@ -360,6 +424,25 @@ export function AgentSidebar({
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}, [isMobile, setOpenMobile]);
|
||||
const openAgendaSession = useCallback(
|
||||
(task: AgendaTaskRecord) => {
|
||||
if (!task.lastSessionId) return;
|
||||
void onOpenSessionById?.(task.lastSessionId);
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[closeMobileSidebar, onOpenSessionById],
|
||||
);
|
||||
const runAgendaTask = useCallback(
|
||||
async (task: AgendaTaskRecord) => {
|
||||
try {
|
||||
const started = await agenda.runTask(task);
|
||||
if (started.lastSessionId) openAgendaSession(started);
|
||||
} catch {
|
||||
// The queue hook exposes the error inline and refreshes after recovery.
|
||||
}
|
||||
},
|
||||
[agenda.runTask, openAgendaSession],
|
||||
);
|
||||
|
||||
const openThread = useCallback(
|
||||
(threadId: string) => {
|
||||
@@ -398,6 +481,12 @@ export function AgentSidebar({
|
||||
const navigateForward = useCallback(() => {
|
||||
onNavigateForward?.();
|
||||
}, [onNavigateForward]);
|
||||
const toggleSidebarContent = useCallback(() => {
|
||||
const next = sidebarContent === "agenda" ? "sessions" : "agenda";
|
||||
setSidebarContent(next);
|
||||
if (next === "agenda") setHasNewTodoTasks(false);
|
||||
if (next === "agenda" && view === "settings") setView("chat");
|
||||
}, [setView, sidebarContent, view]);
|
||||
|
||||
const startRenameThread = useCallback((thread: Thread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
@@ -718,16 +807,47 @@ export function AgentSidebar({
|
||||
>
|
||||
{realtimeVoiceControl}
|
||||
{!isCollapsed ? (
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
aria-label={
|
||||
sidebarContent === "agenda"
|
||||
? "Show Sessions"
|
||||
: "Show Agenda"
|
||||
}
|
||||
aria-pressed={sidebarContent === "agenda"}
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 justify-center px-0",
|
||||
sidebarContent === "agenda" &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={toggleSidebarContent}
|
||||
title={
|
||||
sidebarContent === "agenda"
|
||||
? "Show Sessions"
|
||||
: "Show Agenda"
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<ClipboardList className="size-4" />
|
||||
{hasNewTodoTasks ? (
|
||||
<span
|
||||
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
|
||||
data-testid="new-todo-indicator"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,6 +881,36 @@ export function AgentSidebar({
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : sidebarContent === "agenda" ? (
|
||||
<AgendaSection
|
||||
automatic={
|
||||
agendaAutomation.policy !== null &&
|
||||
agendaAutomation.policy.mode !== "manual"
|
||||
}
|
||||
automationDisabled={
|
||||
agendaAutomation.isLoading || agendaAutomation.isUpdating
|
||||
}
|
||||
error={agenda.error ?? agendaAutomation.error}
|
||||
isLoading={agenda.isLoading}
|
||||
onCreate={agenda.createTask}
|
||||
onApprove={agenda.approveTask}
|
||||
onCancel={(task) => {
|
||||
return agenda.cancelTask(task).catch(() => undefined);
|
||||
}}
|
||||
onOpen={openAgendaSession}
|
||||
onRun={(task) => void runAgendaTask(task)}
|
||||
onToggleAutomation={() => {
|
||||
void agendaAutomation
|
||||
.setAutomatic(
|
||||
agendaAutomation.policy?.mode !== "auto_start" &&
|
||||
agendaAutomation.policy?.mode !== "unattended",
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
pendingTaskIds={agenda.pendingTaskIds}
|
||||
tasks={agenda.tasks}
|
||||
workspaceRoot={agendaWorkspaceRoot}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 shrink-0 pl-4 pr-2">
|
||||
@@ -808,7 +958,11 @@ 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-2">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
{/* Empty-state copy is reserved for a definitive zero-
|
||||
session answer from the backend: before the first
|
||||
response (or while a failed fetch is being retried)
|
||||
"No sessions found" would read as lost history. */}
|
||||
{!hasLoadedHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
</div>
|
||||
@@ -957,8 +1111,11 @@ export function AgentSidebar({
|
||||
className={cn(
|
||||
"size-9 shrink-0 justify-center px-0",
|
||||
view === "settings" &&
|
||||
settingsSection !== "Account" &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
(settingsSection !== "Account"
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: // Clicking the gear is a no-op while the Account (profile)
|
||||
// screen is open, so don't hint interactivity on hover.
|
||||
"hover:bg-transparent hover:text-muted-foreground"),
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
@@ -1037,6 +1194,421 @@ export function AgentSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaSection({
|
||||
tasks,
|
||||
workspaceRoot,
|
||||
isLoading,
|
||||
error,
|
||||
pendingTaskIds,
|
||||
automatic,
|
||||
automationDisabled,
|
||||
onApprove,
|
||||
onRun,
|
||||
onOpen,
|
||||
onCancel,
|
||||
onToggleAutomation,
|
||||
onCreate,
|
||||
}: {
|
||||
tasks: AgendaTaskRecord[];
|
||||
workspaceRoot?: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pendingTaskIds: ReadonlySet<string>;
|
||||
automatic: boolean;
|
||||
automationDisabled: boolean;
|
||||
onApprove: (task: AgendaTaskRecord) => Promise<AgendaTaskRecord>;
|
||||
onRun: (task: AgendaTaskRecord) => void;
|
||||
onOpen: (task: AgendaTaskRecord) => void;
|
||||
onCancel: (task: AgendaTaskRecord) => void | Promise<void>;
|
||||
onToggleAutomation: () => void;
|
||||
onCreate: (input: HubTaskCreateInput) => Promise<AgendaTaskRecord>;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [type, setType] = useState<AgendaTaskType>("todo");
|
||||
const [priority, setPriority] = useState<AgendaTaskPriority>(3);
|
||||
const [scope, setScope] = useState<"workspace" | "global">(
|
||||
workspaceRoot ? "workspace" : "global",
|
||||
);
|
||||
const [expiresAt, setExpiresAt] = useState(() =>
|
||||
new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16),
|
||||
);
|
||||
const resetCreateForm = () => {
|
||||
setTitle("");
|
||||
setInstructions("");
|
||||
setType("todo");
|
||||
setPriority(3);
|
||||
setScope(workspaceRoot ? "workspace" : "global");
|
||||
setExpiresAt(
|
||||
new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16),
|
||||
);
|
||||
};
|
||||
const submitCreate = async () => {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedInstructions = instructions.trim();
|
||||
if (!normalizedTitle || !normalizedInstructions || !expiresAt) return;
|
||||
const expiration = new Date(expiresAt);
|
||||
if (Number.isNaN(expiration.getTime())) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const rememberedModel = readModelSelectionStorageFromWindow();
|
||||
const providerId = rememberedModel.lastProvider.trim();
|
||||
const modelId = providerId
|
||||
? rememberedModel.lastModelByProvider[providerId]?.trim()
|
||||
: undefined;
|
||||
await onCreate({
|
||||
type,
|
||||
title: normalizedTitle,
|
||||
instructions: normalizedInstructions,
|
||||
scope: scope === "workspace" && workspaceRoot ? "workspace" : "global",
|
||||
workspaceRoot:
|
||||
scope === "workspace" && workspaceRoot ? workspaceRoot : undefined,
|
||||
priority,
|
||||
modelSelection: providerId
|
||||
? { providerId, ...(modelId ? { modelId } : {}) }
|
||||
: undefined,
|
||||
expiresAt: expiration.toISOString(),
|
||||
automationEligible: true,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
resetCreateForm();
|
||||
} catch {
|
||||
// The Agenda hook surfaces the manager's structured error inline.
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
aria-label="Agenda"
|
||||
className="mt-4 flex min-h-0 flex-1 flex-col px-2"
|
||||
>
|
||||
<div className="flex h-8 items-center justify-between px-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Todo</span>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label={
|
||||
automatic ? "Pause Agenda automation" : "Automate Agenda"
|
||||
}
|
||||
aria-pressed={automatic}
|
||||
className={cn(
|
||||
"size-7 p-0 text-muted-foreground",
|
||||
automatic && "text-emerald-500",
|
||||
)}
|
||||
disabled={automationDisabled}
|
||||
onClick={onToggleAutomation}
|
||||
title={
|
||||
automatic
|
||||
? "Auto mode: click to switch to manual"
|
||||
: "Manual mode: click to automate eligible trusted work"
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{automationDisabled ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className={cn("size-3.5", automatic && "fill-current")} />
|
||||
)}
|
||||
</Button>
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (open) setScope(workspaceRoot ? "workspace" : "global");
|
||||
}}
|
||||
open={createOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
aria-label="Create Todo item"
|
||||
className="size-7 p-0 text-muted-foreground"
|
||||
title="Create task"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Todo Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<label
|
||||
className="block space-y-1 text-xs"
|
||||
htmlFor="agenda-task-title"
|
||||
>
|
||||
<span className="text-muted-foreground">Title</span>
|
||||
<Input
|
||||
autoFocus
|
||||
id="agenda-task-title"
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="What needs attention?"
|
||||
value={title}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="block space-y-1 text-xs"
|
||||
htmlFor="agenda-task-instructions"
|
||||
>
|
||||
<span className="text-muted-foreground">Instructions</span>
|
||||
<Textarea
|
||||
id="agenda-task-instructions"
|
||||
onChange={(event) => setInstructions(event.target.value)}
|
||||
placeholder="Describe the outcome and any relevant files."
|
||||
rows={4}
|
||||
value={instructions}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2"
|
||||
onChange={(event) =>
|
||||
setType(event.target.value as AgendaTaskType)
|
||||
}
|
||||
value={type}
|
||||
>
|
||||
{[
|
||||
"todo",
|
||||
"follow-up",
|
||||
"suggestion",
|
||||
"handoff",
|
||||
"idea",
|
||||
"reminder",
|
||||
].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Priority</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2"
|
||||
onChange={(event) =>
|
||||
setPriority(
|
||||
Number(event.target.value) as AgendaTaskPriority,
|
||||
)
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
P{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Scope</span>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2"
|
||||
onChange={(event) =>
|
||||
setScope(event.target.value as "workspace" | "global")
|
||||
}
|
||||
value={scope}
|
||||
>
|
||||
{workspaceRoot ? (
|
||||
<option value="workspace">Project</option>
|
||||
) : null}
|
||||
<option value="global">General</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
className="block space-y-1 text-xs"
|
||||
htmlFor="agenda-task-expires-at"
|
||||
>
|
||||
<span className="text-muted-foreground">Expires</span>
|
||||
<Input
|
||||
id="agenda-task-expires-at"
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={
|
||||
creating ||
|
||||
!title.trim() ||
|
||||
!instructions.trim() ||
|
||||
!expiresAt
|
||||
}
|
||||
onClick={() => void submitCreate()}
|
||||
type="button"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Add to Agenda
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{/* Radix ScrollArea wraps its children in a display:table element. A long
|
||||
task title can therefore widen the table beyond the sidebar and push the
|
||||
action buttons off-screen, so this fixed-width list uses native scrolling. */}
|
||||
<div className="min-h-0 w-full min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
<div className="w-full min-w-0 max-w-full space-y-1">
|
||||
{isLoading && tasks.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading Agenda…
|
||||
</div>
|
||||
) : null}
|
||||
{tasks.map((task) => {
|
||||
const pending = pendingTaskIds.has(task.taskId);
|
||||
const requiresFileReview =
|
||||
task.updatedBy.kind === "system" &&
|
||||
task.updatedBy.id === "file_reconciler" &&
|
||||
task.status === "pending_approval";
|
||||
const canOpen = Boolean(task.lastSessionId);
|
||||
const canRun =
|
||||
task.status === "approved" || task.status === "failed";
|
||||
const taskWorkspaceName =
|
||||
task.scope === "workspace"
|
||||
? workspaceDisplayName(task.workspaceRoot ?? task.cwd ?? "") ||
|
||||
"Workspace"
|
||||
: "General";
|
||||
return (
|
||||
<div
|
||||
className="group flex w-full min-w-0 max-w-full flex-col items-stretch overflow-hidden rounded-md px-2 py-1.5 hover:bg-surface-hover"
|
||||
key={task.taskId}
|
||||
title={
|
||||
requiresFileReview
|
||||
? "File-created or edited tasks always require manual review."
|
||||
: task.description || task.instructions
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="w-full min-w-0 overflow-hidden text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={() => setReviewTask(task)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block truncate text-xs text-sidebar-foreground">
|
||||
{task.title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center justify-between">
|
||||
<span className="min-w-0 truncate text-[10px] capitalize text-muted-foreground">
|
||||
{taskWorkspaceName}
|
||||
{task.status !== "pending_approval"
|
||||
? ` · ${task.status.replace("_", " ")}`
|
||||
: ""}
|
||||
{requiresFileReview ? " · file review" : ""}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center">
|
||||
{pending ? (
|
||||
<Loader2 className="mx-1 size-3 animate-spin text-muted-foreground" />
|
||||
) : task.status === "pending_approval" ? (
|
||||
<AgendaIconButton
|
||||
className="text-emerald-500! hover:text-emerald-400!"
|
||||
icon={<Check className="size-3" />}
|
||||
label={`Approve ${task.title}`}
|
||||
onClick={() => setReviewTask(task)}
|
||||
/>
|
||||
) : canRun ? (
|
||||
<AgendaIconButton
|
||||
icon={<Play className="size-3 fill-current" />}
|
||||
label={`Run ${task.title}`}
|
||||
onClick={() => onRun(task)}
|
||||
/>
|
||||
) : canOpen ? (
|
||||
<AgendaIconButton
|
||||
icon={<ChevronRight className="size-3" />}
|
||||
label={`Open session for ${task.title}`}
|
||||
onClick={() => onOpen(task)}
|
||||
/>
|
||||
) : null}
|
||||
{!pending ? (
|
||||
<AgendaIconButton
|
||||
className="text-destructive! hover:text-destructive!"
|
||||
icon={<X className="size-3" />}
|
||||
label={`Cancel ${task.title}`}
|
||||
onClick={() => onCancel(task)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isLoading && tasks.length === 0 && !error ? (
|
||||
<p className="px-2 py-1 text-[11px] text-muted-foreground">
|
||||
Nothing waiting for review.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
className="truncate px-2 py-1 text-[11px] text-destructive"
|
||||
title={error}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AgendaTaskReviewDialog
|
||||
onConfirm={async (task) => {
|
||||
try {
|
||||
await onApprove(task);
|
||||
setReviewTask(null);
|
||||
} catch {
|
||||
// The Agenda hook keeps the manager error visible in this section.
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReviewTask(null);
|
||||
}}
|
||||
onReject={async (task) => {
|
||||
await onCancel(task);
|
||||
setReviewTask(null);
|
||||
}}
|
||||
open={reviewTask !== null}
|
||||
pending={reviewTask ? pendingTaskIds.has(reviewTask.taskId) : false}
|
||||
task={reviewTask}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaIconButton({
|
||||
className,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
className?: string;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-background/70 hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({
|
||||
label,
|
||||
collapsed,
|
||||
|
||||
@@ -6,11 +6,11 @@ export function ClineLogo({ className }: { className?: string }) {
|
||||
aria-hidden="true"
|
||||
className={cn("inline-block shrink-0 bg-current", className)}
|
||||
style={{
|
||||
maskImage: "url('/cline-logo-filled.svg')",
|
||||
maskImage: "url('/icon.svg')",
|
||||
maskPosition: "center",
|
||||
maskRepeat: "no-repeat",
|
||||
maskSize: "contain",
|
||||
WebkitMaskImage: "url('/cline-logo-filled.svg')",
|
||||
WebkitMaskImage: "url('/icon.svg')",
|
||||
WebkitMaskPosition: "center",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskSize: "contain",
|
||||
|
||||
@@ -104,8 +104,8 @@ export function HubUpdateRequiredDialog() {
|
||||
{mismatch?.hubCoreVersion
|
||||
? ` (core ${mismatch.hubCoreVersion})`
|
||||
: ""}
|
||||
, and it no longer matches this app. Update and restart Cline Code
|
||||
to stay in sync with the running Hub.
|
||||
, and it no longer matches this app. Update and restart Cline to
|
||||
stay in sync with the running Hub.
|
||||
</AlertDialogDescription>
|
||||
{updateHint ? (
|
||||
<AlertDialogDescription>{updateHint}</AlertDialogDescription>
|
||||
|
||||
@@ -238,6 +238,34 @@ describe("MemoizedMarkdown interactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// With lineNumbers off, Streamdown emits one bare inline <span> per Shiki
|
||||
// token line with no newline text between non-empty lines; the shared
|
||||
// @cline/ui markdown.css turns those direct line spans into blocks. This
|
||||
// asserts the one-span-per-line structure that CSS contract depends on,
|
||||
// after the async Shiki highlight replaces the raw fallback render (the
|
||||
// SSR tests never exercise that client-side path).
|
||||
test("keeps highlighted code lines as separate line spans", async () => {
|
||||
await renderMarkdown({
|
||||
content: "```typescript\nconst a = 1;\nconst b = 2;\nconst c = 3;\n```",
|
||||
});
|
||||
|
||||
const code = await vi.waitFor(() => {
|
||||
const rendered = container.querySelector<HTMLElement>(
|
||||
'[data-streamdown="code-block-body"] code',
|
||||
);
|
||||
expect(rendered).not.toBeNull();
|
||||
// Styled token spans only appear once the highlighter callback lands.
|
||||
expect(rendered?.querySelector("span > span[style]")).not.toBeNull();
|
||||
return rendered as HTMLElement;
|
||||
});
|
||||
|
||||
expect([...code.children].map((line) => line.textContent)).toEqual([
|
||||
"const a = 1;",
|
||||
"const b = 2;",
|
||||
"const c = 3;",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rerenders incomplete streaming Markdown as completed static Markdown", async () => {
|
||||
await renderMarkdown({
|
||||
content: "```text\nconst answer =",
|
||||
|
||||
@@ -1085,7 +1085,8 @@ describe("ChatInputBar", () => {
|
||||
expect(
|
||||
document.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
|
||||
).toHaveLength(1);
|
||||
expect(document.body.textContent).toContain("refreshed-model");
|
||||
// The picker labels models by display name, not raw id.
|
||||
expect(document.body.textContent).toContain("Refreshed model");
|
||||
const modelMenuTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
@@ -1655,6 +1656,329 @@ describe("ChatInputBar", () => {
|
||||
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("renders the cline model picker with recommended and free sections", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: {
|
||||
cline: [
|
||||
"anthropic/claude-opus-5",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"zzz/other-model",
|
||||
],
|
||||
},
|
||||
// Tier data arrives on the models themselves, stamped by the SDK.
|
||||
providerModelDetails: {
|
||||
cline: [
|
||||
{
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
description: "Most intelligent model",
|
||||
featured: { tier: "recommended", rank: 0, tags: ["NEW"] },
|
||||
},
|
||||
{
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
description: "Fast and efficient",
|
||||
featured: { tier: "free", rank: 0, tags: [] },
|
||||
},
|
||||
{ id: "zzz/other-model", name: "Other Model" },
|
||||
],
|
||||
},
|
||||
providerNames: { cline: "Cline" },
|
||||
providerReasoningModels: { cline: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="anthropic/claude-opus-5"
|
||||
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()}
|
||||
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={false}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The beta composer nests the provider and model pickers inside the
|
||||
// Model settings menu; the provider trigger uses the catalog display
|
||||
// name, the model trigger the model's display name.
|
||||
const modelSettings = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
await act(async () => modelSettings?.click());
|
||||
await vi.waitFor(() => {
|
||||
const providerTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
expect(providerTrigger?.textContent).toContain("Cline");
|
||||
});
|
||||
const modelTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
expect(modelTrigger?.textContent).toContain("Claude Opus 5");
|
||||
|
||||
await act(async () => modelTrigger?.click());
|
||||
const panel = [...document.querySelectorAll('[role="dialog"]')].find(
|
||||
(dialog) => dialog.querySelector('[role="option"]'),
|
||||
);
|
||||
expect(panel?.textContent).toContain("Recommended");
|
||||
expect(panel?.textContent).toContain("Free");
|
||||
expect(panel?.textContent).toContain("All models");
|
||||
expect(panel?.textContent).toContain("Most intelligent model");
|
||||
expect(
|
||||
panel?.querySelector(".cline-ui-search-combobox__badge")?.textContent,
|
||||
).toBe("NEW");
|
||||
// Featured entries lead; the rest of the catalog follows.
|
||||
const optionLabels = [
|
||||
...(panel?.querySelectorAll('[role="option"]') ?? []),
|
||||
].map((option) => option.textContent);
|
||||
expect(optionLabels[0]).toContain("Claude Opus 5");
|
||||
expect(optionLabels[1]).toContain("DeepSeek V4 Flash");
|
||||
expect(optionLabels[2]).toContain("Other Model");
|
||||
});
|
||||
|
||||
describe("cline-pass picker offer", () => {
|
||||
const renderComposer = async (props: {
|
||||
model: string;
|
||||
provider: string;
|
||||
onModelChange?: ReturnType<typeof vi.fn>;
|
||||
onProviderChange?: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model={props.model}
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={props.onModelChange ?? vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={props.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={props.provider}
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking={false}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderModelCatalogMock).toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// The ClinePass offer: one subscribed and one free model, stamped
|
||||
// by the SDK onto ProviderModel.featured. The catalog additionally
|
||||
// contains a stale unstamped model outside the offer, which the
|
||||
// picker hides while the subscribed tier is non-empty.
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline", "cline-pass"],
|
||||
providerModels: {
|
||||
cline: ["test-model"],
|
||||
"cline-pass": [
|
||||
"openai/gpt-5",
|
||||
"google/gemini-flash",
|
||||
"legacy/stale-model",
|
||||
],
|
||||
},
|
||||
providerModelDetails: {
|
||||
"cline-pass": [
|
||||
{
|
||||
id: "openai/gpt-5",
|
||||
name: "GPT-5",
|
||||
featured: { tier: "subscribed", rank: 0, tags: [] },
|
||||
},
|
||||
{
|
||||
id: "google/gemini-flash",
|
||||
name: "Gemini Flash",
|
||||
featured: { tier: "free", rank: 0, tags: [] },
|
||||
},
|
||||
{ id: "legacy/stale-model", name: "Stale Legacy" },
|
||||
],
|
||||
},
|
||||
providerNames: { cline: "Cline", "cline-pass": "ClinePass" },
|
||||
providerReasoningModels: { cline: [], "cline-pass": [] },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("does not resurrect a stale remembered model the picker hides", async () => {
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline-pass",
|
||||
lastModelByProvider: { "cline-pass": "legacy/stale-model" },
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "",
|
||||
onModelChange,
|
||||
provider: "cline-pass",
|
||||
});
|
||||
|
||||
// The default selection must come from the visible offer, not the
|
||||
// hidden remembered id.
|
||||
await vi.waitFor(() => {
|
||||
expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5");
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalledWith("legacy/stale-model");
|
||||
|
||||
const modelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
await act(async () => modelTrigger?.click());
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
expect(panel?.textContent).not.toContain("Stale Legacy");
|
||||
expect(panel?.textContent).not.toContain("Current model");
|
||||
});
|
||||
|
||||
it("keeps an explicitly active out-of-offer model visible and selectable", async () => {
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "legacy/stale-model",
|
||||
onModelChange,
|
||||
provider: "cline-pass",
|
||||
});
|
||||
|
||||
// The session's configured model stays active…
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
const modelSettings = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
await act(async () => modelSettings?.click());
|
||||
const modelTrigger = await vi.waitFor(() => {
|
||||
const element = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
expect(element?.textContent).toContain("Stale Legacy");
|
||||
return element as HTMLButtonElement;
|
||||
});
|
||||
|
||||
// …and the picker surfaces it under its own section instead of
|
||||
// selecting a value that does not exist in the list.
|
||||
await act(async () => modelTrigger?.click());
|
||||
const panel = [...document.querySelectorAll('[role="dialog"]')].find(
|
||||
(dialog) => dialog.querySelector('[role="option"]'),
|
||||
);
|
||||
expect(panel?.textContent).toContain("Current model");
|
||||
const staleOption = [
|
||||
...(panel?.querySelectorAll<HTMLButtonElement>('[role="option"]') ??
|
||||
[]),
|
||||
].find((option) => option.textContent?.includes("Stale Legacy"));
|
||||
expect(staleOption?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to a visible model when switching providers with a hidden remembered model", async () => {
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: {
|
||||
cline: "test-model",
|
||||
"cline-pass": "legacy/stale-model",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
const onProviderChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "test-model",
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
provider: "cline",
|
||||
});
|
||||
|
||||
const modelSettings = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
await act(async () => modelSettings?.click());
|
||||
const providerTrigger = await vi.waitFor(() => {
|
||||
const element = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element as HTMLButtonElement;
|
||||
});
|
||||
await act(async () => providerTrigger?.click());
|
||||
const panel = [...document.querySelectorAll('[role="dialog"]')].find(
|
||||
(dialog) => dialog.querySelector('[role="option"]'),
|
||||
);
|
||||
const clinePassOption = [
|
||||
...(panel?.querySelectorAll<HTMLButtonElement>('[role="option"]') ??
|
||||
[]),
|
||||
].find((option) => option.textContent?.includes("ClinePass"));
|
||||
await act(async () => clinePassOption?.click());
|
||||
|
||||
expect(onProviderChange).toHaveBeenCalledWith("cline-pass");
|
||||
// The hidden remembered model is not restored; the selection falls
|
||||
// back to the offer and the remembered slot is repaired.
|
||||
expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
),
|
||||
).toEqual({
|
||||
lastProvider: "cline-pass",
|
||||
lastModelByProvider: {
|
||||
cline: "test-model",
|
||||
"cline-pass": "openai/gpt-5",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches clipboard images on paste instead of inserting text", async () => {
|
||||
const onAttachFiles = vi.fn();
|
||||
const onPromptInputChange = vi.fn();
|
||||
|
||||
@@ -43,6 +43,10 @@ import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
|
||||
import { cloudRepositoryLabel } from "@/lib/cloud-repositories";
|
||||
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildModelPickerData,
|
||||
type ModelPickerData,
|
||||
} from "@/lib/featured-models";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
@@ -56,6 +60,7 @@ import {
|
||||
type TranscriptionModelTarget,
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type { ProviderModel } from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
|
||||
@@ -1824,6 +1829,12 @@ const ModelSelector = memo(function ModelSelector({
|
||||
"loading" | "catalog" | "fallback"
|
||||
>("loading");
|
||||
const [enabledProviderIds, setEnabledProviderIds] = useState<string[]>([]);
|
||||
const [providerNames, setProviderNames] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [modelDetails, setModelDetails] = useState<
|
||||
Record<string, ProviderModel[]>
|
||||
>({});
|
||||
const [lastSelection, setLastSelection] = useState(() =>
|
||||
readModelSelectionStorageFromWindow(),
|
||||
);
|
||||
@@ -1860,6 +1871,30 @@ const ModelSelector = memo(function ModelSelector({
|
||||
() => visibleProviderModels[resolvedProvider] ?? [],
|
||||
[resolvedProvider, visibleProviderModels],
|
||||
);
|
||||
// Sectioned picker data: display names plus the Recommended/Free tiers the
|
||||
// SDK stamps onto cline/cline-pass models (ProviderModel.featured).
|
||||
const pickerDataForProvider = useCallback(
|
||||
(providerId: string): ModelPickerData => {
|
||||
const detailsById = new Map(
|
||||
(modelDetails[providerId] ?? []).map(
|
||||
(entry) => [entry.id, entry] as const,
|
||||
),
|
||||
);
|
||||
const models = (visibleProviderModels[providerId] ?? []).map(
|
||||
(id) => detailsById.get(id) ?? { id, name: id },
|
||||
);
|
||||
return buildModelPickerData(providerId, models);
|
||||
},
|
||||
[modelDetails, visibleProviderModels],
|
||||
);
|
||||
const modelPicker = useMemo(
|
||||
() => pickerDataForProvider(resolvedProvider),
|
||||
[pickerDataForProvider, resolvedProvider],
|
||||
);
|
||||
const pickerModelIds = useMemo(
|
||||
() => new Set(modelPicker.options.map((option) => option.value)),
|
||||
[modelPicker],
|
||||
);
|
||||
const resolvedModel = useMemo(() => {
|
||||
if (modelsForProvider.length === 0) {
|
||||
return "";
|
||||
@@ -1867,20 +1902,68 @@ const ModelSelector = memo(function ModelSelector({
|
||||
const rememberedModel =
|
||||
lastSelection.lastModelByProvider[resolvedProvider] ??
|
||||
lastSelection.lastModelByProvider[rememberedLastProvider];
|
||||
// An explicitly configured model stays active even when the picker's
|
||||
// offer hides it (the picker preserves it as a visible option below);
|
||||
// remembered and default selections are our own bookkeeping, so they
|
||||
// must resolve to a visible option — otherwise a stale remembered id
|
||||
// gets silently resurrected into a selection the picker cannot show.
|
||||
if (model && modelsForProvider.includes(model)) {
|
||||
return model;
|
||||
}
|
||||
if (rememberedModel && modelsForProvider.includes(rememberedModel)) {
|
||||
if (rememberedModel && pickerModelIds.has(rememberedModel)) {
|
||||
return rememberedModel;
|
||||
}
|
||||
return modelsForProvider[0] ?? "";
|
||||
return (
|
||||
modelsForProvider.find((id) => pickerModelIds.has(id)) ??
|
||||
modelsForProvider[0] ??
|
||||
""
|
||||
);
|
||||
}, [
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
modelsForProvider,
|
||||
pickerModelIds,
|
||||
rememberedLastProvider,
|
||||
resolvedProvider,
|
||||
]);
|
||||
// The picker can intentionally hide catalog models (the ClinePass offer
|
||||
// is exactly its subscribed/free tiers), but the active model must stay
|
||||
// visible and selectable — e.g. a hydrated session configured with a
|
||||
// model outside the current offer. Surface it under its own section
|
||||
// rather than selecting a value that does not exist in the list.
|
||||
const visibleModelPicker = useMemo((): ModelPickerData => {
|
||||
if (!resolvedModel || pickerModelIds.has(resolvedModel)) {
|
||||
return modelPicker;
|
||||
}
|
||||
const detail = (modelDetails[resolvedProvider] ?? []).find(
|
||||
(entry) => entry.id === resolvedModel,
|
||||
);
|
||||
const hasSections = (modelPicker.sections?.length ?? 0) > 0;
|
||||
return {
|
||||
options: [
|
||||
...modelPicker.options,
|
||||
{
|
||||
label: detail?.name?.trim() || resolvedModel,
|
||||
...(hasSections ? { section: "current" } : {}),
|
||||
value: resolvedModel,
|
||||
},
|
||||
],
|
||||
...(hasSections
|
||||
? {
|
||||
sections: [
|
||||
...(modelPicker.sections ?? []),
|
||||
{ id: "current", label: "Current model" },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
modelDetails,
|
||||
modelPicker,
|
||||
pickerModelIds,
|
||||
resolvedModel,
|
||||
resolvedProvider,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1894,6 +1977,14 @@ const ModelSelector = memo(function ModelSelector({
|
||||
}
|
||||
setProviderModels(payload.providerModels);
|
||||
setProviderReasoningModels(payload.providerReasoningModels);
|
||||
setProviderNames((current) => ({
|
||||
...current,
|
||||
...(payload.providerNames ?? {}),
|
||||
}));
|
||||
setModelDetails((current) => ({
|
||||
...current,
|
||||
...(payload.providerModelDetails ?? {}),
|
||||
}));
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) => {
|
||||
const nextProviderIds = new Set(payload.enabledProviderIds);
|
||||
@@ -1931,6 +2022,10 @@ const ModelSelector = memo(function ModelSelector({
|
||||
...current,
|
||||
[normalizedProvider]: reasoningModelIds,
|
||||
}));
|
||||
setModelDetails((current) => ({
|
||||
...current,
|
||||
[normalizedProvider]: models,
|
||||
}));
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) =>
|
||||
current.includes(normalizedProvider)
|
||||
@@ -1961,6 +2056,10 @@ const ModelSelector = memo(function ModelSelector({
|
||||
.filter((entry) => entry.supportsReasoning)
|
||||
.map((entry) => entry.id),
|
||||
}));
|
||||
setModelDetails((current) => ({
|
||||
...current,
|
||||
[normalizedId]: models,
|
||||
}));
|
||||
setEnabledProviderIds((current) =>
|
||||
current.includes(normalizedId) ? current : [...current, normalizedId],
|
||||
);
|
||||
@@ -2071,10 +2170,17 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
// Validate against the target provider's visible picker options,
|
||||
// not its full catalog: a remembered model the picker hides (e.g.
|
||||
// outside the ClinePass offer) must not become the selection.
|
||||
const providerOptionIds = new Set(
|
||||
pickerDataForProvider(value).options.map((option) => option.value),
|
||||
);
|
||||
const nextModel =
|
||||
rememberedModel && providerModelIds.includes(rememberedModel)
|
||||
rememberedModel && providerOptionIds.has(rememberedModel)
|
||||
? rememberedModel
|
||||
: providerModelIds[0];
|
||||
: (providerModelIds.find((id) => providerOptionIds.has(id)) ??
|
||||
providerModelIds[0]);
|
||||
rememberSelection(value, nextModel);
|
||||
if (nextModel && nextModel !== model) {
|
||||
onModelChange(nextModel);
|
||||
@@ -2085,6 +2191,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
model,
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
pickerDataForProvider,
|
||||
rememberSelection,
|
||||
visibleProviderModels,
|
||||
],
|
||||
@@ -2096,6 +2203,17 @@ const ModelSelector = memo(function ModelSelector({
|
||||
},
|
||||
[onModelChange, rememberSelection, resolvedProvider],
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
providers.map((value) => ({
|
||||
label: providerNames[value]?.trim() || value,
|
||||
value,
|
||||
})),
|
||||
[providerNames, providers],
|
||||
);
|
||||
const selectedModelLabel =
|
||||
visibleModelPicker.options.find((option) => option.value === resolvedModel)
|
||||
?.label ?? resolvedModel;
|
||||
const renderProviderSelect = (
|
||||
triggerClassName: string,
|
||||
placement: "top" | "right" | "bottom" | "left" = "top",
|
||||
@@ -2106,7 +2224,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyText="No providers found."
|
||||
onValueChange={handleProviderSelect}
|
||||
options={providers.map((value) => ({ label: value, value }))}
|
||||
options={providerOptions}
|
||||
placeholder="Provider"
|
||||
placement={placement}
|
||||
searchPlaceholder="Search providers"
|
||||
@@ -2129,10 +2247,12 @@ const ModelSelector = memo(function ModelSelector({
|
||||
handleModelSelect(value);
|
||||
if (closeMobileMenu) setMobileOpen(false);
|
||||
}}
|
||||
options={modelsForProvider.map((value) => ({ label: value, value }))}
|
||||
options={visibleModelPicker.options}
|
||||
panelWidth="20rem"
|
||||
placeholder="Model"
|
||||
placement={placement}
|
||||
searchPlaceholder="Search models"
|
||||
sections={visibleModelPicker.sections}
|
||||
value={resolvedModel}
|
||||
/>
|
||||
);
|
||||
@@ -2176,7 +2296,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
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"}`}
|
||||
title={`${providerNames[resolvedProvider]?.trim() || resolvedProvider || "Provider"} / ${selectedModelLabel || "Model"}`}
|
||||
type="button"
|
||||
>
|
||||
<Cpu className="size-3.5" />
|
||||
@@ -2190,9 +2310,9 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onClick={() => setMobileOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<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="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 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-1 motion-reduce:animate-none max-[560px]:block">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Provider
|
||||
</div>
|
||||
{renderProviderSelect(
|
||||
@@ -2200,7 +2320,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Model
|
||||
</div>
|
||||
{renderModelSelect(
|
||||
@@ -2212,9 +2332,12 @@ const ModelSelector = memo(function ModelSelector({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0 max-[560px]:hidden">
|
||||
{renderProviderSelect("max-w-28 text-[11px] text-muted-foreground")}
|
||||
{renderModelSelect("max-w-52 text-[11px] text-foreground")}
|
||||
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
|
||||
{/* Wide enough for the longest built-in provider names ("Cline
|
||||
Usage-Billing", "OpenAI ChatGPT Subscription") untruncated. */}
|
||||
{renderProviderSelect("max-w-56")}
|
||||
<div className="bg-border-2 h-4 w-[0.1rem]" />
|
||||
{renderModelSelect("max-w-52")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function CloudHandoffProgress({
|
||||
{message?.trim() || HANDOFF_PROGRESS_LABELS[phase]}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
You can use the rest of Cline Code while this finishes.
|
||||
You can use the rest of Cline while this finishes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -365,6 +365,61 @@ describe("collapseCompletedWork", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts tool execution time when pre-tool thinking attaches to the answer", () => {
|
||||
// Canonical projection of a thinking-only assistant message that issued
|
||||
// a tool call: the tool row and the reasoning-only row are both stamped
|
||||
// with pre-execution timestamps, and the reasoning row attaches to the
|
||||
// run's final answer in groupChatMessages. The duration must span to
|
||||
// the answer itself (14_500), not to the pre-tool thinking (6_001) —
|
||||
// that would exclude the entire tool execution from "Worked for".
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "run it",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 6_000),
|
||||
makeMessage({
|
||||
id: "r1",
|
||||
reasoning: "planning the command",
|
||||
createdAt: 6_001,
|
||||
}),
|
||||
makeMessage({ id: "a1", content: "Done.", createdAt: 14_500 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
const work = items.find((item) => item.type === "work");
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(1);
|
||||
expect(work.durationMilliseconds).toBe(13_500);
|
||||
});
|
||||
|
||||
it("clamps the duration to the collapsed rows when the answer's timestamp is earlier", () => {
|
||||
// A fallback answer bubble can be minted with a synthetic timestamp
|
||||
// near the send time (RPC completion path); it must not shrink the
|
||||
// duration below the work the run demonstrably performed.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 5_000),
|
||||
makeMessage({ id: "a1", content: "Done.", createdAt: 1_001 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
const work = items.find((item) => item.type === "work");
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.durationMilliseconds).toBe(4_000);
|
||||
});
|
||||
|
||||
it("measures duration from the first working row when no user message precedes it", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
|
||||
@@ -141,6 +141,19 @@ function lastTimestamp(item: ChatRenderItem): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function maxFiniteTimestamp(
|
||||
...values: Array<number | undefined>
|
||||
): number | undefined {
|
||||
let max: number | undefined;
|
||||
for (const value of values) {
|
||||
if (value === undefined || !Number.isFinite(value)) continue;
|
||||
if (max === undefined || value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function firstMessageId(item: ChatRenderItem): string | undefined {
|
||||
if (item.type === "tools") return item.messages[0]?.id;
|
||||
if (item.type === "message") {
|
||||
@@ -223,9 +236,19 @@ export function collapseCompletedWork(
|
||||
const startTimestamp =
|
||||
runStartTimestamp ?? firstTimestamp(firstCollapsed);
|
||||
const lastCollapsed = collapsed[collapsed.length - 1];
|
||||
const endTimestamp = answer
|
||||
? firstTimestamp(answer)
|
||||
: lastTimestamp(lastCollapsed);
|
||||
// The work span ends where the run's answer begins — anchored on the
|
||||
// answer row's own timestamp, not its earliest attached reasoning
|
||||
// row: canonical history projects a thinking-only message that
|
||||
// issued a tool call as a reasoning row stamped before the tool
|
||||
// executed, and that row rides on the answer's reasoningMessages,
|
||||
// which would exclude the entire tool execution from "Worked for".
|
||||
// Clamping to the last collapsed row keeps a fallback answer bubble
|
||||
// minted with an early synthetic timestamp from shrinking the
|
||||
// duration below the work the run demonstrably performed.
|
||||
const endTimestamp = maxFiniteTimestamp(
|
||||
answer ? lastTimestamp(answer) : undefined,
|
||||
lastTimestamp(lastCollapsed),
|
||||
);
|
||||
const durationMilliseconds =
|
||||
startTimestamp !== undefined &&
|
||||
endTimestamp !== undefined &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import type { ComponentProps } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
@@ -18,10 +19,19 @@ const { invokeMock, subscribeMock, accountRef } = vi.hoisted(() => ({
|
||||
accountRef: { user: null as { id: string } | null },
|
||||
}));
|
||||
|
||||
const listAgendaTasksMock = vi.hoisted(() => vi.fn());
|
||||
const approveAgendaTaskMock = vi.hoisted(() => vi.fn());
|
||||
const runAgendaTaskMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
invoke: invokeMock,
|
||||
listAgendaTasks: listAgendaTasksMock,
|
||||
approveAgendaTask: approveAgendaTaskMock,
|
||||
cancelAgendaTask: vi.fn(),
|
||||
runAgendaTask: runAgendaTaskMock,
|
||||
subscribe: subscribeMock,
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
openExternalUrl: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -46,6 +56,10 @@ beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
listAgendaTasksMock.mockReset();
|
||||
listAgendaTasksMock.mockResolvedValue([]);
|
||||
approveAgendaTaskMock.mockReset();
|
||||
runAgendaTaskMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -63,6 +77,7 @@ async function renderWelcomeScreen({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
})),
|
||||
onOpenSession = vi.fn(),
|
||||
...cloudProps
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
@@ -73,6 +88,7 @@ async function renderWelcomeScreen({
|
||||
current: string;
|
||||
branches: string[];
|
||||
}>;
|
||||
onOpenSession?: (sessionId: string) => void | Promise<void>;
|
||||
} & Partial<ComponentProps<typeof WelcomeScreen>>): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -93,6 +109,7 @@ async function renderWelcomeScreen({
|
||||
composer={null}
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onOpenSession={onOpenSession}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
{...cloudProps}
|
||||
/>
|
||||
@@ -102,9 +119,13 @@ async function renderWelcomeScreen({
|
||||
});
|
||||
}
|
||||
|
||||
async function clickButton(text: string, last = false): Promise<void> {
|
||||
async function clickButton(
|
||||
text: string,
|
||||
last = false,
|
||||
rootNode: ParentNode = container,
|
||||
): Promise<void> {
|
||||
const buttons = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].filter((candidate) => candidate.textContent?.includes(text));
|
||||
const button = last ? buttons.at(-1) : buttons[0];
|
||||
expect(button).toBeDefined();
|
||||
@@ -129,120 +150,7 @@ describe("WelcomeScreen", () => {
|
||||
expect(composerWrapper?.classList.contains("max-w-full")).toBe(true);
|
||||
});
|
||||
|
||||
// Prompt suggestions (quick-action cards, including "Review changes") are
|
||||
// temporarily disabled while we improve them; see welcome-chat.tsx.
|
||||
// Re-enable these tests when the suggestions come back.
|
||||
//
|
||||
// it("starts chat with the selected quick-action prompt", async () => {
|
||||
// const onStartChat = vi.fn();
|
||||
// await renderWelcomeScreen({
|
||||
// onStartChat,
|
||||
// workspaceRoot: "/projects/project-1",
|
||||
// workspaces: ["/projects/project-1"],
|
||||
// });
|
||||
//
|
||||
// await clickButton("Check for build errors");
|
||||
//
|
||||
// expect(onStartChat).toHaveBeenCalledWith(
|
||||
// "Check this project for build errors and help me fix any failures.",
|
||||
// );
|
||||
// });
|
||||
//
|
||||
// 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("does not render prompt suggestions while they are disabled", async () => {
|
||||
it("does not render static prompt suggestions", async () => {
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: "main",
|
||||
workspaceRoot: "/projects/project-1",
|
||||
@@ -255,6 +163,78 @@ describe("WelcomeScreen", () => {
|
||||
expect(container.textContent).not.toContain("Draft a document");
|
||||
});
|
||||
|
||||
it("shows live workspace suggestions and approves them before starting", async () => {
|
||||
const task = agendaTask({ status: "pending_approval" });
|
||||
const approved = { ...task, status: "approved" as const, revision: 2 };
|
||||
const running = {
|
||||
...approved,
|
||||
status: "in_progress" as const,
|
||||
lastSessionId: "task-session-1",
|
||||
};
|
||||
const onOpenSession = vi.fn();
|
||||
listAgendaTasksMock.mockResolvedValue([task]);
|
||||
approveAgendaTaskMock.mockResolvedValue(approved);
|
||||
runAgendaTaskMock.mockResolvedValue({ task: running });
|
||||
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
onOpenSession,
|
||||
});
|
||||
expect(listAgendaTasksMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/project-1",
|
||||
types: ["suggestion", "reminder", "follow-up"],
|
||||
}),
|
||||
);
|
||||
await clickButton("Review PR checks");
|
||||
expect(approveAgendaTaskMock).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain(task.instructions);
|
||||
await clickButton("Approve and start", false, document);
|
||||
|
||||
expect(approveAgendaTaskMock).toHaveBeenCalledWith({
|
||||
taskId: "task-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
expect(runAgendaTaskMock).toHaveBeenCalledWith({
|
||||
taskId: "task-1",
|
||||
expectedRevision: 2,
|
||||
});
|
||||
expect(onOpenSession).toHaveBeenCalledWith("task-session-1");
|
||||
});
|
||||
|
||||
it("shows workspace follow-up items", async () => {
|
||||
listAgendaTasksMock.mockResolvedValue([
|
||||
agendaTask({
|
||||
type: "follow-up",
|
||||
title: "Finish accessibility review",
|
||||
description: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Finish accessibility review");
|
||||
expect(container.textContent).toContain("Follow-up · P1");
|
||||
});
|
||||
|
||||
it("hides expired workspace suggestions", async () => {
|
||||
listAgendaTasksMock.mockResolvedValue([
|
||||
agendaTask({ expiresAt: "2020-01-01T00:00:00.000Z" }),
|
||||
]);
|
||||
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Review PR checks");
|
||||
});
|
||||
|
||||
it("renders every known project in the opened workspace menu", async () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
@@ -411,3 +391,29 @@ describe("WelcomeScreen", () => {
|
||||
expect(selectChat).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function agendaTask(
|
||||
overrides: Partial<AgendaTaskRecord> = {},
|
||||
): AgendaTaskRecord {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
type: "suggestion",
|
||||
status: "pending_approval",
|
||||
title: "Review PR checks",
|
||||
description: "Check whether CI is green.",
|
||||
instructions: "Review the pull request checks.",
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/project-1",
|
||||
resourcePaths: [],
|
||||
priority: 1,
|
||||
availableAt: "2026-08-13T00:00:00.000Z",
|
||||
expiresAt: "2099-08-20T00:00:00.000Z",
|
||||
automationEligible: true,
|
||||
revision: 1,
|
||||
createdBy: { kind: "agent" },
|
||||
updatedBy: { kind: "agent" },
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared/browser";
|
||||
import { AgentAurora, AgentHeroHeading } from "@cline/ui";
|
||||
import {
|
||||
AgentAurora,
|
||||
AgentHeroHeading,
|
||||
type AgentQuickAction,
|
||||
AgentQuickActions,
|
||||
} from "@cline/ui";
|
||||
import { Cloud } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
|
||||
import {
|
||||
type CloudBranchListOptions,
|
||||
type CloudBranchListResult,
|
||||
@@ -58,6 +66,7 @@ export function WelcomeScreen({
|
||||
onCloudBranchChange = () => undefined,
|
||||
cloudAgentsEnabled = false,
|
||||
environmentSelector,
|
||||
onOpenSession,
|
||||
}: {
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
@@ -75,6 +84,7 @@ export function WelcomeScreen({
|
||||
onCloudBranchChange?: (branch: string) => void;
|
||||
cloudAgentsEnabled?: boolean;
|
||||
environmentSelector?: ReactNode;
|
||||
onOpenSession?: (sessionId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const { user, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
@@ -213,6 +223,66 @@ export function WelcomeScreen({
|
||||
});
|
||||
}, [checkCloudSetup, cloudModeActive, signedIn]);
|
||||
|
||||
const agenda = useAgendaTasks(
|
||||
{
|
||||
scope: "workspace",
|
||||
workspaceRoot,
|
||||
types: ["suggestion", "reminder", "follow-up"],
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
limit: 8,
|
||||
},
|
||||
active && workspaceRoot.trim().length > 0,
|
||||
);
|
||||
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
|
||||
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
|
||||
const quickActionTasks = useMemo(
|
||||
() => agenda.tasks.filter((task) => !isAgendaTaskExpired(task)).slice(0, 4),
|
||||
[agenda.tasks],
|
||||
);
|
||||
const actions = useMemo<AgentQuickAction[]>(
|
||||
() =>
|
||||
quickActionTasks.map((task) => ({
|
||||
id: task.taskId,
|
||||
label: task.title,
|
||||
description:
|
||||
task.description ||
|
||||
`${task.type === "follow-up" ? "Follow-up" : task.type === "reminder" ? "Reminder" : "Suggestion"} · P${task.priority}`,
|
||||
value: task.instructions,
|
||||
})),
|
||||
[quickActionTasks],
|
||||
);
|
||||
|
||||
const handleTaskAction = useCallback(
|
||||
async (task: AgendaTaskRecord) => {
|
||||
setRunningTaskId(task.taskId);
|
||||
try {
|
||||
if (task.status === "in_progress" && task.lastSessionId) {
|
||||
await onOpenSession?.(task.lastSessionId);
|
||||
return;
|
||||
}
|
||||
let runnable = task;
|
||||
if (runnable.status === "pending_approval") {
|
||||
runnable = await agenda.approveTask(runnable);
|
||||
}
|
||||
if (runnable.status === "in_progress" && runnable.lastSessionId) {
|
||||
await onOpenSession?.(runnable.lastSessionId);
|
||||
return;
|
||||
}
|
||||
if (runnable.status === "approved" || runnable.status === "failed") {
|
||||
const started = await agenda.runTask(runnable);
|
||||
if (started.lastSessionId) {
|
||||
await onOpenSession?.(started.lastSessionId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// useAgendaTasks renders the command failure with the quick actions.
|
||||
} finally {
|
||||
setRunningTaskId(null);
|
||||
}
|
||||
},
|
||||
[agenda.approveTask, agenda.runTask, onOpenSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && executionTarget === "local") void refreshWorkspaces();
|
||||
}, [active, executionTarget, refreshWorkspaces]);
|
||||
@@ -377,6 +447,45 @@ export function WelcomeScreen({
|
||||
going even when you close the app.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<>
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
className="cline-view-enter mt-11"
|
||||
disabled={runningTaskId !== null}
|
||||
onSelect={(action) => {
|
||||
const task = quickActionTasks.find(
|
||||
(candidate) => candidate.taskId === action.id,
|
||||
);
|
||||
if (!task) return;
|
||||
if (task.status === "pending_approval") {
|
||||
setReviewTask(task);
|
||||
} else {
|
||||
void handleTaskAction(task);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AgendaTaskReviewDialog
|
||||
confirmLabel="Approve and start"
|
||||
onConfirm={async (task) => {
|
||||
await handleTaskAction(task);
|
||||
setReviewTask(null);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReviewTask(null);
|
||||
}}
|
||||
open={reviewTask !== null}
|
||||
pending={runningTaskId === reviewTask?.taskId}
|
||||
task={reviewTask}
|
||||
/>
|
||||
{agenda.error ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
{agenda.error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
normalizeCloudRepositoryUrl,
|
||||
preferredCloudBranch,
|
||||
} from "@/lib/cloud-repositories";
|
||||
import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
@@ -61,7 +62,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 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";
|
||||
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl animate-in fade-in-0 zoom-in-95 slide-in-from-top-1 motion-reduce:animate-none";
|
||||
|
||||
function CloudRepositoryPicker({
|
||||
open,
|
||||
@@ -485,26 +486,24 @@ function SearchInput({
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
// Search row styled to match the composer's model picker.
|
||||
return (
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={placeholder}
|
||||
autoComplete="off"
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
name={placeholder.toLowerCase().replaceAll(/[^a-z]+/g, "-")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={placeholder}
|
||||
autoComplete="off"
|
||||
className="h-8 flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
name={placeholder.toLowerCase().replaceAll(/[^a-z]+/g, "-")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,6 +532,7 @@ function WorkspacePicker({
|
||||
const [search, setSearch] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const workspaceListRef = useRef<HTMLDivElement>(null);
|
||||
const [selectingChat, setSelectingChat] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isChatWorkspace =
|
||||
@@ -559,6 +559,11 @@ function WorkspacePicker({
|
||||
void refreshWorkspacesRef.current();
|
||||
}, [open]);
|
||||
|
||||
// Start the freshly opened list at the active workspace, not the top.
|
||||
useEffect(() => {
|
||||
if (open) scrollCurrentOptionIntoView(workspaceListRef.current);
|
||||
}, [open]);
|
||||
|
||||
// The active workspace can be an excluded path (restored session, process
|
||||
// cwd fallback); register it explicitly so it stays visible while active.
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
@@ -669,7 +674,10 @@ function WorkspacePicker({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
|
||||
<div
|
||||
className="flex max-h-48 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={workspaceListRef}
|
||||
>
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{looksLikeFolderPath(search)
|
||||
@@ -685,9 +693,10 @@ function WorkspacePicker({
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
|
||||
isActive
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
? "bg-(--accent-4) hover:bg-(--accent-4)"
|
||||
: "hover:bg-surface-hover",
|
||||
)}
|
||||
data-current={isActive || undefined}
|
||||
disabled={switching}
|
||||
key={path}
|
||||
onClick={() => void handleSelect(path)}
|
||||
@@ -759,6 +768,12 @@ function BranchPicker({
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const branchListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Start the freshly opened list at the current branch, not the top.
|
||||
useEffect(() => {
|
||||
if (open && !loading) scrollCurrentOptionIntoView(branchListRef.current);
|
||||
}, [open, loading]);
|
||||
|
||||
// Load branches fresh each time the menu opens.
|
||||
useEffect(() => {
|
||||
@@ -823,7 +838,10 @@ function BranchPicker({
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
<div
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={branchListRef}
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
@@ -834,9 +852,10 @@ function BranchPicker({
|
||||
className={cn(
|
||||
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
? "bg-(--accent-4) hover:bg-(--accent-4)"
|
||||
: "hover:bg-surface-hover",
|
||||
)}
|
||||
data-current={currentBranch === branch || undefined}
|
||||
disabled={switching}
|
||||
key={branch}
|
||||
onClick={() => void handleSelect(branch)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
@@ -76,6 +77,15 @@ export function WorkspaceSelector({
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
const [showCreateBranch, setShowCreateBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const workspaceListRef = useRef<HTMLDivElement>(null);
|
||||
const branchListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Start freshly opened lists at the current workspace/branch, not the top.
|
||||
useEffect(() => {
|
||||
if (!open || loadingBranches) return;
|
||||
scrollCurrentOptionIntoView(workspaceListRef.current);
|
||||
scrollCurrentOptionIntoView(branchListRef.current);
|
||||
}, [open, loadingBranches]);
|
||||
|
||||
const workspaceName = useMemo(() => {
|
||||
if (isChatWorkspacePath(workspaceRoot)) {
|
||||
@@ -316,27 +326,24 @@ export function WorkspaceSelector({
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 z-50 w-72 rounded-lg border border-border bg-popover shadow-xl",
|
||||
placement === "bottom" ? "top-full mt-2" : "bottom-full mb-2",
|
||||
"absolute right-0 z-50 w-72 rounded-lg border border-border bg-popover shadow-xl animate-in fade-in-0 zoom-in-95 motion-reduce:animate-none",
|
||||
placement === "bottom"
|
||||
? "top-full mt-2 slide-in-from-top-1"
|
||||
: "bottom-full mb-2 slide-in-from-bottom-1",
|
||||
)}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 text-muted-foreground shrink-0" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
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>
|
||||
{/* Search row, styled to match the composer's model picker */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3">
|
||||
<Search className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={
|
||||
hasGit ? "Search workspaces & branches" : "Search workspaces"
|
||||
}
|
||||
className="h-8 flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingBranches ? (
|
||||
@@ -365,7 +372,10 @@ export function WorkspaceSelector({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
|
||||
<div
|
||||
ref={workspaceListRef}
|
||||
className="flex flex-col gap-0.5 max-h-28 overflow-y-auto"
|
||||
>
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{looksLikeFolderPath(search)
|
||||
@@ -381,6 +391,7 @@ export function WorkspaceSelector({
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={wp}
|
||||
data-current={isActive || undefined}
|
||||
disabled={switchingWorkspace}
|
||||
onClick={() => {
|
||||
void handleWorkspaceSelect(wp);
|
||||
@@ -388,8 +399,8 @@ export function WorkspaceSelector({
|
||||
className={cn(
|
||||
"flex items-center justify-between h-auto rounded-md p-2 text-left w-full",
|
||||
isActive
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
? "bg-(--accent-4) hover:bg-(--accent-4)"
|
||||
: "hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
@@ -465,7 +476,10 @@ export function WorkspaceSelector({
|
||||
<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">
|
||||
<div
|
||||
ref={branchListRef}
|
||||
className="flex flex-col gap-0.5 max-h-36 overflow-y-auto"
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
@@ -475,6 +489,7 @@ export function WorkspaceSelector({
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={branch}
|
||||
data-current={currentBranch === branch || undefined}
|
||||
disabled={switching}
|
||||
onClick={() => {
|
||||
void handleSelectBranch(branch);
|
||||
@@ -482,8 +497,8 @@ export function WorkspaceSelector({
|
||||
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",
|
||||
? "bg-(--accent-4) hover:bg-(--accent-4)"
|
||||
: "hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<GitBranch className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Star,
|
||||
Store,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -69,39 +70,60 @@ const CODE_FONT_STYLE: CSSProperties = {
|
||||
'"Geist Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
};
|
||||
|
||||
type MarketplacePageDetails = {
|
||||
title: string;
|
||||
description: string;
|
||||
emptyInstalled: string;
|
||||
emptyCatalog: string;
|
||||
icon: typeof Server;
|
||||
};
|
||||
|
||||
const primitivePageDetails = {
|
||||
mcp: {
|
||||
title: "MCP Servers",
|
||||
description:
|
||||
"Install Model Context Protocol servers into this CLI environment.",
|
||||
emptyInstalled: "No MCP servers installed.",
|
||||
emptyInstalled:
|
||||
"No MCP servers installed. Browse the marketplace or add a server manually.",
|
||||
emptyCatalog: "No MCP servers match the current filters.",
|
||||
icon: Server,
|
||||
},
|
||||
skill: {
|
||||
title: "Skills",
|
||||
description: "Install skills globally for Cline.",
|
||||
emptyInstalled: "No skills installed.",
|
||||
emptyInstalled: "No skills installed. Browse the marketplace to add one.",
|
||||
emptyCatalog: "No skills match the current filters.",
|
||||
icon: Zap,
|
||||
},
|
||||
plugin: {
|
||||
title: "Plugins",
|
||||
description: "Install plugins into this CLI environment.",
|
||||
emptyInstalled: "No plugins installed.",
|
||||
emptyInstalled: "No plugins installed. Browse the marketplace to add one.",
|
||||
emptyCatalog: "No plugins match the current filters.",
|
||||
icon: Puzzle,
|
||||
},
|
||||
} satisfies Record<
|
||||
MarketplacePrimitiveType,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
emptyInstalled: string;
|
||||
emptyCatalog: string;
|
||||
icon: typeof Server;
|
||||
}
|
||||
>;
|
||||
} satisfies Record<MarketplacePrimitiveType, MarketplacePageDetails>;
|
||||
|
||||
const directoryPageDetails: MarketplacePageDetails = {
|
||||
title: "Marketplace",
|
||||
description:
|
||||
"Browse and install plugins, MCP servers, and skills from the Cline marketplace.",
|
||||
emptyInstalled: "Nothing installed yet.",
|
||||
emptyCatalog: "No marketplace entries match the current filters.",
|
||||
icon: Store,
|
||||
};
|
||||
|
||||
const TYPE_FILTER_LABELS: Record<MarketplacePrimitiveType, string> = {
|
||||
plugin: "Plugins",
|
||||
mcp: "MCP servers",
|
||||
skill: "Skills",
|
||||
};
|
||||
|
||||
const TYPE_FILTER_ORDER: MarketplacePrimitiveType[] = [
|
||||
"plugin",
|
||||
"mcp",
|
||||
"skill",
|
||||
];
|
||||
|
||||
const primitiveCommands = {
|
||||
mcp: "cline mcp install",
|
||||
@@ -567,15 +589,17 @@ function MarketplaceSection({
|
||||
showEntryTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
title: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const totalCount = entries.length + localInstalledItems.length;
|
||||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
{title ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{headerContent}
|
||||
{totalCount > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
@@ -612,21 +636,32 @@ function MarketplaceSection({
|
||||
);
|
||||
}
|
||||
|
||||
export type MarketplaceViewVariant = "full" | "installed" | "directory";
|
||||
|
||||
export function MarketplaceView({
|
||||
chrome = "page",
|
||||
defaultTypeFilter,
|
||||
installedItems,
|
||||
onInstalledItemsChanged,
|
||||
primitive,
|
||||
variant = "full",
|
||||
}: {
|
||||
chrome?: "page" | "embedded";
|
||||
/** Preselected type filter chip in the all-types directory variant. */
|
||||
defaultTypeFilter?: MarketplacePrimitiveType;
|
||||
installedItems?: MarketplaceLocalInstalledItem[];
|
||||
onInstalledItemsChanged?: () => void | Promise<void>;
|
||||
primitive: MarketplacePrimitiveType;
|
||||
/** When omitted, the view spans every catalog type (directory variant). */
|
||||
primitive?: MarketplacePrimitiveType;
|
||||
variant?: MarketplaceViewVariant;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<MarketplaceCatalog | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<MarketplacePrimitiveType | null>(
|
||||
defaultTypeFilter ?? null,
|
||||
);
|
||||
const [expandedEntryKey, setExpandedEntryKey] = useState<string | null>(null);
|
||||
const [installedEntryKeys, setInstalledEntryKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
@@ -708,8 +743,9 @@ export function MarketplaceView({
|
||||
};
|
||||
}, [catalog, installedItemsSignature]);
|
||||
|
||||
const pageDetails = primitivePageDetails[primitive];
|
||||
const PageIcon = pageDetails.icon;
|
||||
const pageDetails = primitive
|
||||
? primitivePageDetails[primitive]
|
||||
: directoryPageDetails;
|
||||
const tagLabels = useMemo(
|
||||
() => new Map(catalog?.tags.map((tag) => [tag.id, tag.label]) ?? []),
|
||||
[catalog?.tags],
|
||||
@@ -717,9 +753,11 @@ export function MarketplaceView({
|
||||
|
||||
const primitiveEntries = useMemo(
|
||||
() =>
|
||||
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
|
||||
compareFeaturedEntries,
|
||||
),
|
||||
(
|
||||
catalog?.entries.filter(
|
||||
(entry) => !primitive || entry.type === primitive,
|
||||
) ?? []
|
||||
).sort(compareFeaturedEntries),
|
||||
[catalog?.entries, primitive],
|
||||
);
|
||||
|
||||
@@ -836,36 +874,56 @@ export function MarketplaceView({
|
||||
[queryFilteredEntries, installedEntryKeys, matchedEntryKeys],
|
||||
);
|
||||
|
||||
const marketplaceEntriesBeforeTag = useMemo(
|
||||
// The directory variant is a single browsable list of every catalog entry
|
||||
// (installed entries stay in place with an Uninstall action); other
|
||||
// variants keep not-yet-installed entries in the catalog section only.
|
||||
const catalogEntriesBeforeTag = useMemo(
|
||||
() =>
|
||||
queryFilteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys],
|
||||
variant === "directory"
|
||||
? queryFilteredEntries.filter(
|
||||
(entry) => !typeFilter || entry.type === typeFilter,
|
||||
)
|
||||
: queryFilteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys, typeFilter, variant],
|
||||
);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of marketplaceEntriesBeforeTag) {
|
||||
for (const entry of catalogEntriesBeforeTag) {
|
||||
for (const tag of entry.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [marketplaceEntriesBeforeTag]);
|
||||
}, [catalogEntriesBeforeTag]);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts = new Map<MarketplacePrimitiveType, number>();
|
||||
for (const entry of queryFilteredEntries) {
|
||||
counts.set(entry.type, (counts.get(entry.type) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [queryFilteredEntries]);
|
||||
|
||||
// Keep the selected tag's chip visible even when the current type/query has
|
||||
// no matches for it, so an active filter can never silently empty the list
|
||||
// while its chip is hidden.
|
||||
const primitiveTags = useMemo(
|
||||
() =>
|
||||
(catalog?.tags ?? []).filter((tag) => (tagCounts.get(tag.id) ?? 0) > 0),
|
||||
[catalog?.tags, tagCounts],
|
||||
(catalog?.tags ?? []).filter(
|
||||
(tag) => (tagCounts.get(tag.id) ?? 0) > 0 || tag.id === selectedTag,
|
||||
),
|
||||
[catalog?.tags, selectedTag, tagCounts],
|
||||
);
|
||||
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
marketplaceEntriesBeforeTag.filter(
|
||||
catalogEntriesBeforeTag.filter(
|
||||
(entry) => !selectedTag || entry.tags.includes(selectedTag),
|
||||
),
|
||||
[marketplaceEntriesBeforeTag, selectedTag],
|
||||
[catalogEntriesBeforeTag, selectedTag],
|
||||
);
|
||||
|
||||
const localInstalledItems = useMemo(() => {
|
||||
@@ -896,6 +954,41 @@ export function MarketplaceView({
|
||||
|
||||
const installedStatusReady = installedStatusState === "ready";
|
||||
|
||||
const typeFilterChips =
|
||||
variant === "directory" && !primitive ? (
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
<Button
|
||||
aria-pressed={typeFilter === null}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={typeFilter === null ? "default" : "outline"}
|
||||
>
|
||||
All
|
||||
<span className="rounded bg-background/30 px-1.5 py-0.5 text-xs">
|
||||
{queryFilteredEntries.length}
|
||||
</span>
|
||||
</Button>
|
||||
{TYPE_FILTER_ORDER.map((type) => (
|
||||
<Button
|
||||
aria-pressed={typeFilter === type}
|
||||
key={type}
|
||||
onClick={() =>
|
||||
setTypeFilter((current) => (current === type ? null : type))
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={typeFilter === type ? "default" : "outline"}
|
||||
>
|
||||
{TYPE_FILTER_LABELS[type]}
|
||||
<span className="rounded bg-background/30 px-1.5 py-0.5 text-xs">
|
||||
{typeCounts.get(type) ?? 0}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
@@ -1028,9 +1121,12 @@ export function MarketplaceView({
|
||||
{chrome === "page" ? (
|
||||
<PageHeader
|
||||
description={pageDetails.description}
|
||||
icon={PageIcon}
|
||||
title={pageDetails.title}
|
||||
meta={<CommandBadge>{primitiveCommands[primitive]}</CommandBadge>}
|
||||
meta={
|
||||
primitive ? (
|
||||
<CommandBadge>{primitiveCommands[primitive]}</CommandBadge>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
catalog?.generatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1083,38 +1179,49 @@ export function MarketplaceView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyInstalled}
|
||||
entries={installedEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
localInstalledItems={localInstalledItems}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
showFeaturedBadges={false}
|
||||
showEntryTags={false}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
/>
|
||||
{variant !== "directory" ? (
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyInstalled}
|
||||
entries={installedEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
localInstalledItems={localInstalledItems}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
showFeaturedBadges={false}
|
||||
showEntryTags={false}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={marketplaceTagFilters}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title="Marketplace"
|
||||
/>
|
||||
{variant !== "installed" ? (
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={
|
||||
typeFilterChips || marketplaceTagFilters ? (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title={variant === "directory" ? undefined : "Marketplace"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -84,8 +84,8 @@ function isApiKeyOnlyProvider(provider: Provider): boolean {
|
||||
|
||||
/**
|
||||
* Orders the provider catalog for the API-key setup step: OAuth-managed
|
||||
* providers (Cline itself, ChatGPT, OCA) are excluded because they have
|
||||
* dedicated sign-in paths, providers needing more than an API key are
|
||||
* providers (Cline itself, ClinePass, ChatGPT, OCA) are excluded because they
|
||||
* have dedicated sign-in paths, providers needing more than an API key are
|
||||
* excluded because this form only collects one, popular API-key providers
|
||||
* come first, and the rest follow alphabetically.
|
||||
*/
|
||||
|
||||
@@ -46,17 +46,19 @@ function renderView({
|
||||
loadOlderSessions = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
hasLoadedHistory = true,
|
||||
}: {
|
||||
openThread?: ReturnType<typeof vi.fn>;
|
||||
loadAllSessions?: ReturnType<typeof vi.fn>;
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
threads?: SessionThread[];
|
||||
hasLoadedHistory?: boolean;
|
||||
} = {}) {
|
||||
const history = {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
hasLoadedHistory,
|
||||
isLoadingMore: false,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
@@ -205,6 +207,21 @@ describe("SessionsView table", () => {
|
||||
expect(view.openThread).toHaveBeenCalledWith(thread.id);
|
||||
});
|
||||
|
||||
it("keeps loading until the first response and only then shows the empty state", async () => {
|
||||
const loading = renderView({ threads: [], hasLoadedHistory: false });
|
||||
await loading.render();
|
||||
expect(container.textContent).toContain("Loading session history...");
|
||||
expect(container.textContent).not.toContain("No sessions yet.");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
const empty = renderView({ threads: [], hasLoadedHistory: true });
|
||||
await empty.render();
|
||||
expect(container.textContent).toContain("No sessions yet.");
|
||||
expect(container.textContent).not.toContain("Loading session history...");
|
||||
});
|
||||
|
||||
it("loads complete history before treating search results as exhaustive", async () => {
|
||||
const view = renderView({ mayHaveMoreSessions: true });
|
||||
await view.render();
|
||||
|
||||
@@ -504,13 +504,16 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
<div>
|
||||
{history.isLoadingHistory && history.threads.length === 0 ? (
|
||||
{/* Keep the loader up until the backend's first response: the
|
||||
empty-state copy must only describe an actual zero-session
|
||||
answer, not a fetch that is still in flight or retrying. */}
|
||||
{!history.hasLoadedHistory && history.threads.length === 0 ? (
|
||||
<div className="flex items-center gap-2 border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading session history...
|
||||
</div>
|
||||
) : null}
|
||||
{!history.isLoadingHistory && filteredThreads.length === 0 ? (
|
||||
{history.hasLoadedHistory && filteredThreads.length === 0 ? (
|
||||
<div className="border-t px-4 py-8 text-sm text-muted-foreground">
|
||||
{history.threads.length === 0
|
||||
? "No sessions yet."
|
||||
|
||||
@@ -347,7 +347,15 @@ function CredentialField({
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelsContent() {
|
||||
export function ChannelsContent({
|
||||
chrome = "page",
|
||||
onInventoryChanged,
|
||||
}: {
|
||||
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
|
||||
chrome?: "page" | "embedded";
|
||||
/** Invoked whenever the connector list is (re)loaded or mutated. */
|
||||
onInventoryChanged?: () => void;
|
||||
} = {}) {
|
||||
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
|
||||
const [activeConnectors, setActiveConnectors] = useState<
|
||||
ActiveConnectorRecord[]
|
||||
@@ -373,8 +381,9 @@ export function ChannelsContent() {
|
||||
setActiveConnectors(
|
||||
Array.isArray(response.active) ? response.active : [],
|
||||
);
|
||||
onInventoryChanged?.();
|
||||
},
|
||||
[],
|
||||
[onInventoryChanged],
|
||||
);
|
||||
|
||||
const updateChannelError = useCallback(
|
||||
@@ -572,29 +581,41 @@ export function ChannelsContent() {
|
||||
: [];
|
||||
const isBusy = isLoading || pendingAction !== null;
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button
|
||||
aria-label="Refresh channels"
|
||||
disabled={isBusy}
|
||||
onClick={() => void refreshChannels()}
|
||||
size="sm"
|
||||
title="Refresh channels"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn("size-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
}
|
||||
description="Connect messaging platforms so you can chat with Cline anywhere. Click on a channel name to view or edit its configuration."
|
||||
meta={
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
cline connect
|
||||
</span>
|
||||
}
|
||||
title="Channels"
|
||||
/>
|
||||
const refreshButton = (
|
||||
<Button
|
||||
aria-label="Refresh channels"
|
||||
disabled={isBusy}
|
||||
onClick={() => void refreshChannels()}
|
||||
size="sm"
|
||||
title="Refresh channels"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn("size-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{chrome === "page" ? (
|
||||
<PageHeader
|
||||
actions={refreshButton}
|
||||
description="Connect messaging platforms so you can chat with Cline anywhere. Click on a channel name to view or edit its configuration."
|
||||
meta={
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
cline connect
|
||||
</span>
|
||||
}
|
||||
title="Channels"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect messaging platforms so you can chat with Cline anywhere.
|
||||
Click on a channel name to view or edit its configuration.
|
||||
</p>
|
||||
{refreshButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogError ? (
|
||||
<div
|
||||
@@ -929,6 +950,12 @@ export function ChannelsContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageFrame>
|
||||
</>
|
||||
);
|
||||
|
||||
return chrome === "embedded" ? (
|
||||
<div>{content}</div>
|
||||
) : (
|
||||
<PageFrame>{content}</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,31 @@ let extensionHookStatsCache: {
|
||||
fetchedAt: number;
|
||||
} | null = null;
|
||||
|
||||
const extensionInventoryInvalidationListeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Drops the module-level inventory cache and notifies mounted inventory views
|
||||
* so they refetch immediately. Used by the Marketplace page after
|
||||
* installs/uninstalls that happen outside these views — including ones that
|
||||
* complete after the user has already navigated back to the Plugins hub.
|
||||
*/
|
||||
export function invalidateExtensionInventoryCache() {
|
||||
extensionListsCache = null;
|
||||
for (const listener of extensionInventoryInvalidationListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to inventory invalidations; returns an unsubscribe function. */
|
||||
export function subscribeToExtensionInventoryInvalidation(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
extensionInventoryInvalidationListeners.add(listener);
|
||||
return () => {
|
||||
extensionInventoryInvalidationListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function hasFreshExtensionsListsCache(
|
||||
cache: typeof extensionListsCache,
|
||||
now: number,
|
||||
@@ -325,10 +350,19 @@ function isUnsupportedDesktopCommand(error: unknown, command: string): boolean {
|
||||
|
||||
export function CustomizationSectionView({
|
||||
catalogPrimitive,
|
||||
chrome = "page",
|
||||
marketplaceVariant = "full",
|
||||
onInventoryChanged,
|
||||
section = "Rules",
|
||||
showTabs = false,
|
||||
}: {
|
||||
catalogPrimitive?: MarketplacePrimitiveType;
|
||||
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
|
||||
chrome?: "page" | "embedded";
|
||||
/** Which marketplace sections the embedded MarketplaceView shows. */
|
||||
marketplaceVariant?: "full" | "installed";
|
||||
/** Invoked after a forced inventory refresh (installs, uninstalls). */
|
||||
onInventoryChanged?: () => void;
|
||||
section?: CustomizationSection;
|
||||
showTabs?: boolean;
|
||||
}) {
|
||||
@@ -391,49 +425,55 @@ export function CustomizationSectionView({
|
||||
setActiveTab(section);
|
||||
}, [section]);
|
||||
|
||||
const refresh = useCallback(async (force = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && hasFreshExtensionsListsCache(extensionListsCache, now)) {
|
||||
setWorkspaceRoot(extensionListsCache.workspaceRoot);
|
||||
setRules(extensionListsCache.rules);
|
||||
setWorkflows(extensionListsCache.workflows);
|
||||
setSkills(extensionListsCache.skills);
|
||||
setAgents(extensionListsCache.agents);
|
||||
setPlugins(extensionListsCache.plugins);
|
||||
setTools(extensionListsCache.tools);
|
||||
setHooks(extensionListsCache.hooks);
|
||||
setMcp(extensionListsCache.mcp);
|
||||
setWarnings(extensionListsCache.warnings);
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const refresh = useCallback(
|
||||
async (force = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && hasFreshExtensionsListsCache(extensionListsCache, now)) {
|
||||
setWorkspaceRoot(extensionListsCache.workspaceRoot);
|
||||
setRules(extensionListsCache.rules);
|
||||
setWorkflows(extensionListsCache.workflows);
|
||||
setSkills(extensionListsCache.skills);
|
||||
setAgents(extensionListsCache.agents);
|
||||
setPlugins(extensionListsCache.plugins);
|
||||
setTools(extensionListsCache.tools);
|
||||
setHooks(extensionListsCache.hooks);
|
||||
setMcp(extensionListsCache.mcp);
|
||||
setWarnings(extensionListsCache.warnings);
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await fetchUserInstructionLists();
|
||||
setWorkspaceRoot(response.workspaceRoot);
|
||||
setRules(response.rules);
|
||||
setWorkflows(response.workflows);
|
||||
setSkills(response.skills);
|
||||
setAgents(response.agents);
|
||||
setPlugins(response.plugins);
|
||||
setTools(response.tools);
|
||||
setHooks(response.hooks);
|
||||
setMcp(response.mcp);
|
||||
setWarnings(response.warnings);
|
||||
extensionListsCache = {
|
||||
...response,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await fetchUserInstructionLists();
|
||||
setWorkspaceRoot(response.workspaceRoot);
|
||||
setRules(response.rules);
|
||||
setWorkflows(response.workflows);
|
||||
setSkills(response.skills);
|
||||
setAgents(response.agents);
|
||||
setPlugins(response.plugins);
|
||||
setTools(response.tools);
|
||||
setHooks(response.hooks);
|
||||
setMcp(response.mcp);
|
||||
setWarnings(response.warnings);
|
||||
extensionListsCache = {
|
||||
...response,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
if (force) {
|
||||
onInventoryChanged?.();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[onInventoryChanged],
|
||||
);
|
||||
|
||||
const loadHookExecutionStats = useCallback(async (force = false) => {
|
||||
const now = Date.now();
|
||||
@@ -686,6 +726,17 @@ export function CustomizationSectionView({
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refresh]);
|
||||
|
||||
// Marketplace installs/uninstalls can complete after this view mounted
|
||||
// (e.g. the user navigated back to Plugins mid-install); refetch when the
|
||||
// shared inventory cache is invalidated so the list is never stale.
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToExtensionInventoryInvalidation(() => {
|
||||
void refresh(true);
|
||||
}),
|
||||
[refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "Hooks") {
|
||||
return;
|
||||
@@ -1173,28 +1224,39 @@ export function CustomizationSectionView({
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={sectionDescriptions[activeTab]}
|
||||
title={activeTab}
|
||||
meta={<CommandBadge>{sectionCommands[activeTab]}</CommandBadge>}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void refresh(true);
|
||||
if (activeTab === "Hooks") {
|
||||
void loadHookExecutionStats(true);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
const refreshButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void refresh(true);
|
||||
if (activeTab === "Hooks") {
|
||||
void loadHookExecutionStats(true);
|
||||
}
|
||||
/>
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{chrome === "page" ? (
|
||||
<PageHeader
|
||||
description={sectionDescriptions[activeTab]}
|
||||
title={activeTab}
|
||||
meta={<CommandBadge>{sectionCommands[activeTab]}</CommandBadge>}
|
||||
actions={refreshButton}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sectionDescriptions[activeTab]}
|
||||
</p>
|
||||
{refreshButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabs ? (
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
@@ -1245,6 +1307,7 @@ export function CustomizationSectionView({
|
||||
installedItems={installedCatalogLocalItems ?? undefined}
|
||||
onInstalledItemsChanged={() => refresh(true)}
|
||||
primitive={catalogPrimitive}
|
||||
variant={marketplaceVariant}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1709,7 +1772,13 @@ export function CustomizationSectionView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
</>
|
||||
);
|
||||
|
||||
return chrome === "embedded" ? (
|
||||
<div>{content}</div>
|
||||
) : (
|
||||
<PageFrame>{content}</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
MarketplaceView,
|
||||
} from "../marketplace-view";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
|
||||
import { subscribeToExtensionInventoryInvalidation } from "./extensions-view";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
@@ -205,7 +206,15 @@ function createServerFormState(existing?: McpServer): McpServerFormState {
|
||||
};
|
||||
}
|
||||
|
||||
export function McpServersContent() {
|
||||
export function McpServersContent({
|
||||
chrome = "page",
|
||||
onInventoryChanged,
|
||||
}: {
|
||||
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
|
||||
chrome?: "page" | "embedded";
|
||||
/** Invoked whenever the server list is (re)loaded or mutated. */
|
||||
onInventoryChanged?: () => void;
|
||||
} = {}) {
|
||||
const [servers, setServers] = useState<McpServer[]>([]);
|
||||
const [settingsPath, setSettingsPath] = useState("");
|
||||
const [hasSettingsFile, setHasSettingsFile] = useState(false);
|
||||
@@ -228,11 +237,15 @@ export function McpServersContent() {
|
||||
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
|
||||
|
||||
const applyResponse = useCallback((response: McpServersResponse) => {
|
||||
setServers(response.servers);
|
||||
setSettingsPath(response.settingsPath);
|
||||
setHasSettingsFile(response.hasSettingsFile);
|
||||
}, []);
|
||||
const applyResponse = useCallback(
|
||||
(response: McpServersResponse) => {
|
||||
setServers(response.servers);
|
||||
setSettingsPath(response.settingsPath);
|
||||
setHasSettingsFile(response.hasSettingsFile);
|
||||
onInventoryChanged?.();
|
||||
},
|
||||
[onInventoryChanged],
|
||||
);
|
||||
|
||||
const setServerActionError = useCallback(
|
||||
(serverName: string, message?: string) => {
|
||||
@@ -274,6 +287,17 @@ export function McpServersContent() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshServers]);
|
||||
|
||||
// Marketplace installs/uninstalls can complete after this view mounted
|
||||
// (e.g. the user navigated back to Plugins mid-install); refetch when the
|
||||
// shared inventory cache is invalidated so the list is never stale.
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToExtensionInventoryInvalidation(() => {
|
||||
void refreshServers();
|
||||
}),
|
||||
[refreshServers],
|
||||
);
|
||||
|
||||
const toggleServer = async (server: McpServer, disabled: boolean) => {
|
||||
setBusyServerName(server.name);
|
||||
setErrorMessage(null);
|
||||
@@ -717,42 +741,55 @@ export function McpServersContent() {
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={
|
||||
hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."
|
||||
}
|
||||
title="MCP Servers"
|
||||
meta={
|
||||
<>
|
||||
<CommandBadge>cline config mcp</CommandBadge>
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshServers()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshServers()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{chrome === "page" ? (
|
||||
<PageHeader
|
||||
description={
|
||||
hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."
|
||||
}
|
||||
title="MCP Servers"
|
||||
meta={
|
||||
<>
|
||||
<CommandBadge>cline config mcp</CommandBadge>
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={headerActions}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
@@ -776,6 +813,7 @@ export function McpServersContent() {
|
||||
installedItems={installedItems}
|
||||
onInstalledItemsChanged={() => refreshServers()}
|
||||
primitive="mcp"
|
||||
variant={chrome === "embedded" ? "installed" : "full"}
|
||||
/>
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
@@ -1144,6 +1182,12 @@ export function McpServersContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageFrame>
|
||||
</>
|
||||
);
|
||||
|
||||
return chrome === "embedded" ? (
|
||||
<div>{content}</div>
|
||||
) : (
|
||||
<PageFrame>{content}</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { Store } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
* Unified Plugins hub: one page for everything installed (plugins, MCP
|
||||
* servers, skills) with sub-tabs and live counts. The "Browse Marketplace"
|
||||
* button navigates to the dedicated Marketplace settings page.
|
||||
*/
|
||||
|
||||
type PluginsHubTab = "plugins" | "mcp" | "skills";
|
||||
|
||||
const HUB_TABS: { id: PluginsHubTab; label: string }[] = [
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
];
|
||||
|
||||
type HubCounts = Partial<Record<PluginsHubTab, number>>;
|
||||
|
||||
type HubInventoryResponse = {
|
||||
plugins?: unknown[];
|
||||
skills?: unknown[];
|
||||
workflows?: unknown[];
|
||||
mcp?: { servers?: unknown[] };
|
||||
};
|
||||
|
||||
function asCount(value: unknown): number {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
export function PluginsHubView({
|
||||
onOpenMarketplace,
|
||||
}: {
|
||||
onOpenMarketplace?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<PluginsHubTab>("plugins");
|
||||
const [counts, setCounts] = useState<HubCounts>({});
|
||||
|
||||
const refreshCounts = useCallback(async () => {
|
||||
const inventory = await desktopClient
|
||||
.invoke<HubInventoryResponse>("list_user_instruction_configs")
|
||||
.catch(() => null);
|
||||
if (!inventory) {
|
||||
return;
|
||||
}
|
||||
setCounts({
|
||||
plugins: asCount(inventory.plugins),
|
||||
skills: asCount(inventory.skills) + asCount(inventory.workflows),
|
||||
mcp: asCount(inventory.mcp?.servers),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshCounts();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshCounts]);
|
||||
|
||||
const handleInventoryChanged = useCallback(() => {
|
||||
void refreshCounts();
|
||||
}, [refreshCounts]);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage installed plugins, MCP servers, and skills. Browse the marketplace to install more."
|
||||
title="Plugins"
|
||||
actions={
|
||||
onOpenMarketplace ? (
|
||||
<Button onClick={onOpenMarketplace} type="button" variant="outline">
|
||||
<Store className="size-4" />
|
||||
Browse Marketplace
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{HUB_TABS.map((hubTab) => {
|
||||
const count = counts[hubTab.id];
|
||||
const active = tab === hubTab.id;
|
||||
return (
|
||||
<Button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative rounded-none px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={hubTab.id}
|
||||
onClick={() => setTab(hubTab.id)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{hubTab.label}
|
||||
{typeof count === "number" ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
active
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
{active ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === "plugins" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
/>
|
||||
) : tab === "mcp" ? (
|
||||
<McpServersContent
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
/>
|
||||
) : (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Skills"
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
ProviderListContent,
|
||||
} from "./provider-list-view";
|
||||
|
||||
const { loadProviderModelsMock } = vi.hoisted(() => ({
|
||||
loadProviderModelsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-model-catalog", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/provider-model-catalog")>();
|
||||
return { ...actual, loadProviderModels: loadProviderModelsMock };
|
||||
});
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "elevenlabs",
|
||||
@@ -78,6 +88,19 @@ const providers: Provider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const provider: Provider = {
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
models: 2,
|
||||
color: "#000",
|
||||
letter: "OL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{ id: "alpha", name: "Alpha" },
|
||||
{ id: "beta", name: "Beta" },
|
||||
],
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -717,4 +740,305 @@ describe("ProviderDetailContent audio capabilities", () => {
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("scopes the fetched featured list to its provider and list revision", async () => {
|
||||
const clineProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: true,
|
||||
modelList: [{ id: "cline/snapshot-model", name: "Cline Snapshot" }],
|
||||
};
|
||||
const clinePassProvider: Provider = {
|
||||
id: "cline-pass",
|
||||
name: "ClinePass",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CP",
|
||||
enabled: true,
|
||||
modelList: [{ id: "pass/snapshot-model", name: "Pass Snapshot" }],
|
||||
};
|
||||
const render = (detailProvider: Provider) =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
provider={detailProvider}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let resolvePassModels: (models: unknown[]) => void = () => {};
|
||||
loadProviderModelsMock.mockReset().mockImplementation((id: string) =>
|
||||
id === "cline"
|
||||
? Promise.resolve([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
])
|
||||
: new Promise((resolve) => {
|
||||
resolvePassModels = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await render(clineProvider);
|
||||
expect(container.textContent).toContain("Claude Opus 5");
|
||||
|
||||
// Switching directly to the other featured provider must not keep
|
||||
// showing the previous provider's fetched models while its own
|
||||
// request is still pending — the same component instance is reused.
|
||||
await render(clinePassProvider);
|
||||
expect(container.textContent).not.toContain("Claude Opus 5");
|
||||
expect(container.textContent).toContain("Pass Snapshot");
|
||||
|
||||
await act(async () => {
|
||||
resolvePassModels([{ id: "openai/gpt-5", name: "GPT-5" }]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain("GPT-5");
|
||||
expect(container.textContent).not.toContain("Pass Snapshot");
|
||||
});
|
||||
|
||||
it("keeps the catalog snapshot when the refresh after a switch fails or is empty", async () => {
|
||||
const clineProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: true,
|
||||
modelList: [{ id: "cline/snapshot-model", name: "Cline Snapshot" }],
|
||||
};
|
||||
const clinePassProvider: Provider = {
|
||||
id: "cline-pass",
|
||||
name: "ClinePass",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CP",
|
||||
enabled: true,
|
||||
modelList: [{ id: "pass/snapshot-model", name: "Pass Snapshot" }],
|
||||
};
|
||||
const render = (detailProvider: Provider) =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
provider={detailProvider}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
loadProviderModelsMock
|
||||
.mockReset()
|
||||
.mockImplementation((id: string) =>
|
||||
id === "cline"
|
||||
? Promise.resolve([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
])
|
||||
: Promise.reject(new Error("offline")),
|
||||
);
|
||||
await render(clineProvider);
|
||||
expect(container.textContent).toContain("Claude Opus 5");
|
||||
|
||||
// A failed refresh after switching falls back to the new provider's
|
||||
// snapshot; the previous provider's fetched list must not survive.
|
||||
await render(clinePassProvider);
|
||||
expect(container.textContent).not.toContain("Claude Opus 5");
|
||||
expect(container.textContent).toContain("Pass Snapshot");
|
||||
|
||||
// Same for an empty refresh result (a fresh list revision, so the
|
||||
// earlier successful cline fetch no longer applies).
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([]);
|
||||
await render({
|
||||
...clineProvider,
|
||||
modelList: [{ id: "cline/snapshot-model", name: "Cline Snapshot" }],
|
||||
});
|
||||
expect(container.textContent).not.toContain("GPT-5");
|
||||
expect(container.textContent).toContain("Cline Snapshot");
|
||||
});
|
||||
|
||||
it("reflects same-provider model list updates instead of shadowing them", async () => {
|
||||
loadProviderModelsMock
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
]);
|
||||
const baseProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: true,
|
||||
modelList: [{ id: "cline/snapshot-model", name: "Cline Snapshot" }],
|
||||
};
|
||||
const render = (detailProvider: Provider) =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
provider={detailProvider}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await render(baseProvider);
|
||||
expect(container.textContent).toContain("Claude Opus 5");
|
||||
|
||||
// The parent refreshed the provider's list (e.g. after an update):
|
||||
// the stale fetched copy must not shadow it, and the list is
|
||||
// re-fetched for the new revision.
|
||||
loadProviderModelsMock.mockResolvedValue([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "custom/new-model", name: "Custom New Model" },
|
||||
]);
|
||||
await render({
|
||||
...baseProvider,
|
||||
modelList: [
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "custom/new-model", name: "Custom New Model" },
|
||||
],
|
||||
});
|
||||
expect(container.textContent).toContain("Custom New Model");
|
||||
});
|
||||
|
||||
it("does not drop earlier additions on consecutive model adds", async () => {
|
||||
loadProviderModelsMock
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
]);
|
||||
const onUpdateModels = vi.fn();
|
||||
const baseProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: true,
|
||||
modelList: [{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" }],
|
||||
};
|
||||
const render = (detailProvider: Provider) =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onUpdateModels={onUpdateModels}
|
||||
provider={detailProvider}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const addModel = async (modelId: string) => {
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Add model"]')
|
||||
?.click();
|
||||
});
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="New model ID"]',
|
||||
);
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(input, modelId);
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="New model ID"] + button',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
};
|
||||
|
||||
await render(baseProvider);
|
||||
await addModel("custom/one");
|
||||
expect(onUpdateModels).toHaveBeenLastCalledWith([
|
||||
"anthropic/claude-opus-5",
|
||||
"custom/one",
|
||||
]);
|
||||
|
||||
// The parent applies the update and hands back the new list (as
|
||||
// settings-view does after update_provider_models + reload).
|
||||
loadProviderModelsMock.mockResolvedValue([
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "custom/one", name: "custom/one" },
|
||||
]);
|
||||
await render({
|
||||
...baseProvider,
|
||||
modelList: [
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "custom/one", name: "custom/one" },
|
||||
],
|
||||
});
|
||||
|
||||
// The second addition must include the first one — the stale fetched
|
||||
// list used to shadow the update and submit a list without it.
|
||||
await addModel("custom/two");
|
||||
expect(onUpdateModels).toHaveBeenLastCalledWith([
|
||||
"anthropic/claude-opus-5",
|
||||
"custom/one",
|
||||
"custom/two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes featured providers and renders tier badges and descriptions", async () => {
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([
|
||||
{
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
description: "Most intelligent model",
|
||||
featured: { tier: "recommended", rank: 0, tags: ["NEW"] },
|
||||
},
|
||||
{
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
featured: { tier: "free", rank: 0, tags: [] },
|
||||
},
|
||||
{ id: "vendor/plain-model", name: "Plain Model" },
|
||||
]);
|
||||
const clineProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 1,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: true,
|
||||
// Stale catalog snapshot; the refreshed list must replace it.
|
||||
modelList: [{ id: "old/stale-model", name: "Stale Model" }],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
provider={clineProvider}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline");
|
||||
expect(container.textContent).not.toContain("Stale Model");
|
||||
expect(container.textContent).toContain("Claude Opus 5");
|
||||
expect(container.textContent).toContain("Most intelligent model");
|
||||
const badgeTexts = Array.from(
|
||||
container.querySelectorAll(".uppercase.tracking-wide"),
|
||||
).map((badge) => badge.textContent);
|
||||
expect(badgeTexts).toEqual(["Recommended", "NEW", "Free"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
isDedicatedTranscriptionModel,
|
||||
isRealtimeVoiceModel,
|
||||
isSpeechGenerationModel,
|
||||
loadProviderModels,
|
||||
supportsAudio,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderModel,
|
||||
ProviderSettingsUpdate,
|
||||
RealtimeVoiceModeSettings,
|
||||
VoiceInputSelection,
|
||||
@@ -47,6 +49,30 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const FAVORITE_MODELS_STORAGE_KEY = "cline.favorite-provider-models.v1";
|
||||
|
||||
// Providers whose model lists carry recommended-feed tiers (see the SDK's
|
||||
// applyClineFeaturedModels). Only these are worth a per-card list fetch.
|
||||
const FEATURED_PROVIDER_IDS = new Set(["cline", "cline-pass"]);
|
||||
|
||||
/** Tier + feed tags rendered as small pills next to the model name. */
|
||||
function featuredBadges(model: ProviderModel): string[] {
|
||||
const featured = model.featured;
|
||||
if (!featured) {
|
||||
return [];
|
||||
}
|
||||
const badges: string[] = [];
|
||||
if (featured.tier === "recommended") {
|
||||
badges.push("Recommended");
|
||||
} else if (featured.tier === "free") {
|
||||
badges.push("Free");
|
||||
}
|
||||
for (const tag of featured.tags) {
|
||||
if (!badges.some((badge) => badge.toLowerCase() === tag.toLowerCase())) {
|
||||
badges.push(tag);
|
||||
}
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
|
||||
function readFavoriteModels(): Record<string, string[]> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
@@ -725,7 +751,47 @@ export function ProviderDetailContent({
|
||||
const configFields = provider.configFields ?? [];
|
||||
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
|
||||
const providerKeyUrl = getProviderApiKeyUrl(provider);
|
||||
const modelList = provider.modelList ?? [];
|
||||
// The catalog's modelList is fetched without the recommended-feed overlay
|
||||
// (the catalog must not block on the feed); featured providers refresh
|
||||
// their list here so tier badges and live entries can render. The result
|
||||
// is scoped to the provider AND the modelList revision it was fetched
|
||||
// for: an unscoped copy kept shadowing the next provider's models after
|
||||
// a switch (even when its own request failed) and masked membership
|
||||
// updates — adding a model would then submit the stale list as the
|
||||
// complete configuration and drop earlier additions.
|
||||
const [featuredModelList, setFeaturedModelList] = useState<{
|
||||
providerId: string;
|
||||
baseModelList: Provider["modelList"];
|
||||
models: ProviderModel[];
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (!FEATURED_PROVIDER_IDS.has(provider.id)) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadProviderModels(provider.id)
|
||||
.then((models) => {
|
||||
if (!cancelled && models.length > 0) {
|
||||
setFeaturedModelList({
|
||||
providerId: provider.id,
|
||||
baseModelList: provider.modelList,
|
||||
models,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the catalog snapshot when the refresh fails.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [provider.id, provider.modelList]);
|
||||
const modelList =
|
||||
featuredModelList &&
|
||||
featuredModelList.providerId === provider.id &&
|
||||
featuredModelList.baseModelList === provider.modelList
|
||||
? featuredModelList.models
|
||||
: (provider.modelList ?? []);
|
||||
const modelSearch =
|
||||
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
|
||||
const copiedModelId =
|
||||
@@ -799,10 +865,20 @@ export function ProviderDetailContent({
|
||||
|
||||
const addModel = () => {
|
||||
const modelId = newModelId.trim();
|
||||
if (!modelId || modelList.some((model) => model.id === modelId)) {
|
||||
// Submit the union of the displayed and configured lists: the update
|
||||
// replaces the provider's complete model configuration, so basing it
|
||||
// on the displayed list alone could silently drop configured entries
|
||||
// whenever the two diverge.
|
||||
const baseIds = [
|
||||
...new Set([
|
||||
...modelList.map((model) => model.id),
|
||||
...(provider.modelList ?? []).map((model) => model.id),
|
||||
]),
|
||||
];
|
||||
if (!modelId || baseIds.includes(modelId)) {
|
||||
return;
|
||||
}
|
||||
onUpdateModels?.([...modelList.map((model) => model.id), modelId]);
|
||||
onUpdateModels?.([...baseIds, modelId]);
|
||||
setAddModelState(null);
|
||||
};
|
||||
|
||||
@@ -1121,6 +1197,14 @@ export function ProviderDetailContent({
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{featuredBadges(model).map((badge) => (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded bg-surface-hover px-1 py-px font-sans text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground"
|
||||
key={badge}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<span
|
||||
@@ -1171,6 +1255,11 @@ export function ProviderDetailContent({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description ? (
|
||||
<p className="mt-0.5 truncate px-1 font-sans text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
aria-label={`Copy model ID ${model.id}`}
|
||||
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
@@ -93,6 +93,7 @@ interface RoutineSchedule {
|
||||
scheduleId: string;
|
||||
name: string;
|
||||
cronPattern: string;
|
||||
timezone?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
@@ -375,9 +376,11 @@ function formatScheduleTrigger(schedule: RoutineSchedule): string {
|
||||
return `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`;
|
||||
}
|
||||
const parsed = parseCronPattern(schedule.cronPattern);
|
||||
return parsed.scheduleType === "daily"
|
||||
? `Daily · ${formatScheduleTime(parsed.scheduleHour, parsed.scheduleMinute)}`
|
||||
: `${formatScheduleDays(parsed.scheduleDays)} · ${formatScheduleTime(parsed.scheduleHour, parsed.scheduleMinute)}`;
|
||||
const label =
|
||||
parsed.scheduleType === "daily"
|
||||
? `Daily · ${formatScheduleTime(parsed.scheduleHour, parsed.scheduleMinute)}`
|
||||
: `${formatScheduleDays(parsed.scheduleDays)} · ${formatScheduleTime(parsed.scheduleHour, parsed.scheduleMinute)}`;
|
||||
return schedule.timezone ? `${label} · ${schedule.timezone}` : label;
|
||||
}
|
||||
|
||||
function getOneTimeScheduleRunAt(
|
||||
|
||||
@@ -13,11 +13,12 @@ export const SETTINGS_SECTIONS = [
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group.
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group. Plugins is the
|
||||
// unified hub for installed plugins, MCP servers, and skills; Marketplace is
|
||||
// the full catalog page for installing more.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Skills",
|
||||
"MCP",
|
||||
"Marketplace",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
|
||||
@@ -58,13 +58,17 @@ import {
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarketplaceView } from "../marketplace-view";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
import { NotificationSettings } from "./notification-settings";
|
||||
import { PluginsHubView } from "./plugins-hub-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
@@ -570,11 +574,14 @@ export function SettingsView({
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "Plugins" ? (
|
||||
<CustomizationSectionView catalogPrimitive="plugin" section="Plugins" />
|
||||
) : activeNav === "Skills" ? (
|
||||
<CustomizationSectionView catalogPrimitive="skill" section="Skills" />
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
<PluginsHubView
|
||||
onOpenMarketplace={() => onNavigateSection("Marketplace")}
|
||||
/>
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<MarketplaceView
|
||||
onInstalledItemsChanged={invalidateExtensionInventoryCache}
|
||||
variant="directory"
|
||||
/>
|
||||
) : activeNav === "Hooks" ? (
|
||||
<CustomizationSectionView section="Hooks" />
|
||||
) : activeNav === "Rules" ? (
|
||||
|
||||
@@ -16,6 +16,9 @@ export const CHAT_WS_RECONNECT_MAX_DELAY_MS = 3000;
|
||||
export const CHAT_WS_REQUEST_TIMEOUT_MS = 120000;
|
||||
export const OAUTH_MANAGED_PROVIDERS = new Set([
|
||||
"cline",
|
||||
// ClinePass shares the Cline account OAuth credentials (its auth handler
|
||||
// stores under the "cline" provider), so it never has its own API key.
|
||||
"cline-pass",
|
||||
"oca",
|
||||
"openai-codex",
|
||||
]);
|
||||
|
||||
@@ -55,3 +55,55 @@ describe("resolveCredentialError (cloud)", () => {
|
||||
).toMatch(/Cline provider/);
|
||||
});
|
||||
});
|
||||
|
||||
function makeConfig(overrides: Partial<ChatSessionConfig>): ChatSessionConfig {
|
||||
return {
|
||||
workspaceRoot: "/tmp/project",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
mode: "act",
|
||||
apiKey: "",
|
||||
enableTools: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCredentialError", () => {
|
||||
it("requires a provider", () => {
|
||||
expect(resolveCredentialError(makeConfig({ provider: " " }))).toMatch(
|
||||
/Provider is required/,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks API-key providers without a key", () => {
|
||||
expect(
|
||||
resolveCredentialError(makeConfig({ provider: "anthropic" })),
|
||||
).toMatch(/Missing API key/);
|
||||
});
|
||||
|
||||
it("allows API-key providers with a key", () => {
|
||||
expect(
|
||||
resolveCredentialError(
|
||||
makeConfig({ provider: "anthropic", apiKey: "sk-123" }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"cline",
|
||||
"cline-pass",
|
||||
"oca",
|
||||
"openai-codex",
|
||||
])("allows OAuth-managed provider %s without a visible API key", (provider) => {
|
||||
// OAuth credentials live in the backend provider settings store
|
||||
// (ClinePass shares the Cline account login), never in the webview
|
||||
// config, so the pre-flight gate must not demand an API key.
|
||||
expect(resolveCredentialError(makeConfig({ provider }))).toBeNull();
|
||||
});
|
||||
|
||||
it("treats provider ids case-insensitively", () => {
|
||||
expect(
|
||||
resolveCredentialError(makeConfig({ provider: "Cline-Pass" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAgendaTaskExpired, sortAgendaTasks } from "./use-agenda-tasks";
|
||||
|
||||
describe("Agenda task presentation", () => {
|
||||
it("groups actionable status before ordering by priority", () => {
|
||||
const tasks = [
|
||||
task({ taskId: "approved-p0", status: "approved", priority: 0 }),
|
||||
task({ taskId: "pending-p3", status: "pending_approval", priority: 3 }),
|
||||
task({ taskId: "running-p5", status: "in_progress", priority: 5 }),
|
||||
task({ taskId: "pending-p1", status: "pending_approval", priority: 1 }),
|
||||
];
|
||||
|
||||
expect(sortAgendaTasks(tasks).map((item) => item.taskId)).toEqual([
|
||||
"running-p5",
|
||||
"pending-p1",
|
||||
"pending-p3",
|
||||
"approved-p0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats both an expired status and a past deadline as expired", () => {
|
||||
const now = Date.parse("2026-08-13T12:00:00.000Z");
|
||||
expect(
|
||||
isAgendaTaskExpired(
|
||||
task({ status: "expired", expiresAt: "2099-01-01T00:00:00.000Z" }),
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAgendaTaskExpired(task({ expiresAt: "2026-08-13T11:59:59.000Z" }), now),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAgendaTaskExpired(task({ expiresAt: "2026-08-13T12:00:01.000Z" }), now),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function task(overrides: Partial<AgendaTaskRecord> = {}): AgendaTaskRecord {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
type: "todo",
|
||||
status: "pending_approval",
|
||||
title: "Task",
|
||||
instructions: "Do the task.",
|
||||
scope: "global",
|
||||
resourcePaths: [],
|
||||
priority: 3,
|
||||
availableAt: "2026-08-13T00:00:00.000Z",
|
||||
expiresAt: "2099-08-20T00:00:00.000Z",
|
||||
automationEligible: true,
|
||||
revision: 1,
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
AgendaAutomationPolicy,
|
||||
AgendaTaskListInput,
|
||||
AgendaTaskRecord,
|
||||
AgendaTaskStatus,
|
||||
HubTaskCreateInput,
|
||||
} from "@cline/shared";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
const TASK_EVENTS = [
|
||||
"task.created",
|
||||
"task.updated",
|
||||
"task.deleted",
|
||||
"task.run.started",
|
||||
"task.run.completed",
|
||||
"task.run.failed",
|
||||
"task.automation.updated",
|
||||
] as const;
|
||||
|
||||
const STATUS_ORDER: Record<AgendaTaskStatus, number> = {
|
||||
in_progress: 0,
|
||||
pending_approval: 1,
|
||||
approved: 2,
|
||||
failed: 3,
|
||||
completed: 4,
|
||||
expired: 5,
|
||||
cancelled: 6,
|
||||
};
|
||||
|
||||
export function sortAgendaTasks(tasks: AgendaTaskRecord[]): AgendaTaskRecord[] {
|
||||
return [...tasks].sort((left, right) => {
|
||||
const statusDifference =
|
||||
STATUS_ORDER[left.status] - STATUS_ORDER[right.status];
|
||||
if (statusDifference !== 0) return statusDifference;
|
||||
const priorityDifference = left.priority - right.priority;
|
||||
if (priorityDifference !== 0) return priorityDifference;
|
||||
const availabilityDifference =
|
||||
Date.parse(left.availableAt) - Date.parse(right.availableAt);
|
||||
if (
|
||||
Number.isFinite(availabilityDifference) &&
|
||||
availabilityDifference !== 0
|
||||
) {
|
||||
return availabilityDifference;
|
||||
}
|
||||
return Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAgendaTaskExpired(
|
||||
task: AgendaTaskRecord,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const expiresAt = Date.parse(task.expiresAt);
|
||||
return (
|
||||
task.status === "expired" ||
|
||||
(Number.isFinite(expiresAt) && expiresAt <= now)
|
||||
);
|
||||
}
|
||||
|
||||
export type UseAgendaTasksResult = {
|
||||
tasks: AgendaTaskRecord[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pendingTaskIds: ReadonlySet<string>;
|
||||
refresh: () => Promise<void>;
|
||||
createTask: (input: HubTaskCreateInput) => Promise<AgendaTaskRecord>;
|
||||
approveTask: (task: AgendaTaskRecord) => Promise<AgendaTaskRecord>;
|
||||
cancelTask: (task: AgendaTaskRecord) => Promise<AgendaTaskRecord>;
|
||||
runTask: (task: AgendaTaskRecord) => Promise<AgendaTaskRecord>;
|
||||
};
|
||||
|
||||
export type UseAgendaAutomationResult = {
|
||||
policy: AgendaAutomationPolicy | null;
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
error: string | null;
|
||||
setAutomatic: (automatic: boolean) => Promise<AgendaAutomationPolicy>;
|
||||
};
|
||||
|
||||
const DEFAULT_AUTOMATION_POLICY: Omit<AgendaAutomationPolicy, "updatedAt"> = {
|
||||
scopeKey: "global",
|
||||
mode: "manual",
|
||||
applyToAgentCreated: true,
|
||||
maxConcurrentRuns: 1,
|
||||
maxChainDepth: 3,
|
||||
maxStartsPerHour: 20,
|
||||
};
|
||||
|
||||
function editableAutomationPolicy(
|
||||
policy: AgendaAutomationPolicy,
|
||||
): Omit<AgendaAutomationPolicy, "updatedAt"> {
|
||||
return {
|
||||
scopeKey: policy.scopeKey,
|
||||
mode: policy.mode,
|
||||
applyToAgentCreated: policy.applyToAgentCreated,
|
||||
maxConcurrentRuns: policy.maxConcurrentRuns,
|
||||
maxChainDepth: policy.maxChainDepth,
|
||||
maxStartsPerHour: policy.maxStartsPerHour,
|
||||
enabledBy: policy.enabledBy,
|
||||
enabledAt: policy.enabledAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgendaAutomation(enabled = true): UseAgendaAutomationResult {
|
||||
const [policy, setPolicy] = useState<AgendaAutomationPolicy | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(enabled);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setPolicy(await desktopClient.getAgendaAutomationPolicy());
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unable to load Agenda automation.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (!enabled) return;
|
||||
const unsubscribeEvent = desktopClient.subscribe(
|
||||
"task.automation.updated",
|
||||
() => void refresh(),
|
||||
);
|
||||
const unsubscribeTransport = desktopClient.subscribeTransportState(
|
||||
(state) => {
|
||||
if (state === "connected") void refresh();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
unsubscribeEvent();
|
||||
unsubscribeTransport();
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
|
||||
const setAutomatic = useCallback(
|
||||
async (automatic: boolean) => {
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const current = policy
|
||||
? editableAutomationPolicy(policy)
|
||||
: DEFAULT_AUTOMATION_POLICY;
|
||||
const next = await desktopClient.setAgendaAutomationPolicy({
|
||||
policy: {
|
||||
...current,
|
||||
mode: automatic ? "auto_start" : "manual",
|
||||
},
|
||||
});
|
||||
setPolicy(next);
|
||||
return next;
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unable to update Agenda automation.",
|
||||
);
|
||||
throw cause;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[policy],
|
||||
);
|
||||
|
||||
return { policy, isLoading, isUpdating, error, setAutomatic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live task queue state backed by Hub commands. Task events are invalidation
|
||||
* signals rather than an event-sourced cache, so every mutation/event re-lists
|
||||
* the current projection and reconnects cannot leave stale Agenda state.
|
||||
*/
|
||||
export function useAgendaTasks(
|
||||
filters: AgendaTaskListInput = {},
|
||||
enabled = true,
|
||||
): UseAgendaTasksResult {
|
||||
const filtersKey = JSON.stringify(filters);
|
||||
const parsedFilters = useMemo(
|
||||
() => JSON.parse(filtersKey) as AgendaTaskListInput,
|
||||
[filtersKey],
|
||||
);
|
||||
const [tasks, setTasks] = useState<AgendaTaskRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const requestSequence = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setTasks([]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const requestId = ++requestSequence.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextTasks = await desktopClient.listAgendaTasks(parsedFilters);
|
||||
if (requestId !== requestSequence.current) return;
|
||||
setTasks(sortAgendaTasks(nextTasks));
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (requestId !== requestSequence.current) return;
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : "Unable to load the Agenda.",
|
||||
);
|
||||
} finally {
|
||||
if (requestId === requestSequence.current) setIsLoading(false);
|
||||
}
|
||||
}, [enabled, parsedFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (!enabled) return;
|
||||
|
||||
const invalidate = () => void refresh();
|
||||
const unsubscribeEvents = TASK_EVENTS.map((eventName) =>
|
||||
desktopClient.subscribe(eventName, invalidate),
|
||||
);
|
||||
const unsubscribeTransport = desktopClient.subscribeTransportState(
|
||||
(state) => {
|
||||
if (state === "connected") void refresh();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
requestSequence.current += 1;
|
||||
for (const unsubscribe of unsubscribeEvents) unsubscribe();
|
||||
unsubscribeTransport();
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
|
||||
const mutateTask = useCallback(
|
||||
async (
|
||||
task: AgendaTaskRecord,
|
||||
operation: (task: AgendaTaskRecord) => Promise<AgendaTaskRecord>,
|
||||
) => {
|
||||
setPendingTaskIds((current) => new Set(current).add(task.taskId));
|
||||
setError(null);
|
||||
try {
|
||||
const next = await operation(task);
|
||||
setTasks((current) =>
|
||||
sortAgendaTasks(
|
||||
current.map((item) => (item.taskId === next.taskId ? next : item)),
|
||||
),
|
||||
);
|
||||
void refresh();
|
||||
return next;
|
||||
} catch (cause) {
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : "Unable to update the task.";
|
||||
setError(message);
|
||||
throw cause;
|
||||
} finally {
|
||||
setPendingTaskIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(task.taskId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
const createTask = useCallback(
|
||||
async (input: HubTaskCreateInput) => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = await desktopClient.createAgendaTask(input);
|
||||
setTasks((current) => sortAgendaTasks([created, ...current]));
|
||||
void refresh();
|
||||
return created;
|
||||
} catch (cause) {
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : "Unable to create the task.";
|
||||
setError(message);
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const approveTask = useCallback(
|
||||
(task: AgendaTaskRecord) =>
|
||||
mutateTask(task, (current) =>
|
||||
desktopClient.approveAgendaTask({
|
||||
taskId: current.taskId,
|
||||
expectedRevision: current.revision,
|
||||
}),
|
||||
),
|
||||
[mutateTask],
|
||||
);
|
||||
const cancelTask = useCallback(
|
||||
(task: AgendaTaskRecord) =>
|
||||
mutateTask(task, (current) =>
|
||||
desktopClient.cancelAgendaTask({
|
||||
taskId: current.taskId,
|
||||
expectedRevision: current.revision,
|
||||
}),
|
||||
),
|
||||
[mutateTask],
|
||||
);
|
||||
const runTask = useCallback(
|
||||
(task: AgendaTaskRecord) =>
|
||||
mutateTask(task, async (current) => {
|
||||
const result = await desktopClient.runAgendaTask({
|
||||
taskId: current.taskId,
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
return result.run?.sessionId && !result.task.lastSessionId
|
||||
? { ...result.task, lastSessionId: result.run.sessionId }
|
||||
: result.task;
|
||||
}),
|
||||
[mutateTask],
|
||||
);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
isLoading,
|
||||
error,
|
||||
pendingTaskIds,
|
||||
refresh,
|
||||
createTask,
|
||||
approveTask,
|
||||
cancelTask,
|
||||
runTask,
|
||||
};
|
||||
}
|
||||
@@ -2345,6 +2345,78 @@ describe("useChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a pending question when switching to its session", async () => {
|
||||
const hydratedSessionId = "session-with-question";
|
||||
const pendingQuestion = {
|
||||
requestId: "question-1",
|
||||
sessionId: hydratedSessionId,
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
question: "Which branch should I use?",
|
||||
options: ["Keep current", "Create new"],
|
||||
};
|
||||
const askQuestionHandler = subscribeMock.mock.calls.find(
|
||||
([eventName]) => eventName === "ask_question_requested",
|
||||
)?.[1] as ((payload: unknown) => void) | undefined;
|
||||
expect(askQuestionHandler).toBeTypeOf("function");
|
||||
|
||||
await act(async () => {
|
||||
askQuestionHandler?.(pendingQuestion);
|
||||
});
|
||||
expect(current.pendingAskQuestions).toEqual([]);
|
||||
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "poll_ask_questions") {
|
||||
return args?.sessionId === hydratedSessionId ? [pendingQuestion] : [];
|
||||
}
|
||||
if (
|
||||
command === "poll_tool_approvals" ||
|
||||
command === "read_session_messages" ||
|
||||
command === "read_session_hooks"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "attach") {
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await current.hydrateSession({
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
startedAt: "2026-08-11T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(current.pendingAskQuestions).toEqual([pendingQuestion]),
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledWith("poll_ask_questions", {
|
||||
sessionId: hydratedSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets to the remembered provider/model after viewing a historical session", async () => {
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
|
||||
@@ -3361,6 +3361,10 @@ export function useChatSession(environmentId: string) {
|
||||
sessionId: session.sessionId,
|
||||
hydrationStartedAt,
|
||||
});
|
||||
// Hub attachment starts forwarding future events but does not
|
||||
// replay the tool.started event for a command already in flight.
|
||||
// Rebuild its routing key from canonical history before those
|
||||
// future updates can arrive.
|
||||
const liveToolState = deriveLiveToolState(mergedMessages);
|
||||
liveToolMessageIdsRef.current = liveToolState.messageIds;
|
||||
liveToolInputsRef.current = liveToolState.inputs;
|
||||
|
||||
@@ -143,6 +143,98 @@ describe("useSessionHistory session mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory initial load", () => {
|
||||
it("reports history as loaded only after the backend has answered", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
expect(current.hasLoadedHistory).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// A zero-session answer is a definitive result, not a loading state.
|
||||
expect(current.hasLoadedHistory).toBe(true);
|
||||
expect(current.threads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retries a failed initial fetch quickly instead of waiting for the poll", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].reject(new Error("transport closed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The rejected request must not read as an empty history.
|
||||
expect(current.hasLoadedHistory).toBe(false);
|
||||
|
||||
// The retry fires on the short event cadence (2s), well before the
|
||||
// 12s periodic poll.
|
||||
await flush(2_000);
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve([sessionRow("recovered-session")]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.hasLoadedHistory).toBe(true);
|
||||
expect(current.threads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops fast retries when the hook unmounts mid-request", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
|
||||
// Unmount while the initial request is still in flight, then fail it:
|
||||
// the retry continuation must not re-arm the cleared refresh timer.
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => {
|
||||
pendingLists[0].reject(new Error("transport closed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await flush(3_000);
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
|
||||
// Fresh root so the shared afterEach unmount stays valid.
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
it("does not schedule fast retries once history has loaded", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([sessionRow("session-1")]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.hasLoadedHistory).toBe(true);
|
||||
|
||||
// Advance to the periodic poll and fail it: no 2s retry may follow.
|
||||
await flush(12_000);
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush(3_000);
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory refresh coalescing", () => {
|
||||
it("reuses an in-flight refresh that already covers the requested limit", async () => {
|
||||
await act(async () => {
|
||||
|
||||
@@ -515,7 +515,11 @@ export function useSessionHistory({
|
||||
}: UseSessionHistoryOptions) {
|
||||
const [sessions, setSessions] = useState<SessionHistoryItem[]>([]);
|
||||
const [threads, setThreads] = useState<SessionThread[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
// False until the backend has answered a history request at least once.
|
||||
// Consumers use this to tell "still loading" apart from "loaded, zero
|
||||
// sessions": an empty-state copy shown before the first response reads as
|
||||
// lost history whenever fetching takes more than an instant.
|
||||
const [hasLoadedHistory, setHasLoadedHistory] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [mayHaveMoreSessions, setMayHaveMoreSessions] = useState(false);
|
||||
const [pendingAction, setPendingAction] =
|
||||
@@ -545,6 +549,10 @@ export function useSessionHistory({
|
||||
const refreshLimitRef = useRef(0);
|
||||
const loadAllPromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const lastRefreshStartedAtRef = useRef(0);
|
||||
// Guards scheduleRefresh against continuations that settle after unmount
|
||||
// (e.g. the fast retry of a failed initial fetch), which would otherwise
|
||||
// re-arm a timer the cleanup has already cleared and poll forever.
|
||||
const disposedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
@@ -585,12 +593,6 @@ export function useSessionHistory({
|
||||
lastRefreshStartedAtRef.current = Date.now();
|
||||
const limit = fetchLimitRef.current;
|
||||
refreshLimitRef.current = limit;
|
||||
// Only surface the loading state before anything has been fetched:
|
||||
// consumers only render it for an empty list, and toggling it on
|
||||
// every background poll re-rendered the whole app twice per refresh.
|
||||
if (sessionsRef.current.length === 0) {
|
||||
setIsLoadingHistory(true);
|
||||
}
|
||||
try {
|
||||
const discovered = await desktopClient
|
||||
.invoke<CliDiscoveredSession[]>("list_discovered_sessions", { limit })
|
||||
@@ -670,12 +672,11 @@ export function useSessionHistory({
|
||||
return areThreadsEquivalent(current, next) ? current : next;
|
||||
});
|
||||
loadedLimitRef.current = Math.max(loadedLimitRef.current, limit);
|
||||
setHasLoadedHistory(true);
|
||||
return true;
|
||||
} catch {
|
||||
// Ignore in browser mode or when tauri command is unavailable.
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -693,6 +694,9 @@ export function useSessionHistory({
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
(delayMs = 0, options: { force?: boolean } = {}) => {
|
||||
if (disposedRef.current) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const minTarget = options.force
|
||||
? now
|
||||
@@ -714,7 +718,16 @@ export function useSessionHistory({
|
||||
() => {
|
||||
refreshTimeoutRef.current = null;
|
||||
scheduledRefreshAtRef.current = null;
|
||||
void refreshSessions();
|
||||
void refreshSessions().then((loaded) => {
|
||||
// Until something has loaded the UI has nothing but a
|
||||
// loading state to show, so a failed fetch (e.g. the
|
||||
// websocket losing the race with a webview reload) retries
|
||||
// on the short event cadence instead of stranding the
|
||||
// sidebar until the periodic poll fires.
|
||||
if (!loaded && loadedLimitRef.current === 0) {
|
||||
scheduleRefresh(MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
},
|
||||
Math.max(0, target - now),
|
||||
);
|
||||
@@ -724,6 +737,7 @@ export function useSessionHistory({
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
disposedRef.current = false;
|
||||
|
||||
const runRefresh = () => {
|
||||
if (!disposed) {
|
||||
@@ -741,6 +755,7 @@ export function useSessionHistory({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposedRef.current = true;
|
||||
window.clearInterval(interval);
|
||||
if (refreshTimeoutRef.current !== null) {
|
||||
window.clearTimeout(refreshTimeoutRef.current);
|
||||
@@ -1501,7 +1516,7 @@ export function useSessionHistory({
|
||||
|
||||
return {
|
||||
getSessionByThreadId,
|
||||
isLoadingHistory,
|
||||
hasLoadedHistory,
|
||||
isLoadingMore,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* See apps/examples/desktop-app/EXPERIMENTAL.md for the channel model.
|
||||
*/
|
||||
|
||||
export const STABLE_PRODUCT_NAME = "Cline Code";
|
||||
export const BETA_PRODUCT_NAME = "Cline Code Beta";
|
||||
export const STABLE_PRODUCT_NAME = "Cline";
|
||||
export const BETA_PRODUCT_NAME = "Cline Beta";
|
||||
|
||||
export function isBetaVersion(version: string | null | undefined): boolean {
|
||||
return typeof version === "string" && version.includes("-beta");
|
||||
|
||||
@@ -34,18 +34,34 @@ describe("app icon", () => {
|
||||
expect(readStoredAppIcon()).toBe(DEFAULT_APP_ICON);
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, "bogus");
|
||||
expect(readStoredAppIcon()).toBe(DEFAULT_APP_ICON);
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, "toString");
|
||||
expect(readStoredAppIcon()).toBe(DEFAULT_APP_ICON);
|
||||
expect(isAppIconId("midnight")).toBe(true);
|
||||
expect(isAppIconId("hologram")).toBe(true);
|
||||
expect(isAppIconId("chip")).toBe(true);
|
||||
expect(isAppIconId("steel")).toBe(false);
|
||||
expect(isAppIconId("sunrise")).toBe(false);
|
||||
expect(isAppIconId("bogus")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["sunrise", "hologram"],
|
||||
["steel", "midnight"],
|
||||
])("migrates the retired %s preference to %s", (retired, replacement) => {
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, retired);
|
||||
|
||||
expect(readStoredAppIcon()).toBe(replacement);
|
||||
expect(window.localStorage.getItem(APP_ICON_STORAGE_KEY)).toBe(replacement);
|
||||
});
|
||||
|
||||
it("persists the choice and swaps the favicon in browser mode", async () => {
|
||||
await setStoredAppIcon("steel");
|
||||
expect(window.localStorage.getItem(APP_ICON_STORAGE_KEY)).toBe("steel");
|
||||
await setStoredAppIcon("classic");
|
||||
expect(window.localStorage.getItem(APP_ICON_STORAGE_KEY)).toBe("classic");
|
||||
expect(
|
||||
document
|
||||
.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
?.getAttribute("href"),
|
||||
).toBe(appIconAssetPath("steel"));
|
||||
).toBe(appIconAssetPath("classic"));
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,15 +9,29 @@ export const APP_ICON_STORAGE_KEY = "cline.code.app-icon.v1";
|
||||
*/
|
||||
export const APP_ICONS = [
|
||||
{ id: "classic", label: "Classic" },
|
||||
{ id: "sunrise", label: "Sunrise" },
|
||||
{ id: "steel", label: "Steel" },
|
||||
{ id: "midnight", label: "Midnight" },
|
||||
{ id: "hologram", label: "Hologram" },
|
||||
{ id: "chip", label: "Chip" },
|
||||
] as const;
|
||||
|
||||
export type AppIconId = (typeof APP_ICONS)[number]["id"];
|
||||
|
||||
export const DEFAULT_APP_ICON: AppIconId = "midnight";
|
||||
|
||||
const RETIRED_APP_ICON_MIGRATIONS = {
|
||||
sunrise: "hologram",
|
||||
steel: DEFAULT_APP_ICON,
|
||||
} as const satisfies Record<string, AppIconId>;
|
||||
|
||||
type RetiredAppIconId = keyof typeof RETIRED_APP_ICON_MIGRATIONS;
|
||||
|
||||
function isRetiredAppIconId(value: unknown): value is RetiredAppIconId {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
Object.hasOwn(RETIRED_APP_ICON_MIGRATIONS, value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppIconId(value: unknown): value is AppIconId {
|
||||
return APP_ICONS.some((icon) => icon.id === value);
|
||||
}
|
||||
@@ -29,6 +43,11 @@ export function appIconAssetPath(icon: AppIconId): string {
|
||||
export function readStoredAppIcon(): AppIconId {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(APP_ICON_STORAGE_KEY);
|
||||
if (isRetiredAppIconId(stored)) {
|
||||
const migrated = RETIRED_APP_ICON_MIGRATIONS[stored];
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, migrated);
|
||||
return migrated;
|
||||
}
|
||||
return isAppIconId(stored) ? stored : DEFAULT_APP_ICON;
|
||||
} catch {
|
||||
return DEFAULT_APP_ICON;
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("parseCloudSessionError", () => {
|
||||
"The session belongs to environment undefined, not [object Object].",
|
||||
),
|
||||
).toBe(
|
||||
"Cline Code couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.",
|
||||
"Cline couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function humanizeCloudSessionError(value: string): string {
|
||||
/session belongs to environment/i.test(message) &&
|
||||
(/\bundefined\b/i.test(message) || message.includes("[object Object]"))
|
||||
) {
|
||||
return "Cline Code couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.";
|
||||
return "Cline couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { writeDesktopDebugLog } from "./desktop-client";
|
||||
type SentDesktopRequest = {
|
||||
id: string;
|
||||
command: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
@@ -107,6 +108,50 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("DesktopClient command deadlines", () => {
|
||||
it("sends the displayed revision for Agenda approval, cancellation, and run", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
const approval = desktopClient.approveAgendaTask({
|
||||
taskId: "task-7",
|
||||
expectedRevision: 7,
|
||||
});
|
||||
const socket = await connectLatestSocket();
|
||||
expect(socket.lastRequest()).toMatchObject({
|
||||
command: "task.approve",
|
||||
args: { taskId: "task-7", expectedRevision: 7 },
|
||||
});
|
||||
socket.respond({ task: {} });
|
||||
await approval;
|
||||
|
||||
const cancellation = desktopClient.cancelAgendaTask({
|
||||
taskId: "task-7",
|
||||
expectedRevision: 7,
|
||||
reason: "Superseded",
|
||||
});
|
||||
await vi.waitFor(() => expect(socket.sent).toHaveLength(2));
|
||||
expect(socket.lastRequest()).toMatchObject({
|
||||
command: "task.cancel",
|
||||
args: {
|
||||
taskId: "task-7",
|
||||
expectedRevision: 7,
|
||||
reason: "Superseded",
|
||||
},
|
||||
});
|
||||
socket.respond({ task: {} });
|
||||
await cancellation;
|
||||
|
||||
const run = desktopClient.runAgendaTask({
|
||||
taskId: "task-7",
|
||||
expectedRevision: 7,
|
||||
});
|
||||
await vi.waitFor(() => expect(socket.sent).toHaveLength(3));
|
||||
expect(socket.lastRequest()).toMatchObject({
|
||||
command: "task.run",
|
||||
args: { taskId: "task-7", expectedRevision: 7 },
|
||||
});
|
||||
socket.respond({ task: {} });
|
||||
await run;
|
||||
});
|
||||
|
||||
it("reports the same error object only once across local and global handlers", async () => {
|
||||
const { desktopClient } = await import("./desktop-client");
|
||||
const error = new Error("native command failed");
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
AgendaAutomationPolicy,
|
||||
AgendaTaskListInput,
|
||||
AgendaTaskRecord,
|
||||
AgendaTaskRunRecord,
|
||||
DesktopDebugLogPayload,
|
||||
HubTaskCreateInput,
|
||||
HubTaskUpdateInput,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
DesktopTransportEvent,
|
||||
DesktopTransportMessage,
|
||||
DesktopTransportRequest,
|
||||
@@ -102,6 +110,22 @@ export type DesktopInvokeOptions = {
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
export type AgendaTaskIdInput = {
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type AgendaTaskRevisionInput = AgendaTaskIdInput & {
|
||||
expectedRevision: number;
|
||||
};
|
||||
|
||||
export type AgendaTaskCancelInput = AgendaTaskRevisionInput & {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type AgendaAutomationSetInput = {
|
||||
policy: Omit<AgendaAutomationPolicy, "updatedAt">;
|
||||
};
|
||||
|
||||
export type DesktopErrorReport = {
|
||||
operation: string;
|
||||
error: unknown;
|
||||
@@ -601,6 +625,89 @@ class DesktopClient {
|
||||
getTransportError(): string | null {
|
||||
return this.transportError;
|
||||
}
|
||||
|
||||
async createAgendaTask(input: HubTaskCreateInput): Promise<AgendaTaskRecord> {
|
||||
const response = await this.invoke<{ task: AgendaTaskRecord }>(
|
||||
"task.create",
|
||||
{ ...input },
|
||||
);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async listAgendaTasks(
|
||||
input: AgendaTaskListInput = {},
|
||||
): Promise<AgendaTaskRecord[]> {
|
||||
const response = await this.invoke<{ tasks: AgendaTaskRecord[] }>(
|
||||
"task.list",
|
||||
{ ...input },
|
||||
);
|
||||
return response.tasks ?? [];
|
||||
}
|
||||
|
||||
async getAgendaTask(taskId: string): Promise<AgendaTaskRecord | undefined> {
|
||||
const response = await this.invoke<{ task?: AgendaTaskRecord }>(
|
||||
"task.get",
|
||||
{
|
||||
taskId,
|
||||
},
|
||||
);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async updateAgendaTask(input: HubTaskUpdateInput): Promise<AgendaTaskRecord> {
|
||||
const response = await this.invoke<{ task: AgendaTaskRecord }>(
|
||||
"task.update",
|
||||
{ ...input },
|
||||
);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async approveAgendaTask(
|
||||
input: AgendaTaskRevisionInput,
|
||||
): Promise<AgendaTaskRecord> {
|
||||
const response = await this.invoke<{ task: AgendaTaskRecord }>(
|
||||
"task.approve",
|
||||
{ ...input },
|
||||
);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async cancelAgendaTask(
|
||||
input: AgendaTaskCancelInput,
|
||||
): Promise<AgendaTaskRecord> {
|
||||
const response = await this.invoke<{ task: AgendaTaskRecord }>(
|
||||
"task.cancel",
|
||||
{ ...input },
|
||||
);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async runAgendaTask(input: AgendaTaskRevisionInput): Promise<{
|
||||
task: AgendaTaskRecord;
|
||||
run?: AgendaTaskRunRecord;
|
||||
}> {
|
||||
return await this.invoke<{
|
||||
task: AgendaTaskRecord;
|
||||
run?: AgendaTaskRunRecord;
|
||||
}>("task.run", { ...input });
|
||||
}
|
||||
|
||||
async getAgendaAutomationPolicy(): Promise<AgendaAutomationPolicy> {
|
||||
const response = await this.invoke<{ policy: AgendaAutomationPolicy }>(
|
||||
"task.automation.get",
|
||||
);
|
||||
return response.policy;
|
||||
}
|
||||
|
||||
async setAgendaAutomationPolicy(
|
||||
input: AgendaAutomationSetInput,
|
||||
): Promise<AgendaAutomationPolicy> {
|
||||
const response = await this.invoke<{ policy: AgendaAutomationPolicy }>(
|
||||
"task.automation.set",
|
||||
{ ...input },
|
||||
);
|
||||
return response.policy;
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopClient = new DesktopClient();
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("desktop window title", () => {
|
||||
it("titles beta builds with the beta product name", async () => {
|
||||
const { buildDesktopWindowTitle } = await importFresh();
|
||||
expect(buildDesktopWindowTitle("0.0.14-beta.1")).toBe(
|
||||
"Cline Code Beta v0.0.14-beta.1",
|
||||
"Cline Beta v0.0.14-beta.1",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildModelPickerData } from "@/lib/featured-models";
|
||||
import type {
|
||||
ProviderModel,
|
||||
ProviderModelFeatured,
|
||||
} from "@/lib/provider-schema";
|
||||
|
||||
function model(
|
||||
id: string,
|
||||
name?: string,
|
||||
featured?: ProviderModelFeatured,
|
||||
description?: string,
|
||||
): ProviderModel {
|
||||
return { id, name: name ?? id, featured, description };
|
||||
}
|
||||
|
||||
describe("buildModelPickerData", () => {
|
||||
it("builds recommended / free / all sections for the cline provider", () => {
|
||||
const { options, sections } = buildModelPickerData("cline", [
|
||||
model("zzz/last-model", "ZZZ Last"),
|
||||
model(
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"DeepSeek V4 Flash",
|
||||
{ tier: "free", rank: 0, tags: [] },
|
||||
"Fast and efficient",
|
||||
),
|
||||
model(
|
||||
"anthropic/claude-opus-5",
|
||||
"Claude Opus 5",
|
||||
{ tier: "recommended", rank: 0, tags: ["NEW"] },
|
||||
"Most intelligent model",
|
||||
),
|
||||
model("aaa/first-model", "AAA First"),
|
||||
]);
|
||||
|
||||
expect(sections?.map((section) => section.id)).toEqual([
|
||||
"recommended",
|
||||
"free",
|
||||
"all",
|
||||
]);
|
||||
expect(options.map((option) => option.value)).toEqual([
|
||||
"anthropic/claude-opus-5",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"aaa/first-model",
|
||||
"zzz/last-model",
|
||||
]);
|
||||
expect(options[0]).toMatchObject({
|
||||
badge: "NEW",
|
||||
description: "Most intelligent model",
|
||||
label: "Claude Opus 5",
|
||||
section: "recommended",
|
||||
});
|
||||
expect(options[1]).toMatchObject({ badge: "Free", section: "free" });
|
||||
// The "all" tier is sorted by display name, not raw id.
|
||||
expect(options[2]?.label).toBe("AAA First");
|
||||
});
|
||||
|
||||
it("orders featured tiers by feed rank, not list order", () => {
|
||||
const { options } = buildModelPickerData("cline", [
|
||||
model("openai/gpt-5.6-sol", "GPT-5.6 Sol", {
|
||||
tier: "recommended",
|
||||
rank: 1,
|
||||
tags: [],
|
||||
}),
|
||||
model("moonshotai/kimi-k3", "Kimi K3", {
|
||||
tier: "recommended",
|
||||
rank: 0,
|
||||
tags: ["NEW"],
|
||||
}),
|
||||
]);
|
||||
expect(options.map((option) => option.label)).toEqual([
|
||||
"Kimi K3",
|
||||
"GPT-5.6 Sol",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds subscribed / free sections for cline-pass and hides stale catalog leftovers", () => {
|
||||
const { options, sections } = buildModelPickerData("cline-pass", [
|
||||
model("cline-pass/kimi-k3", "Kimi K3", {
|
||||
tier: "subscribed",
|
||||
rank: 0,
|
||||
tags: [],
|
||||
}),
|
||||
model("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", {
|
||||
tier: "free",
|
||||
rank: 0,
|
||||
tags: [],
|
||||
}),
|
||||
// Bundled/cached entry no longer part of the plan's offer.
|
||||
model("nvidia/nemotron-ultra", "Nemotron Ultra"),
|
||||
]);
|
||||
expect(sections?.map((section) => section.id)).toEqual([
|
||||
"subscribed",
|
||||
"free",
|
||||
]);
|
||||
expect(options.map((option) => option.section)).toEqual([
|
||||
"subscribed",
|
||||
"free",
|
||||
]);
|
||||
expect(
|
||||
options.some((option) => option.value === "nvidia/nemotron-ultra"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the full cline-pass catalog when the subscribed tier is empty", () => {
|
||||
const { options, sections } = buildModelPickerData("cline-pass", [
|
||||
model("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", {
|
||||
tier: "free",
|
||||
rank: 0,
|
||||
tags: [],
|
||||
}),
|
||||
model("nvidia/nemotron-ultra", "Nemotron Ultra"),
|
||||
]);
|
||||
expect(sections?.map((section) => section.id)).toEqual([
|
||||
"subscribed",
|
||||
"free",
|
||||
"all",
|
||||
]);
|
||||
expect(options.map((option) => option.value)).toEqual([
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"nvidia/nemotron-ultra",
|
||||
]);
|
||||
expect(options[1]?.section).toBe("all");
|
||||
});
|
||||
|
||||
it("falls back to a flat name-sorted list when nothing is featured", () => {
|
||||
const { options, sections } = buildModelPickerData("cline", [
|
||||
model("zzz/last", "ZZZ"),
|
||||
model("aaa/first", "AAA"),
|
||||
]);
|
||||
expect(sections).toBeUndefined();
|
||||
expect(options.map((option) => option.label)).toEqual(["AAA", "ZZZ"]);
|
||||
expect(options[0]?.section).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders other providers as a flat list with display names", () => {
|
||||
const { options, sections } = buildModelPickerData("anthropic", [
|
||||
model("claude-sonnet-4-6", "Claude Sonnet 4.6"),
|
||||
]);
|
||||
expect(sections).toBeUndefined();
|
||||
expect(options).toEqual([
|
||||
{ label: "Claude Sonnet 4.6", value: "claude-sonnet-4-6" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import type { SearchComboboxOption, SearchComboboxSection } from "@cline/ui";
|
||||
import type {
|
||||
ProviderModel,
|
||||
ProviderModelFeaturedTier,
|
||||
} from "@/lib/provider-schema";
|
||||
|
||||
export type ModelPickerData = {
|
||||
options: SearchComboboxOption[];
|
||||
sections?: SearchComboboxSection[];
|
||||
};
|
||||
|
||||
// Section copy mirrors the CLI's featured picker so the products read the same.
|
||||
const FREE_SECTION_DESCRIPTION = "Try with limited usage at no cost";
|
||||
const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota";
|
||||
|
||||
function displayName(model: ProviderModel): string {
|
||||
return model.name?.trim() || model.id;
|
||||
}
|
||||
|
||||
function byLabel(a: SearchComboboxOption, b: SearchComboboxOption): number {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function flatOptions(models: ProviderModel[]): SearchComboboxOption[] {
|
||||
return models
|
||||
.map((model) => ({ label: displayName(model), value: model.id }))
|
||||
.sort(byLabel);
|
||||
}
|
||||
|
||||
function tierOptions(
|
||||
models: ProviderModel[],
|
||||
tier: ProviderModelFeaturedTier,
|
||||
section: string,
|
||||
badge?: (model: ProviderModel) => string | undefined,
|
||||
): SearchComboboxOption[] {
|
||||
return models
|
||||
.filter((model) => model.featured?.tier === tier)
|
||||
.sort((a, b) => (a.featured?.rank ?? 0) - (b.featured?.rank ?? 0))
|
||||
.map((model) => ({
|
||||
badge: badge?.(model),
|
||||
description: model.description?.trim() || undefined,
|
||||
label: displayName(model),
|
||||
section,
|
||||
value: model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sectioned model picker for a provider from the tier data the SDK
|
||||
* stamps onto `ProviderModel.featured` (see @cline/core's
|
||||
* applyClineFeaturedModels). The `cline` provider gets Recommended / Free /
|
||||
* All models; `cline-pass` gets Subscribed / Free only — its offer is exactly
|
||||
* those tiers, and stale catalog leftovers must not be advertised (the full
|
||||
* catalog only returns when the subscribed tier is empty, so a subscriber is
|
||||
* never limited to free models offline). Every other provider renders its
|
||||
* catalog as a flat list ordered by display name.
|
||||
*/
|
||||
export function buildModelPickerData(
|
||||
providerId: string,
|
||||
models: ProviderModel[],
|
||||
): ModelPickerData {
|
||||
if (providerId === "cline") {
|
||||
const recommended = tierOptions(
|
||||
models,
|
||||
"recommended",
|
||||
"recommended",
|
||||
(model) => model.featured?.tags[0],
|
||||
);
|
||||
const free = tierOptions(models, "free", "free", () => "Free");
|
||||
if (recommended.length === 0 && free.length === 0) {
|
||||
return { options: flatOptions(models) };
|
||||
}
|
||||
const rest = models
|
||||
.filter((model) => !model.featured)
|
||||
.map((model) => ({
|
||||
label: displayName(model),
|
||||
section: "all",
|
||||
value: model.id,
|
||||
}))
|
||||
.sort(byLabel);
|
||||
return {
|
||||
options: [...recommended, ...free, ...rest],
|
||||
sections: [
|
||||
{ id: "recommended", label: "Recommended" },
|
||||
{
|
||||
description: FREE_SECTION_DESCRIPTION,
|
||||
id: "free",
|
||||
label: "Free",
|
||||
},
|
||||
{ id: "all", label: "All models" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId === "cline-pass") {
|
||||
const subscribed = tierOptions(models, "subscribed", "subscribed");
|
||||
const free = tierOptions(models, "free", "free", () => "Free");
|
||||
if (subscribed.length === 0 && free.length === 0) {
|
||||
return { options: flatOptions(models) };
|
||||
}
|
||||
const rest =
|
||||
subscribed.length === 0
|
||||
? models
|
||||
.filter((model) => !model.featured)
|
||||
.map((model) => ({
|
||||
label: displayName(model),
|
||||
section: "all",
|
||||
value: model.id,
|
||||
}))
|
||||
.sort(byLabel)
|
||||
: [];
|
||||
return {
|
||||
options: [...subscribed, ...free, ...rest],
|
||||
sections: [
|
||||
{ id: "subscribed", label: "Subscribed" },
|
||||
{
|
||||
description: CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
id: "free",
|
||||
label: "Free",
|
||||
},
|
||||
...(rest.length > 0
|
||||
? [{ id: "all", label: "All models" } as SearchComboboxSection]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { options: flatOptions(models) };
|
||||
}
|
||||