Compare commits

...
10 Commits
Author SHA1 Message Date
Saoud RizwanandClaude Opus 5 fb1d838085 chore(desktop): release v0.0.29
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:01:03 -07:00
Saoud RizwanandSaoud Rizwan 99db7582b0 desktop: show target mode icon on sidebar sort toggle (#14189)
The sidebar time/project toggle showed the icon for the mode that was
already active. Swap it so the icon reflects the mode a click switches to:
folder icon while sorted by time, clock icon while grouped by project.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-16 09:56:03 -07:00
BeeandSaoud Rizwan 15f001ad0b fix(desktop): steer queued prompts with Enter CLINE-3285 (#13958)
* feat(cli): add Enter key steering for empty input in queue

Allow empty input with Enter to promote the first queued prompt, enhancing navigation efficiency. Update hint text and add tests for key routing behavior.

* fix(desktop): bound queue steering waits and reject stale replies

* fix: steer the current queue head atomically in core

* ui update

* fix(core): type session ID in hub UI event test mock

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-16 00:45:20 -07:00
Mikołaj Kondratek 186ac22e29 telemetry: fire ui.panel_opened from initializeWebview on every host (#14101)
* telemetry: fire ui.panel_opened from initializeWebview on every host

ui.panel_opened was only captured from VscodeWebviewProvider, so the
JetBrains plugin never emitted it: the activation funnel there jumps from
user.extension_activated (core spawned on project open) straight to task
events, with no signal that the Cline UI was ever rendered.

The webview calls the initializeWebview RPC once on mount on every host,
so capture the same event there with source "webview_initialized".
Existing dashboards keyed on ui.panel_opened pick up JetBrains for free;
VS Code gains one extra source value on an event it already emits.

* telemetry: type the ui.panel_opened source

A PanelOpenedSource union on capturePanelOpened keeps the documented
source list and the call sites from drifting apart. Also note that on
JetBrains webview_initialized fires on every webview reload, so a
crash-restart loop emits one per restart.
2026-09-16 09:25:15 +02:00
fc68af9660 fix(desktop): add standard macOS margin to the Dock icon (#14155)
* fix(desktop): add standard macOS margin to the Dock icon

The bundled icon.icns and the selectable runtime Dock icons were
edge-to-edge, so Cline rendered larger than neighbouring apps in the
Dock. Regenerate icon.icns from the source artwork scaled to 824/1024
of the canvas, and add padded copies of the runtime icons under
icons/app/macos/ that set_app_icon uses on macOS. Windows keeps the
existing edge-to-edge .ico and icons/app PNGs.

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

* fix(desktop): refresh icon artwork and platform exports

* fix(desktop): limit artwork updates to macOS icons

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Haley Park <haleypark.design@gmail.com>
2026-09-16 00:17:46 -07:00
Mikołaj Kondratek b675eb6189 telemetry: report host-provided core spawn ordinal and reason as metadata (#14104)
* telemetry: report host-provided core spawn ordinal and reason as metadata

Out-of-process hosts spawn cline-core and sometimes respawn it: after a
crash, on a rollout fallback or demotion, or when the user restarts the
agent. From core's side every spawn is just another
user.extension_activated, so a core that crash-restarted three times and a
user with three project windows open are indistinguishable in telemetry.

Read CLINE_CORE_SPAWN_ORDINAL and CLINE_CORE_SPAWN_REASON from the spawn
env, the same contract as IS_DEV and CLINE_ROLLOUT_VARIANT, and attach
them as core_spawn_ordinal / core_spawn_reason metadata on both telemetry
pipelines (classic TelemetryService and the SDK handle), so they land on
every event. Malformed values are dropped field by field; in-process hosts
set nothing and get no fields, like host_plugin_version on the CLI.

The JetBrains plugin sets the env in a companion change.

* telemetry: type core_spawn_reason as the shared CoreSpawnReason union

Define CORE_SPAWN_REASONS / CoreSpawnReason once in @cline/shared next to
TelemetryMetadata and use the union in every metadata contract (shared,
classic TelemetryService, vitest stub) and in the env parser, so a
programmatic setMetadata/updateMetadata cannot introduce a reason the
parser's allowlist would have rejected.

* telemetry: derive the classic metadata's spawn fields from CoreSpawnTelemetryMetadata

Index into the parser's type instead of redeclaring both fields and their
doc comment, mirroring how extension_variant defers to
RolloutTelemetryMetadata. Also fix import order flagged by biome check.
2026-09-16 15:09:57 +09:00
Bee 82b8e1fa04 chore: Refine provider model catalog filtering (#13368)
* refactor model catalog filtering

* fix hub model compatibility filtering

* filter routine fallback models

* docs: correct image-output filter comments to match mergeKnownModels behavior (#13388)

* chore: revert unrelated merge-hook formatting
2026-09-15 11:57:30 -07:00
Saoud Rizwan 6e8bea1cba docs(changelog): drop the packaging note from the 4.1.18 entry
Whether 4.1.18 ships as the combined package or the standalone SDK build is
a release-mechanics detail that may change again; keep the entry to what
users actually get.
2026-09-15 02:01:52 -07:00
Saoud Rizwan 8739d4953c docs(changelog): describe 4.1.18 as a combined-package release
The 4.1.18 entry claimed the package was now SDK-only and smaller. The
standalone publish path is timing out against the Marketplace, so this
version ships through the combined A/B workflow instead; state that
accurately, since the entry is pasted verbatim into the release and Slack.
2026-09-15 02:00:14 -07:00
Saoud Rizwan c17c82b79a chore(desktop): release v0.0.28 2026-09-15 00:34:19 -07:00
54 changed files with 1157 additions and 130 deletions
-2
View File
@@ -2,8 +2,6 @@
## [4.1.18]
This is the first release built solely from the SDK extension. Earlier 4.1.x releases shipped a combined package containing both the SDK extension and the previous one, with a rollout flag choosing between them per window; that migration is now complete, so the package contains only the SDK extension and is substantially smaller to download and install.
### Added
- Images attached to a model that cannot read them are now flagged instead of silently discarded. Thumbnails get a warning badge and the composer explains that the images will be ignored, with a button to switch to an image-capable model. Previously the thumbnail looked normal and the image was replaced with a text placeholder just before the request, so there was no way to tell it had been dropped. Model info and the attachment picker also report image support accurately for models that declare text-only input without listing capabilities.
@@ -36,7 +36,7 @@ export function QueuedPrompts(props: {
? "Waiting. ↑/↓ navigate, Tab edit, Esc cancels turn"
: `Steered next. ↑/↓ navigate, Tab edit, ${escapeHint}`
: `↑/↓ navigate, Enter steer, Tab edit, ${escapeHint}`
: "↑ steer or edit messages";
: "Enter with empty input to steer first · ↑ select or edit";
return (
<box
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import type { KeyEvent } from "@opentui/core";
import { describe, expect, it, vi } from "vitest";
import { useRootKeyboard } from "./use-root-keyboard";
import { shouldHandleInputHistory } from "./root-keyboard-routing";
describe("root keyboard input history routing", () => {
@@ -29,3 +31,58 @@ describe("root keyboard input history routing", () => {
).toBe(false);
});
});
const keyboard = vi.hoisted(() => ({ handle: (_key: KeyEvent) => {} }));
vi.mock("@opentui/react", () => ({
useKeyboard: (handle: (key: KeyEvent) => void) => {
keyboard.handle = handle;
},
}));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useRef: (current: unknown) => ({ current }),
}));
vi.mock("../contexts/session-context", () => ({
useSession: () => ({ isRunning: true, isExitRequested: false }),
}));
describe("empty-input queue steering", () => {
it.each([
{ text: "", shift: false, promote: true },
{ text: " ", shift: false, promote: true },
{ text: "new draft", shift: false, promote: false },
{ text: "", shift: true, promote: false },
])("routes Enter with $text and shift=$shift", ({ text, shift, promote }) => {
const promotePrompt = vi.fn();
useRootKeyboard({
isDialogOpen: false,
appView: "chat",
autocomplete: { mode: false },
transcriptScrollRef: { current: null },
getCurrentInputText: () => text,
queuedPromptSelection: {
items: [
{ id: "first", prompt: "first", steer: false, attachmentCount: 0 },
{ id: "second", prompt: "second", steer: false, attachmentCount: 0 },
],
selectedId: null,
editingId: null,
promote: promotePrompt,
},
} as unknown as Parameters<typeof useRootKeyboard>[0]);
const preventDefault = vi.fn();
keyboard.handle({
name: "return",
ctrl: false,
meta: false,
shift,
repeated: false,
preventDefault,
} as unknown as KeyEvent);
expect(promotePrompt).toHaveBeenCalledTimes(promote ? 1 : 0);
if (promote) {
expect(promotePrompt).toHaveBeenCalledWith("first");
expect(preventDefault).toHaveBeenCalledOnce();
}
});
});
@@ -209,6 +209,22 @@ export function useRootKeyboard(input: {
return;
}
if (
!hasInputText &&
hasQueuedPrompts &&
!key.shift &&
!key.ctrl &&
!key.meta &&
(key.name === "enter" || key.name === "return")
) {
key.preventDefault();
const firstPrompt = queuedSelection.items[0];
if (firstPrompt && !firstPrompt.steer && !key.repeated) {
queuedSelection.promote(firstPrompt.id);
}
return;
}
if (key.name === "up" && canHandleInputHistory) {
if (
input.inputHistory.navigateHistory("up", input.inputValueRef.current)
@@ -60,6 +60,7 @@ import { desktopClient } from "@/lib/desktop-client";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
import {
filterChatModels,
loadProviderModelCatalog,
loadProviderModels,
} from "@/lib/provider-model-catalog";
@@ -492,7 +493,9 @@ export function RoutineSchedulesContent() {
async function loadModelsForProvider() {
try {
const models = await loadProviderModels(normalizedProvider);
const models = filterChatModels(
await loadProviderModels(normalizedProvider),
);
if (cancelled || models.length === 0) {
return;
}
@@ -16,57 +16,48 @@ export type ProviderModelCatalog = {
providerReasoningModels: Record<string, string[]>;
};
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter(
(model) =>
model.supportsReasoning &&
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.map((model) => model.id);
export function filterChatModels(
models: ProviderModel[] | undefined,
): ProviderModel[] {
return (models ?? []).filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
);
}
export function buildProviderModelCatalog(
providers: Provider[],
): ProviderModelCatalog {
const providerEntries = providers.map((provider) => {
const chatModels = filterChatModels(provider.modelList);
return {
provider,
modelIds: chatModels.map((model) => model.id),
reasoningModelIds: chatModels
.filter((model) => model.supportsReasoning)
.map((model) => model.id),
};
});
return {
providers,
enabledProviderIds: providers
enabledProviderIds: providerEntries
.filter(
(provider) =>
provider.enabled && toModelIds(provider.modelList).length > 0,
({ provider, modelIds }) => provider.enabled && modelIds.length > 0,
)
.map((provider) => provider.id),
.map(({ provider }) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
provider.id,
toModelIds(provider.modelList),
]),
providerEntries.map(({ provider, modelIds }) => [provider.id, modelIds]),
),
providerReasoningModels: Object.fromEntries(
providers.map((provider) => [
providerEntries.map(({ provider, reasoningModelIds }) => [
provider.id,
toReasoningModelIds(provider.modelList),
reasoningModelIds,
]),
),
};
+18
View File
@@ -1,5 +1,23 @@
# Cline Desktop Changelog
## 0.0.29
- Queued messages can now be sent into the running turn with Enter. While the agent is working, pressing Enter with an empty composer steers the first message in the queue, so it is picked up at the next turn boundary instead of waiting for the whole task to finish; the composer placeholder says so whenever the queue is non-empty. The queue head is claimed atomically, so a fast second Enter can't steer a message that has already left the queue, and a steer that fails now surfaces as a toast rather than silently doing nothing
- The macOS Dock icon now has the standard margin other apps use. The bundled icon and the selectable runtime icons (Classic, Chip, Hologram, Midnight) were edge-to-edge, so Cline rendered visibly larger than its neighbours in the Dock. Windows keeps its existing artwork
- The session sidebar's sort toggle now shows the mode a click switches to, not the one already active — a folder icon while sorted by time, a clock while grouped by project
## 0.0.28
- The app no longer gets stuck on "Desktop backend unavailable" when the backend is slow to start. The window asked for the backend's address exactly once and the shell stops waiting after about 15 seconds, so on a slower machine — where starting the hub pushed past that — you were left on the error screen until you relaunched, and the relaunch could lose the same race. Connection attempts now keep retrying and re-resolve the address each time, so a backend that has since restarted is reached at its current one
- Confirming text with a Chinese or Japanese IME no longer sends the message. The Enter that commits an in-progress composition also reached the composer, so choosing a candidate fired off a half-typed message; Enter and the arrow keys now belong to the IME while you are composing
- The model picker refreshes its list every time you open it, instead of staying frozen until you restart the app. The Recommended and Free groupings come from a live feed, so a newly promoted free model would otherwise not show up for the rest of the session
- Signing in with Cline now explains what the plan includes. The sign-in card lists the regular free model promotions, ClinePass for generous usage across open-weight models like DeepSeek, Kimi, and GLM, and that no API key is needed; the last onboarding step then shows the current free models alongside a ClinePass summary. This only appears when you sign in with Cline, not when you bring your own API key
- Cline Pass now starts you on a model from your subscription. Its model list carries both subscription and free models and the default was simply the most recently published entry, so a subscriber who never picked a model was left on a free model instead of the tier they pay for
- A turn that fails partway through with a temporary provider error is now retried up to three times instead of failing the task. A single rate-limit response passed along by a provider gateway previously ended the turn outright. A turn that has already produced output is never retried, so nothing is duplicated
- Long responses stream faster. Every chunk of a streaming reply was being handed to client-side hooks as a round trip carrying a full copy of the session, with the agent waiting on each one before continuing
- The command-running tool now tells the model which PowerShell edition it is talking to — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` — and to write commands directly rather than wrapping them in a second shell invocation, which was corrupting pipelines that use `$_`
- Refreshed the model catalog. Adds three providers (Infer by Flow7, Melious, and Wallaby) and takes the catalog from 5,923 to 6,079 models. The resolved default model changes for 33 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go — while NVIDIA moves to GLM 5.3 Flash and NanoGPT to GPT Astra. If you use a provider without pinning a model, expect a different default
## 0.0.27
- An expired Cline sign-in now produces one actionable error instead of two dead ends. A turn that fails before the runtime takes the prompt was reported twice — the hub's detail-less `run.failed` appended a bubble, then the send RPC resolved with the real error and overwrote the error banner underneath it — so you got a vague bubble stacked on a detailed banner. The failure now renders exactly once, upgrading in place when the detailed report arrives. Credential failures also carry a fix button: **Sign in to Cline** for the Cline provider (Settings → API Providers reads a stale token as "Signed in via browser", so there is nothing to fix there), **Open model settings** for others, and local-CLI providers keep pointing at their CLI. Sessions that fail to start over credentials get the same hint and action
+17
View File
@@ -63,6 +63,23 @@ names, paths, PR numbers/titles, check names, and URLs are not included.
Automatic polling does not emit additional impressions. Telemetry delivery
does not block interactions, and failures do not interrupt the feature.
## App Icons
`src-tauri/app-icon.png` (1024x1024, edge-to-edge) is the source for
`bun tauri icon`, which generates the Windows `.ico` and Linux PNGs in
`src-tauri/icons/`. macOS is the exception: Dock icons are expected to have a
transparent margin, with the artwork filling 824 of the 1024 canvas, so the
committed `icons/icon.icns` is built from a padded copy of the source, and the
selectable runtime icons in `icons/app/macos/` are padded copies of the
Windows ones in `icons/app/`. To regenerate the macOS icon after changing the
artwork:
```bash
cd src-tauri
magick app-icon.png -resize 824x824 -background none -gravity center -extent 1024x1024 /tmp/app-icon-macos.png
bun tauri icon /tmp/app-icon-macos.png -o /tmp/icons-macos && cp /tmp/icons-macos/icon.icns icons/icon.icns
```
## Customizing the macOS Install Window
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.27",
"version": "0.0.29",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -2280,3 +2280,35 @@ describe("mistake-limit prompt", () => {
expect(start).toHaveBeenCalledTimes(1);
});
});
describe("queue steering routing", () => {
it.each([
undefined,
"selected-prompt",
])("routes steering with prompt ID %s", async (promptId) => {
const result = { sessionId: "session", prompts: [], updated: false };
const steerFirst = vi.fn(async () => result);
const update = vi.fn(async () => result);
const ctx = {
liveSessions: new Map(),
wsClients: new Set(),
sessionManager: { pendingPrompts: { steerFirst, update } },
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "steer_prompt",
sessionId: "session",
promptId,
});
if (promptId === undefined) {
expect(steerFirst).toHaveBeenCalledWith({ sessionId: "session" });
expect(update).not.toHaveBeenCalled();
} else {
expect(update).toHaveBeenCalledWith({
sessionId: "session",
promptId,
delivery: "steer",
});
expect(steerFirst).not.toHaveBeenCalled();
}
});
});
@@ -1711,14 +1711,17 @@ async function handleSteerPrompt(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
if (!sessionId) throw new Error("sessionId is required");
if (request.promptId !== undefined && !promptId)
throw new Error("promptId cannot be empty");
const manager = getSessionManager(ctx);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
delivery: "steer",
});
const result = promptId
? await manager.pendingPrompts.update({
sessionId,
promptId,
delivery: "steer",
})
: await manager.pendingPrompts.steerFirst({ sessionId });
return {
sessionId,
updated: result.updated === true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.
@@ -814,15 +814,21 @@ 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 id has a matching bundled resource at
/// icons/app/<id>.png.
/// icons/app/<id>.png, plus a macOS variant at icons/app/macos/<id>.png with
/// the transparent margin the Dock expects (artwork fills ~80% of the canvas).
const APP_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
#[cfg(target_os = "macos")]
const APP_ICON_RESOURCE_DIR: &str = "icons/app/macos";
#[cfg(target_os = "windows")]
const APP_ICON_RESOURCE_DIR: &str = "icons/app";
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn resolve_app_icon(app: &tauri::AppHandle, icon: &str) -> Result<PathBuf, String> {
let icon_path = app
.path()
.resolve(
format!("icons/app/{icon}.png"),
format!("{APP_ICON_RESOURCE_DIR}/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving app icon resource: {e}"))?;
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.27",
"version": "0.0.29",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -38,7 +38,7 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/app/*.png"],
"resources": ["icons/app/*.png", "icons/app/macos/*.png"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -659,7 +659,7 @@ export function AgentSidebar({
);
// A single click flips straight to the other mode (a dropdown here would
// cost an extra click for a two-option choice); the icon shows the mode
// that is currently active.
// the click switches to, not the one currently active.
const sortToggle = (
<Button
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
@@ -676,9 +676,9 @@ export function AgentSidebar({
variant="ghost"
>
{sortMode === "time" ? (
<Clock3 className="size-3.5" />
) : (
<FolderTree className="size-3.5" />
) : (
<Clock3 className="size-3.5" />
)}
</Button>
);
@@ -1235,6 +1235,37 @@ describe("ChatInputBar", () => {
);
});
onSteerPromptInQueue.mockResolvedValueOnce(undefined);
const input = container.querySelector("textarea");
for (const modifiers of [
{ shiftKey: true },
{ isComposing: true },
{ repeat: true },
]) {
await act(async () => {
input?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
...modifiers,
}),
);
});
}
expect(onSteerPromptInQueue).not.toHaveBeenCalled();
await act(async () => {
input?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}),
);
});
expect(onSteerPromptInQueue).toHaveBeenCalledExactlyOnceWith();
onSteerPromptInQueue.mockClear();
const queueToggle = [
...container.querySelectorAll<HTMLButtonElement>(
"button[aria-controls][aria-expanded]",
@@ -314,7 +314,7 @@ type ChatInputBarProps = {
attachments: Array<{ id: string; name: string; isImage: boolean }>;
onAttachFiles: (files: File[]) => void;
onRemoveAttachment: (id: string) => void;
onSteerPromptInQueue: (promptId: string) => Promise<void> | void;
onSteerPromptInQueue: (promptId?: string) => Promise<void> | void;
onEditPromptInQueue: (
promptId: string,
prompt: string,
@@ -497,6 +497,23 @@ function ChatInputBarImpl({
? attachments.filter((attachment) => attachment.isImage).length
: 0;
const canSend = hasDraft && !speechInputActive;
const steeringPromptRef = useRef(false);
const steerFirstQueuedPrompt = async () => {
const firstPrompt = promptsInQueue[0];
if (!firstPrompt || firstPrompt.steer || steeringPromptRef.current) return;
steeringPromptRef.current = true;
try {
await onSteerPromptInQueue();
} catch (error) {
toast({
variant: "destructive",
title: "Could not steer queued message",
description: error instanceof Error ? error.message : String(error),
});
} finally {
steeringPromptRef.current = false;
}
};
const handleSend = useCallback(() => {
if (speechInputActive) return;
if (unsupportedDraftImageCount > 0) {
@@ -1093,7 +1110,11 @@ function ChatInputBarImpl({
<div
className={cn(
"px-4 py-3",
variant === "welcome" ? "pb-2 pt-4" : "py-4",
variant === "welcome"
? "pb-2 pt-4"
: promptsInQueue.length > 0
? "pb-4 pt-0"
: "py-4",
)}
>
<AgentPromptQueue
@@ -1337,6 +1358,15 @@ function ChatInputBarImpl({
e.preventDefault();
if (canSend) {
handleSend();
} else if (
!hasDraft &&
!speechInputActive &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.repeat
) {
void steerFirstQueuedPrompt();
}
}
}}
@@ -1351,7 +1381,9 @@ function ChatInputBarImpl({
: variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: isBusy
? "Agent is working... submit to queue another message"
? promptsInQueue.length > 0
? "Agent is working... submit to queue another message, or Enter to send the first message from the queue"
: "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
}
readOnly={speechInputActive}
@@ -83,6 +83,251 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("sends first-prompt steering intent without reading a queue snapshot", async () => {
const requests: Record<string, unknown>[] = [];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as Record<string, unknown>;
requests.push(request);
if (request.action === "start") return { sessionId: "atomic-steer" };
return { promptsInQueue: [] };
},
);
await act(async () => current.start(current.config));
requests.length = 0;
await act(async () => current.steerPromptInQueue());
expect(requests).toEqual([
{ action: "steer_prompt", sessionId: "atomic-steer" },
]);
});
it("steers the first server entry after enqueue acknowledgement without waiting for the active response", async () => {
const sessionId = "session-quick-steer";
const activeResponse = deferred<unknown>();
const queuedResponse = deferred<unknown>();
let acknowledged = false;
const queued = { id: "pending-real", prompt: "queued", steer: false };
const requests: Array<{
action?: string;
prompt?: string;
promptId?: string;
}> = [];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as (typeof requests)[number];
requests.push(request);
if (request.action === "start") return { sessionId };
if (request.action === "send")
return request.prompt === "active"
? activeResponse.promise
: queuedResponse.promise;
if (request.action === "pending_prompts")
return { promptsInQueue: acknowledged ? [queued] : [] };
if (request.action === "steer_prompt")
return {
updated: true,
promptsInQueue: [{ ...queued, steer: true }],
};
return {};
},
);
await act(async () => current.start(current.config));
let activeTask!: Promise<void>;
await act(async () => {
activeTask = current.sendPrompt("active");
});
let queuedTask!: Promise<void>;
await act(async () => {
queuedTask = current.sendPrompt("queued");
});
expect(current.promptsInQueue[0]?.id).not.toBe(queued.id);
let steerTask!: Promise<void>;
await act(async () => {
steerTask = current.steerPromptInQueue();
});
expect(requests.some((request) => request.action === "steer_prompt")).toBe(
false,
);
await act(async () => {
acknowledged = true;
queuedResponse.resolve({
ok: true,
queued: true,
promptsInQueue: [queued],
});
await queuedTask;
await steerTask;
});
expect(
requests.filter((request) => request.action === "steer_prompt"),
).toEqual([{ action: "steer_prompt", sessionId }]);
expect(current.status).toBe("running");
await act(async () => {
activeResponse.resolve({ ok: true });
await activeTask;
});
});
it("steers an acknowledged prompt when another submission never acknowledges", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-quick-steer";
const activeResponse = deferred<unknown>();
const queuedResponse = deferred<unknown>();
const acknowledged = true;
const queued = { id: "pending-real", prompt: "queued", steer: false };
const requests: Array<{
action?: string;
prompt?: string;
promptId?: string;
}> = [];
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as (typeof requests)[number];
requests.push(request);
if (request.action === "start") return { sessionId };
if (request.action === "send")
return request.prompt === "active"
? activeResponse.promise
: queuedResponse.promise;
if (request.action === "pending_prompts")
return { promptsInQueue: acknowledged ? [queued] : [] };
if (request.action === "steer_prompt")
return {
updated: true,
promptsInQueue: [{ ...queued, steer: true }],
};
return {};
},
);
await act(async () => current.start(current.config));
let activeTask!: Promise<void>;
await act(async () => {
activeTask = current.sendPrompt("active");
});
await act(async () => {
void current.sendPrompt("queued");
});
expect(current.promptsInQueue[0]?.id).toBe(queued.id);
let steerTask!: Promise<void>;
await act(async () => {
steerTask = current.steerPromptInQueue();
});
expect(
requests.some((request) => request.action === "steer_prompt"),
).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
await steerTask;
});
expect(
requests.filter((request) => request.action === "steer_prompt"),
).toEqual([{ action: "steer_prompt", sessionId }]);
await act(async () => {
activeResponse.resolve({ ok: true });
await activeTask;
});
} finally {
vi.useRealTimers();
}
});
it("does not resurrect a consumed queued prompt when its steering reply arrives late", async () => {
const sessionId = "session-steer-snapshot";
const reply = deferred<unknown>();
const first = { id: "pending-first", prompt: "first", steer: false };
const second = { id: "pending-second", prompt: "second", steer: false };
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as { action?: string };
if (request.action === "start") return { sessionId };
if (request.action === "steer_prompt") return reply.promise;
return {};
},
);
await act(async () => current.start(current.config));
await act(async () => {
handlerFor("prompts_in_queue_state")({
sessionId,
items: [first, second],
});
});
let steerTask!: Promise<void>;
await act(async () => {
steerTask = current.steerPromptInQueue(first.id);
});
await act(async () => {
handlerFor("chat_event")({
sessionId,
stream: "chat_queued_prompt_start",
chunk: JSON.stringify({ promptId: first.id, prompt: first.prompt }),
ts: Date.now(),
index: 1,
});
handlerFor("prompts_in_queue_state")({ sessionId, items: [second] });
});
await act(async () => {
reply.resolve({
updated: true,
promptsInQueue: [{ ...first, steer: true }, second],
});
await steerTask;
});
expect(current.promptsInQueue).toEqual([second]);
});
it.each([
"remove",
"edit",
"enqueue",
] as const)("preserves a concurrent queue %s when a steering reply arrives late", async (mutation) => {
const sessionId = "session-steer-snapshot";
const reply = deferred<unknown>();
const first = { id: "pending-first", prompt: "first", steer: false };
const second = { id: "pending-second", prompt: "second", steer: false };
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as { action?: string };
if (request.action === "start") return { sessionId };
if (request.action === "steer_prompt") return reply.promise;
return {};
},
);
await act(async () => current.start(current.config));
await act(async () => {
handlerFor("prompts_in_queue_state")({
sessionId,
items: [first, second],
});
});
let steerTask!: Promise<void>;
await act(async () => {
steerTask = current.steerPromptInQueue(first.id);
});
const newerQueue =
mutation === "remove"
? [second]
: mutation === "edit"
? [first, { ...second, prompt: "edited" }]
: [first, second, { id: "third", prompt: "third", steer: false }];
await act(async () => {
handlerFor("prompts_in_queue_state")({ sessionId, items: newerQueue });
});
await act(async () => {
reply.resolve({
updated: true,
promptsInQueue: [{ ...first, steer: true }, second],
});
await steerTask;
});
expect(current.promptsInQueue).toEqual(newerQueue);
});
it("restores an idle parent when aborting its child fails", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
@@ -77,6 +77,7 @@ const STREAM_FLUSH_INTERVAL_MS = 48;
// turn metadata) races the done event by a few milliseconds, so an immediate
// read could catch the file mid-rewrite.
const TURN_END_RECONCILE_DELAY_MS = 250;
const QUEUE_ACKNOWLEDGEMENT_WAIT_MS = 1_000;
const RELEVANT_STREAMS = new Set([
"chat_text",
@@ -406,7 +407,18 @@ export function useChatSession() {
const [pendingAskQuestions, setPendingAskQuestions] = useState<
AskQuestionRequestItem[]
>([]);
const [promptsInQueue, setPromptsInQueue] = useState<PromptInQueue[]>([]);
const [promptsInQueue, setPromptsInQueueState] = useState<PromptInQueue[]>(
[],
);
const queueRevisionRef = useRef(0);
const setPromptsInQueue = useCallback(
(value: React.SetStateAction<PromptInQueue[]>) => {
// Invalidate in-flight snapshots immediately, before React renders.
queueRevisionRef.current += 1;
setPromptsInQueueState(value);
},
[],
);
const messagesRef = useRef<ChatMessage[]>([]);
// When the last chat_event chunk for the active session arrived. The
// stale-stream fallback below only polls while this stays quiet.
@@ -437,6 +449,7 @@ export function useChatSession() {
const workspaceSelectionRequestRef = useRef(0);
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
const pendingQueueSubmissionsRef = useRef(new Map<Promise<void>, string>());
const activePromptSubmissionsRef = useRef(0);
const activeTurnCostTrackerRef = useRef<TurnCostTracker | null>(null);
const unpersistedCostUsdRef = useRef(0);
@@ -827,7 +840,7 @@ export function useChatSession() {
// Keep the last known state on transient transport failures.
});
},
[finalizeSettledTurn, postSession],
[finalizeSettledTurn, postSession, setPromptsInQueue],
);
const refreshPromptsInQueue = useCallback(
@@ -848,15 +861,18 @@ export function useChatSession() {
// Ignore queue refresh failures and keep the last known state.
}
},
[postSession],
[postSession, setPromptsInQueue],
);
const applyPromptsInQueue = useCallback((value: unknown) => {
if (!Array.isArray(value)) {
return;
}
setPromptsInQueue(value as PromptInQueue[]);
}, []);
const applyPromptsInQueue = useCallback(
(value: unknown) => {
if (!Array.isArray(value)) {
return;
}
setPromptsInQueue(value as PromptInQueue[]);
},
[setPromptsInQueue],
);
const sessionDiffCwd = (config.cwd || config.workspaceRoot || "").trim();
const refreshSessionDiffSummary = useCallback(
@@ -1164,7 +1180,12 @@ export function useChatSession() {
}
void refreshSessionDiffSummary(sessionId);
void refreshPromptsInQueue(sessionId);
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
}, [
refreshPromptsInQueue,
refreshSessionDiffSummary,
sessionId,
setPromptsInQueue,
]);
// Fallback for sessions with no tool events in the hook log (e.g. sessions
// recorded before tool_call/tool_result hook logging existed): rebuild the
@@ -1320,7 +1341,7 @@ export function useChatSession() {
if (record.sessionId !== activeSessionIdRef.current) return;
setPromptsInQueue(Array.isArray(record.items) ? record.items : []);
});
}, []);
}, [setPromptsInQueue]);
// ---- Incoming chunk handler ----
@@ -1832,6 +1853,7 @@ export function useChatSession() {
schedulePendingStreamFlush,
shouldApplyStreamChunk,
verifyQueueStillBusy,
setPromptsInQueue,
],
);
@@ -2146,6 +2168,7 @@ export function useChatSession() {
resetCounters,
setErrorState,
startSession,
setPromptsInQueue,
],
);
@@ -2173,9 +2196,15 @@ export function useChatSession() {
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
activePromptSubmissionsRef.current += 1;
let promptSubmissionFinished = false;
let queuedSubmission: Promise<void> | undefined;
let resolveQueuedSubmission: (() => void) | undefined;
const finishPromptSubmission = () => {
if (promptSubmissionFinished) return;
promptSubmissionFinished = true;
if (queuedSubmission) {
pendingQueueSubmissionsRef.current.delete(queuedSubmission);
resolveQueuedSubmission?.();
}
activePromptSubmissionsRef.current = Math.max(
0,
activePromptSubmissionsRef.current - 1,
@@ -2220,6 +2249,15 @@ export function useChatSession() {
(hasEarlierPromptSubmission ||
Boolean(pendingSessionStart) ||
BUSY_STATUSES.has(status));
if (shouldQueue && activeSessionId) {
queuedSubmission = new Promise<void>((resolve) => {
resolveQueuedSubmission = resolve;
});
pendingQueueSubmissionsRef.current.set(
queuedSubmission,
activeSessionId,
);
}
const turnCostTracker: TurnCostTracker | undefined = shouldQueue
? undefined
: { streamedCostUsd: 0 };
@@ -2844,6 +2882,7 @@ export function useChatSession() {
startSession,
status,
postSession,
setPromptsInQueue,
],
);
@@ -2949,6 +2988,7 @@ export function useChatSession() {
refreshSessionDiffSummary,
resetCounters,
status,
setPromptsInQueue,
],
);
@@ -3066,6 +3106,7 @@ export function useChatSession() {
postSession,
resetCounters,
clearLiveToolRefs,
setPromptsInQueue,
]);
const hydrateSession = useCallback(
@@ -3233,6 +3274,7 @@ export function useChatSession() {
refreshSessionDiffSummary,
resetStreamDedupe,
resetCounters,
setPromptsInQueue,
],
);
@@ -3282,21 +3324,56 @@ export function useChatSession() {
);
const steerPromptInQueue = useCallback(
async (promptId: string) => {
async (promptId?: string) => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId || !promptId.trim()) {
return;
if (!activeSessionId) return;
if (promptId === undefined) {
// Enter targets the first server queue entry. The composer can
// already be empty while its optimistic entry is still being sent.
// Give new submissions a bounded chance to reach the server. A
// lost acknowledgement must not block already queued prompts.
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
Promise.all(
[...pendingQueueSubmissionsRef.current]
.filter(([, sid]) => sid === activeSessionId)
.map(([submission]) => submission),
),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, QUEUE_ACKNOWLEDGEMENT_WAIT_MS);
}),
]);
} finally {
clearTimeout(timer);
}
if (activeSessionIdRef.current !== activeSessionId) return;
}
if (
(promptId !== undefined && !promptId.trim()) ||
activeSessionIdRef.current !== activeSessionId
)
return;
const epoch = turnEpochRef.current;
const queueRevision = queueRevisionRef.current;
const payload = await postSession({
action: "steer_prompt",
sessionId: activeSessionId,
promptId,
...(promptId === undefined ? {} : { promptId }),
});
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
// Steering can consume the prompt before this RPC returns. Its
// snapshot must not overwrite any newer local or remote queue change.
if (
activeSessionIdRef.current === activeSessionId &&
turnEpochRef.current === epoch &&
queueRevisionRef.current === queueRevision
) {
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
}
},
[postSession],
[postSession, setPromptsInQueue],
);
const updatePromptInQueue = useCallback(
@@ -3315,7 +3392,7 @@ export function useChatSession() {
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
},
[postSession],
[postSession, setPromptsInQueue],
);
const removePromptInQueue = useCallback(
@@ -3334,7 +3411,7 @@ export function useChatSession() {
);
return payload.prompt;
},
[postSession],
[postSession, setPromptsInQueue],
);
const summary = useMemo(
@@ -237,6 +237,10 @@ describe("transcription model selection", () => {
const catalog = buildProviderModelCatalog([provider]);
expect(catalog.providerModels.openai).toEqual(["gpt-audio", "gpt-text"]);
expect(catalog.providerModelDetails.openai).toEqual(
provider.modelList?.slice(1),
);
expect(catalog.providerNames.openai).toBe("OpenAI");
expect(
filterChatModels(provider.modelList).map((model) => model.id),
).toEqual(["gpt-audio", "gpt-text"]);
@@ -87,47 +87,45 @@ export function selectTranscriptionModel(
: null;
}
function toModelIds(models: ProviderModel[] | undefined): string[] {
return filterChatModels(models).map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return filterChatModels(models)
.filter((model) => model.supportsReasoning)
.map((model) => model.id);
}
export function buildProviderModelCatalog(
providers: Provider[],
voiceInput?: VoiceInputSelection,
): ProviderModelCatalog {
const providerEntries = providers.map((provider) => {
const chatModels = filterChatModels(provider.modelList);
return {
provider,
chatModels,
modelIds: chatModels.map((model) => model.id),
reasoningModelIds: chatModels
.filter((model) => model.supportsReasoning)
.map((model) => model.id),
};
});
return {
providers,
enabledProviderIds: providers
enabledProviderIds: providerEntries
.filter(
(provider) =>
provider.enabled && toModelIds(provider.modelList).length > 0,
({ provider, modelIds }) => provider.enabled && modelIds.length > 0,
)
.map((provider) => provider.id),
.map(({ provider }) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
provider.id,
toModelIds(provider.modelList),
]),
providerEntries.map(({ provider, modelIds }) => [provider.id, modelIds]),
),
providerModelDetails: Object.fromEntries(
providers.map((provider) => [
providerEntries.map(({ provider, chatModels }) => [
provider.id,
filterChatModels(provider.modelList),
chatModels,
]),
),
providerNames: Object.fromEntries(
providers.map((provider) => [provider.id, provider.name]),
),
providerReasoningModels: Object.fromEntries(
providers.map((provider) => [
providerEntries.map(({ provider, reasoningModelIds }) => [
provider.id,
toReasoningModelIds(provider.modelList),
reasoningModelIds,
]),
),
voiceInput: selectTranscriptionModel(providers, voiceInput),
@@ -20,6 +20,11 @@ import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
*/
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
try {
// The webview calls this once on mount, on every host. The VS Code-only
// "sidebar_resolved"/"sidebar_visible" sources never fire for JetBrains, so
// this is the only host-neutral signal that the Cline UI actually rendered.
telemetryService.capturePanelOpened("webview_initialized")
// Sync workflow toggles with the files on disk so the chat input's slash
// command menu knows about workflows without requiring the user to open
// the Workflows modal first (which is the only other place that refreshes
+2
View File
@@ -11,6 +11,7 @@ import { StateManager } from "@/core/storage/StateManager"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { getDistinctId } from "@/services/logging/distinctId"
import { getCoreSpawnTelemetryMetadata } from "@/services/telemetry/core-spawn-metadata"
import { getRolloutTelemetryMetadata } from "@/services/telemetry/rollout-metadata"
import { Setting } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
@@ -44,6 +45,7 @@ export function createVscodeSdkTelemetryHandle(options: CreateVscodeSdkTelemetry
os_type: process.platform,
os_version: os.version(),
is_dev: process.env.IS_DEV,
...getCoreSpawnTelemetryMetadata(),
...options.metadata,
},
}),
@@ -9,6 +9,7 @@ import { Logger } from "@/shared/services/Logger"
import { Mode } from "@/shared/storage/types"
import { version as extensionVersion } from "../../../package.json"
import { getDeviceId, setDistinctId } from "../logging/distinctId"
import { type CoreSpawnTelemetryMetadata, getCoreSpawnTelemetryMetadata } from "./core-spawn-metadata"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
import {
getRolloutErrorProperties,
@@ -91,6 +92,13 @@ export enum TerminalHangStage {
BUFFER_STUCK = "buffer_stuck",
}
/**
* Which hook fired `ui.panel_opened`. "sidebar_resolved" / "sidebar_visible" come from
* VscodeWebviewProvider and are VS Code-only; "webview_initialized" comes from the webview's
* mount-time initializeWebview RPC and fires on every host.
*/
export type PanelOpenedSource = "sidebar_resolved" | "sidebar_visible" | "webview_initialized"
export type TelemetryMetadata = {
/**
* The extension or cline-core version. JetBrains and CLI have different
@@ -110,6 +118,9 @@ export type TelemetryMetadata = {
* `extension_version`). Absent when the host does not report one (e.g. CLI).
*/
host_plugin_version?: string
// Spawn-time facts from an out-of-process host; see core-spawn-metadata.ts.
core_spawn_ordinal?: CoreSpawnTelemetryMetadata["core_spawn_ordinal"]
core_spawn_reason?: CoreSpawnTelemetryMetadata["core_spawn_reason"]
/** The name of the host IDE or environment e.g. VSCode, Cursor, IntelliJ Professional Edition, etc. */
platform: string
/** The version of the host environment */
@@ -324,7 +335,9 @@ export class TelemetryService {
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
// Tracks when a button is clicked
BUTTON_CLICKED: "ui.button_clicked",
// Tracks when the Cline panel becomes visible
// Tracks when the Cline panel becomes visible; `source` (PanelOpenedSource) says
// which hook fired. On JetBrains "webview_initialized" also fires on every webview
// reload, e.g. after a core restart, so a crash-restart loop emits one per restart.
PANEL_OPENED: "ui.panel_opened",
// Tracks when the user explicitly starts a new task flow
NEW_TASK_CLICKED: "ui.new_task_clicked",
@@ -361,6 +374,7 @@ export class TelemetryService {
// `remoteName` is normalized by the host bridge to `undefined` for local workspaces.
is_remote_workspace: !!hostVersion.remoteName,
is_dev: process.env.IS_DEV,
...getCoreSpawnTelemetryMetadata(),
...getRolloutTelemetryMetadata(),
}
return new TelemetryService(providers, metadata)
@@ -939,7 +953,7 @@ export class TelemetryService {
})
}
public capturePanelOpened(source?: string) {
public capturePanelOpened(source: PanelOpenedSource) {
this.capture({
event: TelemetryService.EVENTS.UI.PANEL_OPENED,
properties: { source },
@@ -0,0 +1,27 @@
import { describe, expect, it } from "bun:test"
import { CORE_SPAWN_ORDINAL_ENV, CORE_SPAWN_REASON_ENV, getCoreSpawnTelemetryMetadata } from "./core-spawn-metadata"
describe("core spawn telemetry metadata", () => {
it("reports ordinal and reason from the host's spawn env", () => {
expect(
getCoreSpawnTelemetryMetadata({ [CORE_SPAWN_ORDINAL_ENV]: "3", [CORE_SPAWN_REASON_ENV]: "crash_restart" }),
).toEqual({ core_spawn_ordinal: 3, core_spawn_reason: "crash_restart" })
})
it("omits everything when the host set nothing, as in-process VS Code does", () => {
expect(getCoreSpawnTelemetryMetadata({})).toEqual({})
})
it("drops malformed values independently of each other", () => {
expect(
getCoreSpawnTelemetryMetadata({ [CORE_SPAWN_ORDINAL_ENV]: "0", [CORE_SPAWN_REASON_ENV]: "crash_restart" }),
).toEqual({
core_spawn_reason: "crash_restart",
})
expect(getCoreSpawnTelemetryMetadata({ [CORE_SPAWN_ORDINAL_ENV]: "2", [CORE_SPAWN_REASON_ENV]: "because" })).toEqual({
core_spawn_ordinal: 2,
})
expect(getCoreSpawnTelemetryMetadata({ [CORE_SPAWN_ORDINAL_ENV]: "1.5" })).toEqual({})
expect(getCoreSpawnTelemetryMetadata({ [CORE_SPAWN_ORDINAL_ENV]: "" })).toEqual({})
})
})
@@ -0,0 +1,40 @@
/**
* Spawn-time facts about this cline-core process, reported by the host that
* launched it. Only out-of-process hosts (the JetBrains plugin) set these; the
* VS Code extension runs in-process and reports nothing, so the fields are
* simply absent there, the way `host_plugin_version` is absent on the CLI.
*
* Same contract as `IS_DEV` -> `is_dev` and `CLINE_ROLLOUT_VARIANT`: the host
* states a fact at spawn, core owns the telemetry. Without these, a core that
* crash-restarted three times and a user with three project windows both look
* like three `user.extension_activated` events.
*/
import { CORE_SPAWN_REASONS, type CoreSpawnReason } from "@cline/shared"
export const CORE_SPAWN_ORDINAL_ENV = "CLINE_CORE_SPAWN_ORDINAL"
export const CORE_SPAWN_REASON_ENV = "CLINE_CORE_SPAWN_REASON"
export { CORE_SPAWN_REASONS, type CoreSpawnReason }
export type CoreSpawnTelemetryMetadata = {
/** 1-based count of core processes this host window has spawned, including this one. */
core_spawn_ordinal?: number
core_spawn_reason?: CoreSpawnReason
}
export function getCoreSpawnTelemetryMetadata(env: NodeJS.ProcessEnv = process.env): CoreSpawnTelemetryMetadata {
const metadata: CoreSpawnTelemetryMetadata = {}
const ordinal = Number(env[CORE_SPAWN_ORDINAL_ENV])
if (Number.isInteger(ordinal) && ordinal >= 1) {
metadata.core_spawn_ordinal = ordinal
}
const reason = env[CORE_SPAWN_REASON_ENV]
if (isCoreSpawnReason(reason)) {
metadata.core_spawn_reason = reason
}
return metadata
}
function isCoreSpawnReason(value: string | undefined): value is CoreSpawnReason {
return value !== undefined && (CORE_SPAWN_REASONS as readonly string[]).includes(value)
}
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import type { CoreSpawnReason } from "@cline/shared"
import { createFileReadExecutor } from "../../../../sdk/packages/core/src/extensions/tools/executors/file-read"
export interface OAuthCredentials {
@@ -228,6 +229,8 @@ export interface TelemetryMetadata {
os_type: string
os_version: string
is_dev?: string
core_spawn_ordinal?: number
core_spawn_reason?: CoreSpawnReason
}
export interface ITelemetryService {
+1 -1
View File
@@ -165,7 +165,7 @@
},
"apps/examples/desktop-app": {
"name": "@cline/code",
"version": "0.0.25",
"version": "0.0.28",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@cline/core": "workspace:*",
+6
View File
@@ -884,6 +884,12 @@ The following workspace apps are internal and not published as SDK packages:
- `apps/webview` — VS Code webview
- `apps/examples` — example plugins and integrations
Queue steering through `pendingPrompts.steerFirst` selects and promotes the
current queue head in one synchronous core operation. Desktop Enter sends this
intent through the sidecar and Hub without fetching a prompt ID first; explicit
per-prompt steering continues to update by ID. Concurrent clients therefore
cannot make Enter promote an entry from a stale queue snapshot.
### SSH environments
`core/src/remote` owns the reusable SSH environment service and standalone remote
@@ -108,6 +108,91 @@ describe("AgentRuntime", () => {
expect(model.requests).toHaveLength(1);
});
it.each([
"text",
"partial-tool",
"empty",
"finish-event",
])("interrupts a blocked %s response to consume steering in the same run", async (content) => {
const started = Promise.withResolvers<void>();
let pending: string | undefined;
const tool = createEchoTool();
const execute = vi.spyOn(tool, "execute");
const { telemetry, capture } = createTelemetryMock();
const model = new ScriptedModel([
async function* (request) {
if (content !== "empty") {
yield { type: "text-delta", text: "Old response" };
}
if (content === "partial-tool") {
yield { type: "reasoning-delta", text: "unfinished reasoning" };
yield {
type: "tool-call-delta",
toolCallId: "partial",
toolName: "echo",
inputText: '{"text":',
};
}
await new Promise<void>((resolve) => {
request.signal?.addEventListener("abort", () => resolve(), {
once: true,
});
started.resolve();
});
if (content === "finish-event") {
yield { type: "finish", reason: "aborted" };
return;
}
throw new DOMException("Cancelled", "AbortError");
},
(request) => {
expect(request.signal?.aborted).toBe(false);
expect(request.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: "Change direction" }],
});
expect(
request.messages
.flatMap((message) => message.content)
.some(
(part) => part.type === "tool-call" || part.type === "reasoning",
),
).toBe(false);
return [
{ type: "text-delta", text: "Steered response" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
model,
tools: [tool],
telemetry,
consumePendingUserMessage: () => {
const message = pending;
pending = undefined;
return message;
},
});
const resultPromise = runtime.run("Start");
await started.promise;
pending = "Change direction";
runtime.notifyPendingUserMessage();
const result = await resultPromise;
expect(result.status).toBe("completed");
expect(result.outputText).toBe("Steered response");
expect(model.requests).toHaveLength(2);
expect(model.requests[0]?.signal?.aborted).toBe(true);
expect(execute).not.toHaveBeenCalled();
expect(
capture.mock.calls.some(
([event]) =>
event === TASK_PROVIDER_STREAM_FAILED_EVENT ||
event === TASK_CANCELLED_EVENT,
),
).toBe(false);
});
it("persists generated images in assistant message content", async () => {
const model = new ScriptedModel([
() => [
+75 -4
View File
@@ -537,6 +537,7 @@ export class AgentRuntime {
private overflowRecoveryAttempted = false;
private initialization?: Promise<void>;
private abortController?: AbortController;
private modelSteerController?: AbortController;
private readonly telemetryProviderId?: string;
private readonly telemetryModelId?: string;
@@ -566,6 +567,11 @@ export class AgentRuntime {
return this.execute(input);
}
/** Interrupt only the current model request; running tools finish normally. */
notifyPendingUserMessage(): void {
this.modelSteerController?.abort();
}
abort(reason?: unknown): void {
if (!this.abortController) {
return;
@@ -767,8 +773,17 @@ export class AgentRuntime {
// A fresh error slate per turn: nothing from a previous turn may leak
// into this turn's error classification or retry decision.
this.resetLastError();
const { message, finishReason } =
const { message, finishReason, interrupted } =
await this.generateAssistantMessageWithProviderRetry();
if (interrupted && message.content.length === 0) {
await this.emit({
type: "turn-finished",
snapshot: this.snapshot(),
iteration: this.state.iteration,
toolCallCount: 0,
});
continue;
}
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
@@ -810,6 +825,16 @@ export class AgentRuntime {
finishReason,
});
if (interrupted) {
await this.emit({
type: "turn-finished",
snapshot: this.snapshot(),
iteration: this.state.iteration,
toolCallCount: 0,
});
continue;
}
if (finishReason === "max-tokens" && toolCalls.length === 0) {
throw new Error(MAX_TOKENS_INCOMPLETE_TURN_MESSAGE);
}
@@ -999,6 +1024,7 @@ export class AgentRuntime {
private async generateAssistantMessageWithProviderRetry(): Promise<{
message: AgentMessage;
finishReason: AgentModelFinishReason;
interrupted?: boolean;
}> {
let attempt = 0;
for (;;) {
@@ -1117,6 +1143,7 @@ export class AgentRuntime {
private async generateAssistantMessageWithOverflowRecovery(): Promise<{
message: AgentMessage;
finishReason: AgentModelFinishReason;
interrupted?: boolean;
}> {
const first = await this.generateAssistantMessage();
if (!this.isRecoverableOverflowTurn(first)) {
@@ -1179,6 +1206,26 @@ export class AgentRuntime {
}): Promise<{
message: AgentMessage;
finishReason: AgentModelFinishReason;
interrupted?: boolean;
}> {
const controller = new AbortController();
this.modelSteerController = controller;
try {
return await this.generateAssistantMessageForRequest(controller, options);
} finally {
this.modelSteerController = undefined;
}
}
private async generateAssistantMessageForRequest(
steerController: AbortController,
options?: {
overflowRecovery?: boolean;
},
): Promise<{
message: AgentMessage;
finishReason: AgentModelFinishReason;
interrupted?: boolean;
}> {
const usageBeforeModel = cloneUsage(this.state.usage);
const modelRequestMetadata = omitUndefinedValues({
@@ -1267,6 +1314,15 @@ export class AgentRuntime {
durationMs: getTaskLifecycleDurationMs(),
phase: "provider_request_started",
});
// Steering cancels provider generation, while request preparation keeps
// the run-level signal so compaction and hooks can finish consistently.
request = {
...request,
signal: AbortSignal.any([
steerController.signal,
...(this.abortController ? [this.abortController.signal] : []),
]),
};
const stream = this.openTaskLifecycleStream(
request,
getTaskLifecycleDurationMs,
@@ -1285,6 +1341,7 @@ export class AgentRuntime {
let accumulatedReasoning = "";
for await (const event of stream) {
if (steerController.signal.aborted) break;
this.throwIfAborted();
switch (event.type) {
case "text-delta": {
@@ -1476,8 +1533,18 @@ export class AgentRuntime {
}
}
}
this.throwIfAborted();
const interrupted = steerController.signal.aborted;
if (interrupted) finishReason = "stop";
for (const item of sequence) {
// A cancelled stream may contain incomplete tool JSON or unsigned
// reasoning. Keep only replayable visible content from that response.
if (
interrupted &&
(item.type === "tool" || item.part.type === "reasoning")
)
continue;
if (item.type === "part") {
content.push(item.part);
continue;
@@ -1543,7 +1610,7 @@ export class AgentRuntime {
this.applyStopControl(control);
}
return { message, finishReason };
return { message, finishReason, interrupted };
}
private async *openTaskLifecycleStream(
@@ -1561,7 +1628,9 @@ export class AgentRuntime {
phase,
});
} catch (error) {
if (!this.isAbortError(error)) {
if (request.signal?.aborted && !this.abortController?.signal.aborted)
return;
if (!request.signal?.aborted && !this.isAbortError(error)) {
this.captureTaskLifecycleFailure(
error,
phase,
@@ -1586,7 +1655,9 @@ export class AgentRuntime {
yield event;
}
} catch (error) {
if (!this.isAbortError(error)) {
if (request.signal?.aborted && !this.abortController?.signal.aborted)
return;
if (!request.signal?.aborted && !this.isAbortError(error)) {
this.captureTaskLifecycleFailure(
error,
phase,
@@ -64,6 +64,9 @@ export function createClineCorePendingPromptsApi(
return service;
}
return {
steerFirst(input) {
return getService().steerFirst(input);
},
list(input) {
return getService().list(input);
},
@@ -1814,3 +1814,26 @@ describe("HubRuntimeHost", () => {
expect(disposeMock).toHaveBeenCalledTimes(1);
});
});
it("sends atomic first-prompt steering through the Hub without listing the queue", async () => {
const { HubRuntimeHost } = await import("./hub-runtime-host");
const result = {
sessionId: "session",
prompts: [{ id: "current-head", prompt: "current", delivery: "steer" }],
updated: true,
};
commandMock.mockResolvedValue({ payload: result });
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
try {
expect(
await host.pendingPrompts.steerFirst({ sessionId: "session" }),
).toMatchObject(result);
expect(commandMock).toHaveBeenCalledExactlyOnceWith(
"session.steer_first_pending_prompt",
{ sessionId: "session" },
"session",
);
} finally {
await host.dispose();
}
});
@@ -797,6 +797,7 @@ export class HubRuntimeHost implements RuntimeHost {
this.telemetry = options.telemetry;
this.runtimeAddress = options.url;
this.pendingPrompts = {
steerFirst: (input) => this.requestSteerFirstPendingPrompt(input),
list: (input) => this.requestPendingPromptsList(input),
update: (input) => this.requestPendingPromptUpdate(input),
delete: (input) => this.requestPendingPromptDelete(input),
@@ -1199,6 +1200,25 @@ export class HubRuntimeHost implements RuntimeHost {
: [];
}
private async requestSteerFirstPendingPrompt(
input: Parameters<PendingPromptsServiceApi["steerFirst"]>[0],
): Promise<PendingPromptMutationResult> {
this.ensureSessionSubscription(input.sessionId);
const reply = await this.client.command(
"session.steer_first_pending_prompt",
{ ...input },
input.sessionId,
);
return {
sessionId: input.sessionId,
prompts: Array.isArray(reply.payload?.prompts)
? (reply.payload.prompts as SessionPendingPrompt[])
: [],
prompt: reply.payload?.prompt as SessionPendingPrompt | undefined,
updated: reply.payload?.updated === true,
};
}
private async requestPendingPromptUpdate(
input: Parameters<PendingPromptsServiceApi["update"]>[0],
): Promise<PendingPromptMutationResult> {
@@ -1206,6 +1206,26 @@ export async function handleSessionPendingPrompts(
return okReply(envelope, { sessionId, prompts });
}
export async function handleSessionSteerFirstPendingPrompt(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
const service = ctx.sessionHost.pendingPrompts;
if (!service) {
return errorReply(
envelope,
"pending_prompts_unavailable",
"Pending prompt service is not available.",
);
}
const result = await service.steerFirst({ sessionId });
return okReply(
envelope,
result as unknown as Record<string, JsonValue | undefined>,
);
}
export async function handleSessionUpdatePendingPrompt(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
@@ -103,6 +103,7 @@ import {
handleSessionSearch,
handleSessionUpdate,
handleSessionUpdateConnection,
handleSessionSteerFirstPendingPrompt,
handleSessionUpdatePendingPrompt,
} from "./handlers/session-handlers";
import { HubEventLogStore } from "./hub-event-log";
@@ -846,6 +847,8 @@ export class HubServerTransport implements NativeHubTransport {
return await handleSessionCompactionUpdate(this.ctx, envelope);
case "session.pending_prompts":
return await handleSessionPendingPrompts(this.ctx, envelope);
case "session.steer_first_pending_prompt":
return await handleSessionSteerFirstPendingPrompt(this.ctx, envelope);
case "session.update_pending_prompt":
return await handleSessionUpdatePendingPrompt(this.ctx, envelope);
case "session.remove_pending_prompt":
@@ -173,6 +173,11 @@ describe("hub UI events", () => {
throw new Error("not used in this test");
},
pendingPrompts: {
steerFirst: async ({ sessionId }: { sessionId: string }) => ({
sessionId,
prompts: [],
updated: false,
}),
list: async () => [],
update: async () => ({
sessionId: sessionRecord.sessionId,
@@ -2766,6 +2766,7 @@ describe("LocalRuntimeHost", () => {
run,
continue: continueFn,
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -2843,6 +2844,7 @@ describe("LocalRuntimeHost", () => {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -2918,6 +2920,7 @@ describe("LocalRuntimeHost", () => {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(false),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
@@ -2953,6 +2956,8 @@ describe("LocalRuntimeHost", () => {
}),
).resolves.toBeUndefined();
expect(agent.notifyPendingUserMessage).toHaveBeenCalledOnce();
const consumed = await Promise.resolve(
agentConfig?.consumePendingUserMessage?.(),
);
@@ -3027,6 +3032,7 @@ describe("LocalRuntimeHost", () => {
run,
continue: continueTurn,
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -5022,6 +5028,7 @@ describe("LocalRuntimeHost", () => {
continue: continueFn,
canStartRun: vi.fn(() => canStartRun),
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -5324,6 +5331,7 @@ describe("LocalRuntimeHost", () => {
continue: vi.fn().mockResolvedValue(createResult({ text: "next" })),
canStartRun: vi.fn(() => false),
abort: vi.fn(),
notifyPendingUserMessage: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -329,6 +329,8 @@ export class LocalRuntimeHost implements RuntimeHost {
send: (input) => this.runTurn(input),
});
this.pendingPrompts = {
steerFirst: async (input) =>
this.pendingPromptsController.steerFirst(input.sessionId),
list: async (input) =>
this.pendingPromptsController.list(input.sessionId),
update: async (input) => this.pendingPromptsController.update(input),
@@ -296,6 +296,10 @@ export interface PendingPromptsDeleteInput {
}
export interface PendingPromptsServiceApi {
/** Select and steer the current queue head atomically in the runtime. */
steerFirst(
input: PendingPromptsListInput,
): Promise<PendingPromptMutationResult>;
list(input: PendingPromptsListInput): Promise<SessionPendingPrompt[]>;
update(
input: PendingPromptsUpdateInput,
@@ -591,6 +591,10 @@ export class SessionRuntime {
// Abort / shutdown
// -------------------------------------------------------------------
notifyPendingUserMessage(): void {
this.activeRuntime?.notifyPendingUserMessage();
}
abort(reason?: unknown): void {
const message =
typeof reason === "string"
@@ -120,6 +120,94 @@ describe("PendingPromptService", () => {
).toThrow("prompt cannot be empty");
});
it("wakes the running model when a queued prompt is promoted and preserves the remaining queue", () => {
const notifyPendingUserMessage = vi.fn();
const session = {
sessionId: "session",
pendingPrompts: [],
aborting: false,
agent: { canStartRun: () => false, notifyPendingUserMessage },
} as unknown as ActiveSession;
const controller = new PendingPromptsController({
getSession: () => session,
emit: vi.fn(),
send: vi.fn(),
});
controller.enqueue("session", { prompt: "first", delivery: "queue" });
controller.enqueue("session", { prompt: "second", delivery: "queue" });
expect(notifyPendingUserMessage).not.toHaveBeenCalled();
const first = controller.list("session")[0]!;
controller.update({
sessionId: "session",
promptId: first.id,
delivery: "steer",
});
expect(notifyPendingUserMessage).toHaveBeenCalledOnce();
expect(controller.consumeSteer("session")?.prompt).toBe("first");
expect(controller.list("session").map((prompt) => prompt.prompt)).toEqual([
"second",
]);
});
it.each([
"remove",
"reorder",
"insert",
] as const)("steers the current head after a concurrent %s instead of a stale snapshot", (mutation) => {
const notifyPendingUserMessage = vi.fn();
const session = {
sessionId: "session",
pendingPrompts: [],
aborting: false,
agent: { canStartRun: () => false, notifyPendingUserMessage },
} as unknown as ActiveSession;
const controller = new PendingPromptsController({
getSession: () => session,
emit: vi.fn(),
send: vi.fn(),
});
controller.enqueue("session", { prompt: "first", delivery: "queue" });
controller.enqueue("session", { prompt: "second", delivery: "queue" });
const staleHead = controller.list("session")[0]!;
if (mutation === "remove") {
controller.delete({ sessionId: "session", promptId: staleHead.id });
} else if (mutation === "reorder") {
// Re-enqueueing the same queued text moves that entry to the tail.
controller.enqueue("session", { prompt: "first", delivery: "queue" });
} else {
controller.enqueue("session", { prompt: "urgent", delivery: "steer" });
}
notifyPendingUserMessage.mockClear();
const result = controller.steerFirst("session");
expect(result.prompts[0]).toMatchObject({
prompt: mutation === "insert" ? "urgent" : "second",
delivery: "steer",
});
expect(result.prompts[0]?.id).not.toBe(staleHead.id);
expect(result.updated).toBe(mutation !== "insert");
expect(notifyPendingUserMessage).toHaveBeenCalledTimes(
mutation === "insert" ? 0 : 1,
);
});
it("does nothing when steering an empty or missing session queue", () => {
const controller = new PendingPromptsController({
getSession: (id) =>
id === "empty"
? ({ pendingPrompts: [] } as unknown as ActiveSession)
: undefined,
emit: vi.fn(),
send: vi.fn(),
});
for (const sessionId of ["empty", "missing"]) {
expect(controller.steerFirst(sessionId)).toEqual({
sessionId,
prompts: [],
updated: false,
});
}
});
it("keeps prompts enqueued while the session is aborting", async () => {
const sessionId = "sess-enqueue-while-aborting";
const session = {
@@ -213,6 +213,17 @@ export class PendingPromptsController {
return this.service.list(this.deps.getSession(sessionId));
}
steerFirst(sessionId: string): PendingPromptMutationResult {
// Selection and promotion share one synchronous runtime operation. No
// client snapshot or asynchronous request can intervene between them.
const session = this.deps.getSession(sessionId);
const first = session?.pendingPrompts[0];
if (!first || first.delivery === "steer") {
return { sessionId, prompts: this.service.list(session), updated: false };
}
return this.update({ sessionId, promptId: first.id, delivery: "steer" });
}
update(input: PendingPromptsUpdateInput): PendingPromptMutationResult {
const session = this.deps.getSession(input.sessionId);
if (!session) {
@@ -220,6 +231,13 @@ export class PendingPromptsController {
}
const result = this.service.update(session, input);
this.emitPrompts(session);
if (
result.updated &&
result.prompt?.delivery === "steer" &&
!session.aborting
) {
session.agent.notifyPendingUserMessage();
}
this.scheduleDrain(input.sessionId, session);
return result;
}
@@ -255,6 +273,9 @@ export class PendingPromptsController {
// abort is settling.
this.service.enqueue(session, entry);
this.emitPrompts(session);
if (entry.delivery === "steer" && !session.aborting) {
session.agent.notifyPendingUserMessage();
}
this.scheduleDrain(sessionId, session);
}
@@ -207,7 +207,11 @@ async function mergeKnownModels(
// Cline recommendations can use Vercel-style ids while the broader
// catalog includes OpenRouter aliases for the same models. Image-output
// models are temporarily unavailable through Cline's inference backend,
// so filter them only at the Cline catalog boundary.
// so filter them only at the Cline catalog boundary. buildClineModels
// applies the same restriction to the bundled catalog. User overrides are
// also subject to this filter — the backend rejects image output
// regardless of where the model was configured. Remove both filter call
// sites together when the backend gains image-output support.
return Llms.sortModelsByReleaseDate(
Llms.filterImageOutputModels({
...Llms.preferCanonicalModelIds(
@@ -4,7 +4,12 @@ import type { ModelInfo } from "./types";
* Remove models that declare image output from a model catalog.
*
* This includes both dedicated image-generation models and language models
* that can return mixed text-and-image output.
* that can return mixed text-and-image output. Cline deliberately applies
* this temporary backend limitation in both `buildClineModels` (the bundled
* catalog) and `mergeKnownModels` (the merged runtime sources, including
* user model overrides the backend rejects image output regardless of
* where the model was configured). Remove both filter call sites together
* when the inference backend supports image output.
*/
export function filterImageOutputModels(
models: Record<string, ModelInfo>,
+3 -1
View File
@@ -518,7 +518,9 @@ function buildClineModels(): Record<string, ModelInfo> {
);
// Cline's inference backend currently rejects image-output models. Keep
// those models in their native OpenRouter and Vercel catalogs.
// those models in their native OpenRouter and Vercel catalogs. This filter
// is also applied to the merged runtime catalog in mergeKnownModels; remove
// both call sites together when the backend gains image-output support.
return filterImageOutputModels(models);
}
+1
View File
@@ -543,6 +543,7 @@ export type HubCommandName =
| "session.compaction.get"
| "session.compaction.update"
| "session.pending_prompts"
| "session.steer_first_pending_prompt"
| "session.update_pending_prompt"
| "session.remove_pending_prompt"
| "session.fork"
+2
View File
@@ -529,6 +529,7 @@ export type {
TelemetryPrimitive,
TelemetryProperties,
TelemetryValue,
CoreSpawnReason,
} from "./services/telemetry";
export {
AGENT_UNEXPECTED_REASONING_TOKENS_EVENT,
@@ -547,6 +548,7 @@ export {
TASK_PROVIDER_REQUEST_STARTED_EVENT,
TASK_PROVIDER_STREAM_FAILED_EVENT,
TASK_PROVIDER_STREAM_STARTED_EVENT,
CORE_SPAWN_REASONS,
} from "./services/telemetry";
export type { ClineTelemetryServiceConfig } from "./services/telemetry-config";
export {
@@ -90,6 +90,19 @@ export interface CaptureTaskLifecycleEventInput {
messageLimit?: number;
}
/**
* Why an out-of-process host spawned this core. The JetBrains plugin sets it via
* `CLINE_CORE_SPAWN_REASON`; keep in sync with its `SpawnReason`.
*/
export const CORE_SPAWN_REASONS = [
"initial",
"crash_restart",
"rollout_fallback",
"rollout_demotion",
"user_restart",
] as const;
export type CoreSpawnReason = (typeof CORE_SPAWN_REASONS)[number];
export interface TelemetryMetadata {
extension_version: string;
/**
@@ -105,6 +118,13 @@ export interface TelemetryMetadata {
os_version: string;
is_dev?: string;
is_remote_workspace?: boolean;
/**
* Spawn-time facts reported by an out-of-process host (the JetBrains plugin): how many
* cores this host window has spawned so far and why this one was started. Absent when the
* host runs core in-process (VS Code).
*/
core_spawn_ordinal?: number;
core_spawn_reason?: CoreSpawnReason;
}
export interface ITelemetryService {
@@ -17,8 +17,8 @@ export interface AgentPromptQueueProps {
}
type IconName =
| "arrow-up"
| "check"
| "corner-down-left"
| "chevron-down"
| "chevron-right"
| "clock"
@@ -27,7 +27,7 @@ type IconName =
| "x";
const ACTION_CLASS_NAME =
"cline-ui-agent-prompt-queue__action inline-flex cursor-pointer items-center justify-center rounded-cline-ui-md border-0 bg-transparent p-1.5 text-cline-ui-muted-foreground transition-[color,background-color] duration-150 ease-[ease] enabled:hover:bg-cline-ui-accent enabled:hover:text-cline-ui-foreground focus-visible:outline-2 focus-visible:outline-cline-ui-ring focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50";
"cline-ui-agent-prompt-queue__action inline-flex cursor-pointer items-center justify-center rounded-cline-ui-md border-0 bg-transparent p-1 text-cline-ui-muted-foreground transition-[color,background-color] duration-150 ease-[ease] [&>svg]:size-3.5 enabled:hover:bg-cline-ui-accent enabled:hover:text-cline-ui-foreground focus-visible:outline-2 focus-visible:outline-cline-ui-ring focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50";
function Icon({ name, small = false }: { name: IconName; small?: boolean }) {
return (
@@ -44,13 +44,13 @@ function Icon({ name, small = false }: { name: IconName; small?: boolean }) {
strokeWidth="2"
viewBox="0 0 24 24"
>
{name === "arrow-up" ? (
{name === "check" ? <path d="M20 6 9 17l-5-5" /> : null}
{name === "corner-down-left" ? (
<>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
<path d="m9 10-5 5 5 5" />
<path d="M4 15h10a6 6 0 0 0 6-6V4" />
</>
) : null}
{name === "check" ? <path d="M20 6 9 17l-5-5" /> : null}
{name === "chevron-down" ? <path d="m6 9 6 6 6-6" /> : null}
{name === "chevron-right" ? <path d="m9 18 6-6-6-6" /> : null}
{name === "clock" ? (
@@ -113,6 +113,7 @@ export function AgentPromptQueue({
} | null>(null);
const [expanded, setExpanded] = useState(false);
const queueId = useId();
const hasMultipleItems = items.length > 1;
const cancelEdit = useCallback(() => {
setEditingId(null);
@@ -181,12 +182,18 @@ export function AgentPromptQueue({
if (items.length === 0) return null;
return (
<div className="cline-ui-agent-prompt-queue mb-2">
<div className="cline-ui-agent-prompt-queue mb-2 border-b border-cline-ui-border pb-0">
<button
aria-controls={queueId}
aria-expanded={expanded}
className="cline-ui-agent-prompt-queue__toggle flex w-full cursor-pointer items-center gap-2 rounded-cline-ui-md border-0 bg-transparent px-1.5 py-1 text-left font-cline-ui-medium text-cline-ui-foreground text-cline-ui-xs transition-[background-color] duration-150 ease-[ease] hover:bg-cline-ui-accent/60 focus-visible:outline-2 focus-visible:outline-cline-ui-ring focus-visible:outline-offset-0"
onClick={() => setExpanded((value) => !value)}
aria-expanded={hasMultipleItems ? expanded : true}
className={
hasMultipleItems
? "cline-ui-agent-prompt-queue__toggle flex w-full cursor-pointer items-center gap-2 rounded-cline-ui-md border-0 bg-transparent px-0 py-1 text-left font-cline-ui-medium text-cline-ui-foreground text-cline-ui-xs transition-[background-color] duration-150 ease-[ease] hover:bg-cline-ui-accent/60 focus-visible:outline-2 focus-visible:outline-cline-ui-ring focus-visible:outline-offset-0"
: "sr-only"
}
onClick={() => {
if (hasMultipleItems) setExpanded((value) => !value);
}}
type="button"
>
<Icon name={expanded ? "chevron-down" : "chevron-right"} small />
@@ -195,8 +202,8 @@ export function AgentPromptQueue({
</span>
</button>
<div
className="cline-ui-agent-prompt-queue__items flex max-h-[40dvh] flex-col gap-0.5 overflow-y-auto py-1"
hidden={!expanded}
className="cline-ui-agent-prompt-queue__items flex max-h-[40dvh] flex-col gap-0.5 overflow-y-auto py-0"
hidden={hasMultipleItems && !expanded}
id={queueId}
>
{items.map((item) => {
@@ -209,7 +216,7 @@ export function AgentPromptQueue({
return (
<div
aria-busy={isPending || undefined}
className="cline-ui-agent-prompt-queue__item flex min-w-0 items-center gap-2 rounded-cline-ui-md p-1.5 hover:bg-[color-mix(in_oklab,var(--cline-ui-accent)_35%,transparent)] data-[steer=true]:bg-cline-ui-primary/5"
className="cline-ui-agent-prompt-queue__item flex min-w-0 items-center gap-2 rounded-cline-ui-md px-0 py-1 hover:bg-[color-mix(in_oklab,var(--cline-ui-accent)_35%,transparent)] data-[steer=true]:bg-cline-ui-primary/5"
data-steer={item.steer || undefined}
key={item.id}
>
@@ -217,7 +224,11 @@ export function AgentPromptQueue({
className="cline-ui-agent-prompt-queue__status-icon inline-flex size-4 shrink-0 text-cline-ui-muted-foreground data-[steer=true]:text-cline-ui-primary"
data-steer={item.steer || undefined}
>
<Icon name={item.steer ? "arrow-up" : "clock"} />
{item.steer ? (
<Icon name="corner-down-left" />
) : (
<Icon name="clock" />
)}
</span>
<div className="cline-ui-agent-prompt-queue__content min-w-0 flex-1">
{isEditing ? (
@@ -300,10 +311,10 @@ export function AgentPromptQueue({
className={ACTION_CLASS_NAME}
disabled={isBusy}
onClick={() => void runAction(item, "steer")}
title="Steer next"
title="Steer"
type="button"
>
<Icon name="arrow-up" />
<Icon name="corner-down-left" />
</button>
) : null}
<button