Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7c1dfc298 | ||
|
|
23924da84f | ||
|
|
dd50b97192 | ||
|
|
5142921a54 | ||
|
|
e4f1df2c5a | ||
|
|
3f74808a43 | ||
|
|
1636215231 | ||
|
|
a0a8c060fb | ||
|
|
31b0dd9900 | ||
|
|
ea368a1fc5 | ||
|
|
f6359842a5 | ||
|
|
a35f9c7e35 | ||
|
|
05dbe644d1 | ||
|
|
7877d0e0a3 | ||
|
|
4063e1731e | ||
|
|
75ac98a3c8 | ||
|
|
258227dbe0 | ||
|
|
fc3273bbe0 | ||
|
|
ae0c86f11f | ||
|
|
46ba1d3228 | ||
|
|
35a0597471 | ||
|
|
c393958c52 | ||
|
|
be65a78b38 | ||
|
|
c8a0ace8b6 | ||
|
|
3033c394c7 | ||
|
|
2be718baba | ||
|
|
97a0d27d16 | ||
|
|
c52e87d52d | ||
|
|
35b4cd010a | ||
|
|
038102987e | ||
|
|
00fa85c164 | ||
|
|
08df88d6ec | ||
|
|
01aeb2147d | ||
|
|
1063db45d8 | ||
|
|
194214af76 | ||
|
|
2dd8f11ada | ||
|
|
bf4a364e08 | ||
|
|
fee4fb96f2 | ||
|
|
eb18ce3407 | ||
|
|
9fb1e6fc25 | ||
|
|
40f1d75bd2 | ||
|
|
b18de0904f | ||
|
|
de1d2a322c | ||
|
|
245a7d0ccb | ||
|
|
23f2197c53 | ||
|
|
9df2876c89 | ||
|
|
b63c738c22 | ||
|
|
bbacedd437 | ||
|
|
4096eca8a1 | ||
|
|
2f0f78bb8e | ||
|
|
62ba65397f | ||
|
|
79bf1e8c48 | ||
|
|
ffd65af086 | ||
|
|
119fa4ea03 | ||
|
|
40a7e6526d | ||
|
|
3d5070705c | ||
|
|
5a3b870d85 | ||
|
|
43bb575b6d | ||
|
|
50f5664683 | ||
|
|
8a520150f8 | ||
|
|
7e8063857b | ||
|
|
8ff5f22cf9 | ||
|
|
f5af82140b | ||
|
|
fc28a5fe33 | ||
|
|
5746f74ee7 | ||
|
|
d56cd10a6c | ||
|
|
6abb7cee76 | ||
|
|
afa1b01be1 | ||
|
|
c21b17255b | ||
|
|
dac3b35ba4 | ||
|
|
adbfbd97d3 | ||
|
|
952df213ee | ||
|
|
d7cb79aea5 | ||
|
|
5de79a75d0 | ||
|
|
ab3acd6a8a | ||
|
|
c322ab2bbc | ||
|
|
72b714b6be | ||
|
|
f0de3a2c20 | ||
|
|
4ae53292d3 | ||
|
|
3d0531238e | ||
|
|
b318c84f52 | ||
|
|
9e0af5c010 | ||
|
|
97b0700151 | ||
|
|
b46cf77ed1 | ||
|
|
c85384431d | ||
|
|
be59305d7a | ||
|
|
833be95cfb |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Warn when attached images will be ignored because the selected model does not support image input: image thumbnails get a warning badge and the composer offers to switch to an image-capable model, instead of the images being silently dropped before the API call
|
||||
@@ -319,6 +319,12 @@ jobs:
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# The DMG background, window size, and icon positions from
|
||||
# tauri.conf.json are applied by a Finder AppleScript in Tauri's
|
||||
# bundle_dmg.sh. When CI=true (always set on Actions) the bundler
|
||||
# passes --skip-jenkins and silently skips that script, shipping a
|
||||
# bare DMG. macOS runners have a GUI session, so opt out of the skip.
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
|
||||
@@ -61,8 +61,9 @@ Emission ownership:
|
||||
|
||||
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
|
||||
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
|
||||
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a de-duplicated emitter in
|
||||
`prepareLocalRuntimeBootstrap`. A dedicated host emits once per workspace; a shared Hub emits
|
||||
once per client surface and workspace. Hosts must NOT re-emit these.
|
||||
- `workspace.path_resolved`: emitted from default tool executors **only when**
|
||||
`WorkspaceManager` exposes more than one root.
|
||||
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
|
||||
@@ -104,6 +105,19 @@ hub-backed session, so the daemon must own its own `ITelemetryService`. It build
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
|
||||
The Hub transport forwards the serializable `ExtensionContext.client` and
|
||||
`ExtensionContext.user` values with session create/restore requests. The daemon wraps its
|
||||
process-owned service with `createClientScopedTelemetryService()` so lifecycle events use the
|
||||
originating client's `cline_type`, platform/version, and current account/organization without
|
||||
mutating the singleton shared by concurrent clients. Keep canonical task fields named
|
||||
`provider` and `model`; do not reintroduce host-specific aliases such as `apiProvider` or
|
||||
`modelId` for `task.created`, `task.restarted`, or `task.completed`.
|
||||
|
||||
`UserContext.distinctId` may be an anonymous machine ID. Set `UserContext.accountId` to the
|
||||
authenticated account ID (or `null` for an explicitly signed-out client) whenever a client
|
||||
forwards user context; this prevents machine IDs and stale daemon identity from becoming
|
||||
`user_id` / `organization_id` on task events.
|
||||
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.17]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- ClinePass is now surfaced across the app: a card on the account page describing what the plan covers, a hint in provider settings, and a banner on the home screen. Dismissed banners stay dismissed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the background Hub process ballooning in memory during long sessions. Session status updates broadcast a full copy of the conversation transcript to every connected client, so on a large task each status change shipped megabytes and could grow the process to tens of gigabytes. Snapshots now carry state only.
|
||||
- Hook scripts that fail to spawn no longer crash the extension's core process and take the running task down with them.
|
||||
- Fixed a chat render crash on malformed `api_req` payloads.
|
||||
- Cost estimates no longer appear in task history for subscription-billed tasks (ClinePass, ChatGPT via Codex, and Claude Code), matching the task header.
|
||||
- Pasted provider API keys are now stripped of the invisible characters clipboards smuggle in (newlines, zero-width spaces, BOM). A key corrupted that way was hidden by the masked field and rejected by the provider with a 401 indistinguishable from a genuinely wrong key. Credential rejections now say that the API key is the problem and point at its configuration, keeping the provider's raw response as a diagnostic tail.
|
||||
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when callback port 1455 is occupied. Previously the button opened a browser to a flow whose callback could never arrive, and nothing else happened. OAuth redirect errors such as `access_denied` are surfaced instead of being reported as a missing authorization code.
|
||||
- A transient network failure while refreshing OpenAI Codex or OpenAI-compatible-account tokens no longer signs you out. Only a genuinely rejected refresh token now requires re-authentication.
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request.
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty.
|
||||
- Restoring a checkpoint now refuses to run when commits were made after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected.
|
||||
- `apply_patch` now preserves a file's existing CRLF line endings.
|
||||
- Global rules are now also read from `~/Cline/Rules`, which is where the Rules tab writes them on WSL and headless installs whose Documents folder resolves to the home directory.
|
||||
- An enabled but unreachable remote (SSE or streamable HTTP) MCP server no longer stalls session startup; remote connects now have a 10 second budget.
|
||||
- Aborting a task now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running.
|
||||
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not ready in every published build while working in development.
|
||||
- Cline provider models are now read from the live catalog, so newly published models appear without an extension update.
|
||||
- Hook execution telemetry now fires; the task id was not threaded into hook runner creation, so those events were dropped.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed the built-in model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing throughout. This is an unusually wide refresh: the resolved default model changes for 57 providers, most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT following. If you use a provider without pinning a model, expect a different default.
|
||||
- The message the model receives when you reject a tool call now names the rejected tool and reads as your decision rather than an error.
|
||||
|
||||
## [4.1.16]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Cline</h1>
|
||||
|
||||
<p align="center">
|
||||
The open source coding agent in your IDE and terminal.
|
||||
The open source coding agent in your IDE, terminal, and desktop.
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -44,7 +44,7 @@ The open source coding agent in your IDE and terminal.
|
||||
### CLI
|
||||
|
||||
Run Cline in your terminal.
|
||||
Interactive chat or fully headless
|
||||
Interactive chat or fully headless
|
||||
for CI/CD and scripting.
|
||||
|
||||
```
|
||||
@@ -57,17 +57,13 @@ npm i -g cline
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
|
||||
### Kanban
|
||||
### Desktop App
|
||||
|
||||
Run many agents in parallel from a
|
||||
web-based task board. Each card gets its own
|
||||
worktree, auto-commit, and dependency chains.
|
||||
Cline as a native app for macOS and Windows.
|
||||
Run agent sessions in any folder, schedule
|
||||
routines, and manage models, plugins, and MCP servers.
|
||||
|
||||
```
|
||||
npm i -g kanban
|
||||
```
|
||||
|
||||
<a href="https://github.com/cline/kanban">Learn more</a>
|
||||
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -108,7 +104,7 @@ the JetBrains family.
|
||||
|
||||
### SDK
|
||||
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, Kanban, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, desktop app, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
|
||||
```
|
||||
npm install @cline/sdk
|
||||
@@ -131,8 +127,8 @@ npm install @cline/sdk
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
|
||||
|
||||
## Edits Code Across Your Project
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
## 3.0.60
|
||||
|
||||
- The config screen now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugins can be enabled or disabled with Space; the Hub persists the state and their skills and MCP servers follow it when the interactive runtime is rebuilt
|
||||
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing on files that contain a single enormous line
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { createCliCore } from "../session/session";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
@@ -423,8 +424,20 @@ async function loadInteractiveConfigDataForCommand(
|
||||
cwd: string,
|
||||
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
|
||||
const userInstructionService = createConfigUserInstructionService(cwd);
|
||||
const core = await createCliCore({
|
||||
backendMode: "auto",
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
});
|
||||
try {
|
||||
await userInstructionService.start();
|
||||
const [, agentPluginSettings] = await Promise.all([
|
||||
userInstructionService.start(),
|
||||
core.settings.list({
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
includePluginTools: true,
|
||||
}),
|
||||
]);
|
||||
return await loadInteractiveConfigData({
|
||||
userInstructionService,
|
||||
cwd,
|
||||
@@ -432,9 +445,11 @@ async function loadInteractiveConfigDataForCommand(
|
||||
availabilityContext: {
|
||||
mode: "act",
|
||||
},
|
||||
agentPluginSettings,
|
||||
});
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
await core.dispose("cli_config_command_complete");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
// Note: discord.js@14 declares undici ^6.27.0, but the root package.json
|
||||
// override ("undici": ">=7.29.0 <8") forces undici 7.x for CVE-2026-1525.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
|
||||
@@ -165,8 +165,14 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", async () => {
|
||||
// Keep dispatch tests independent of the full SDK runtime import graph.
|
||||
// Only persisted-settings behavior needs its real implementation here.
|
||||
const { readGlobalSettings } = await vi.importActual<
|
||||
typeof import("../../../sdk/packages/core/src/services/global-settings")
|
||||
>("../../../sdk/packages/core/src/services/global-settings");
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
readGlobalSettings,
|
||||
setSdkLogger: vi.fn(),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
applyPluginFailures,
|
||||
type InteractiveConfigItem,
|
||||
isToggleableInteractiveConfigItem,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { createInteractiveConfigDataLoader } from "./config-data";
|
||||
@@ -113,6 +114,172 @@ describe("interactive config data loader", () => {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("merges the hub-owned Agent Plugin inventory into the config view", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-plugin-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const pluginRoot = "/remote/home/.agents/plugins/portable-review";
|
||||
const calls: unknown[] = [];
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
loadCoreSettings: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
workflows: [],
|
||||
rules: [],
|
||||
tools: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
id: "portable-review:review",
|
||||
name: "review",
|
||||
path: `${pluginRoot}/skills/review/SKILL.md`,
|
||||
kind: "skill",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: "portable-review",
|
||||
pluginPath: pluginRoot,
|
||||
},
|
||||
],
|
||||
mcp: [
|
||||
{
|
||||
id: "portable-review.docs",
|
||||
name: "portable-review.docs",
|
||||
path: `${pluginRoot}/mcp.json`,
|
||||
kind: "mcp",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: "portable-review",
|
||||
pluginPath: pluginRoot,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: false });
|
||||
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
includePluginTools: false,
|
||||
}),
|
||||
]);
|
||||
expect(data.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "portable-review",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const skill = data.skills.find(
|
||||
(item) => item.id === "portable-review:review",
|
||||
);
|
||||
expect(skill).toMatchObject({
|
||||
pluginName: "portable-review",
|
||||
agentPlugin: true,
|
||||
});
|
||||
expect(skill && isToggleableInteractiveConfigItem(skill)).toBe(false);
|
||||
expect(data.mcp).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "portable-review.docs" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Agent Plugins through the hub without mutating client settings", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-toggle-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const globalSettingsPath = join(tempRoot, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
const pluginRoot = "/hub/home/.agents/plugins/portable-review";
|
||||
const toggleCalls: unknown[] = [];
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: {
|
||||
...createConfig(tempRoot),
|
||||
agentPluginPaths: ["./portable-review"],
|
||||
},
|
||||
toggleCoreSettings: async (input) => {
|
||||
toggleCalls.push(input);
|
||||
return {
|
||||
changedTypes: ["plugins", "skills", "mcp"],
|
||||
snapshot: {
|
||||
workflows: [],
|
||||
rules: [],
|
||||
tools: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: false,
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const data = await loader.onToggleConfigItem(
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
agentPlugin: true,
|
||||
},
|
||||
{ includePluginTools: false },
|
||||
);
|
||||
|
||||
expect(toggleCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "plugins",
|
||||
id: "agent-plugin:portable-review",
|
||||
path: pluginRoot,
|
||||
name: "portable-review",
|
||||
enabled: false,
|
||||
agentPluginPaths: ["./portable-review"],
|
||||
includePluginTools: false,
|
||||
}),
|
||||
]);
|
||||
expect(data?.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "portable-review",
|
||||
enabled: false,
|
||||
agentPlugin: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
type CoreSettingsListInput,
|
||||
type CoreSettingsMutationResult,
|
||||
type CoreSettingsSnapshot,
|
||||
type CoreSettingsToggleInput,
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
@@ -10,6 +14,7 @@ import {
|
||||
import {
|
||||
type InteractiveConfigData,
|
||||
type InteractiveConfigItem,
|
||||
isToggleableInteractiveConfigItem,
|
||||
type LoadInteractiveConfigDataOptions,
|
||||
loadInteractiveConfigData,
|
||||
} from "../../tui/interactive-config";
|
||||
@@ -18,6 +23,12 @@ import type { Config } from "../../utils/types";
|
||||
export function createInteractiveConfigDataLoader(input: {
|
||||
config: Config;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
loadCoreSettings?: (
|
||||
input: CoreSettingsListInput,
|
||||
) => Promise<CoreSettingsSnapshot>;
|
||||
toggleCoreSettings?: (
|
||||
input: CoreSettingsToggleInput,
|
||||
) => Promise<CoreSettingsMutationResult>;
|
||||
}) {
|
||||
const workspaceRoot = () =>
|
||||
input.config.workspaceRoot?.trim() || input.config.cwd;
|
||||
@@ -28,16 +39,36 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
enableSpawnAgent: input.config.enableSpawnAgent,
|
||||
enableAgentTeams: input.config.enableAgentTeams,
|
||||
});
|
||||
const loadConfigData = async (
|
||||
const buildSettingsInput = (
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData> =>
|
||||
await loadInteractiveConfigData({
|
||||
): CoreSettingsListInput => ({
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
agentPluginPaths: input.config.agentPluginPaths,
|
||||
includePluginTools: options.includePluginTools,
|
||||
});
|
||||
const buildConfigData = async (
|
||||
options: LoadInteractiveConfigDataOptions,
|
||||
agentPluginSettings: CoreSettingsSnapshot | undefined,
|
||||
): Promise<InteractiveConfigData> => {
|
||||
return await loadInteractiveConfigData({
|
||||
userInstructionService: input.userInstructionService,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
includePluginTools: options.includePluginTools,
|
||||
agentPluginSettings,
|
||||
});
|
||||
};
|
||||
const loadConfigData = async (
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData> => {
|
||||
const agentPluginSettings = await input
|
||||
.loadCoreSettings?.(buildSettingsInput(options))
|
||||
.catch(() => undefined);
|
||||
return await buildConfigData(options, agentPluginSettings);
|
||||
};
|
||||
|
||||
const refreshUserInstructionConfigs = async (): Promise<void> => {
|
||||
const service = input.userInstructionService;
|
||||
@@ -55,6 +86,9 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (!isToggleableInteractiveConfigItem(item)) {
|
||||
return undefined;
|
||||
}
|
||||
const settings = createCoreSettingsService();
|
||||
if (item.kind === "skill" && typeof item.enabled === "boolean") {
|
||||
await settings.toggle({
|
||||
@@ -72,6 +106,22 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
if (item.agentPlugin === true) {
|
||||
if (!input.toggleCoreSettings) {
|
||||
throw new Error(
|
||||
"Agent Plugin settings require a connected Cline Hub.",
|
||||
);
|
||||
}
|
||||
const result = await input.toggleCoreSettings({
|
||||
...buildSettingsInput(options),
|
||||
type: "plugins",
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
enabled: !item.enabled,
|
||||
});
|
||||
return await buildConfigData(options, result.snapshot);
|
||||
}
|
||||
if (item.enabled) {
|
||||
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
|
||||
setDisabledPlugin(item.path, true);
|
||||
@@ -150,7 +200,11 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (item.kind !== "plugin") {
|
||||
if (
|
||||
item.kind !== "plugin" ||
|
||||
item.agentPlugin === true ||
|
||||
item.deletable === false
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
await uninstallPlugin({
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
type CoreSettingsListInput,
|
||||
type CoreSettingsMutationResult,
|
||||
type CoreSettingsSnapshot,
|
||||
type CoreSettingsToggleInput,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
@@ -299,6 +303,19 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
const listCoreSettings = async (
|
||||
settingsInput: CoreSettingsListInput,
|
||||
): Promise<CoreSettingsSnapshot> => {
|
||||
const manager = await ensureSessionManager();
|
||||
return await manager.settings.list(settingsInput);
|
||||
};
|
||||
const toggleCoreSettings = async (
|
||||
settingsInput: CoreSettingsToggleInput,
|
||||
): Promise<CoreSettingsMutationResult> => {
|
||||
const manager = await ensureSessionManager();
|
||||
return await manager.settings.toggle(settingsInput);
|
||||
};
|
||||
|
||||
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
@@ -883,6 +900,8 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
return {
|
||||
ensureReady,
|
||||
listCoreSettings,
|
||||
toggleCoreSettings,
|
||||
sendCurrentTurn,
|
||||
updatePendingPrompt,
|
||||
getAccumulatedUsage,
|
||||
|
||||
@@ -200,10 +200,6 @@ export async function runInteractive(
|
||||
let pluginChatCommandHostPromise:
|
||||
| Promise<InteractiveSlashCommand[]>
|
||||
| undefined;
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
});
|
||||
const ensurePluginChatCommandHost = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
@@ -302,6 +298,12 @@ export async function runInteractive(
|
||||
uiEvents.emit("pending-prompt-submitted", event);
|
||||
},
|
||||
});
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
loadCoreSettings: sessionRuntime.listCoreSettings,
|
||||
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
|
||||
});
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
@@ -158,38 +160,6 @@ export async function createClineAccountService(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
@@ -213,16 +183,12 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
const accountContext = resolveClineAccountTelemetryIdentity(user);
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
persistClineAccountTelemetryIdentity(
|
||||
new ProviderSettingsManager(),
|
||||
accountContext,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -47,7 +47,10 @@ export function shouldCloseExtDetailForKey(keyName: string): boolean {
|
||||
|
||||
export function shouldToggleExtDetailForKey(
|
||||
keyName: string,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
keyName === "space" &&
|
||||
@@ -57,7 +60,10 @@ export function shouldToggleExtDetailForKey(
|
||||
}
|
||||
|
||||
export function getExtDetailFooterText(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): string {
|
||||
return typeof item.enabled === "boolean" &&
|
||||
isToggleableInteractiveConfigItem(item)
|
||||
|
||||
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* "Hub update required" dialog. Falls back to an unquantified phrase when the
|
||||
* Hub could not answer the activity query.
|
||||
*/
|
||||
export function describeOutdatedHubSessions(counts: {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}): string {
|
||||
const sessions = counts.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return "active sessions from other Cline clients";
|
||||
}
|
||||
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
|
||||
const clients = counts.participantClientCount;
|
||||
if (typeof clients !== "number" || clients <= 0) {
|
||||
return sessionsPhrase;
|
||||
}
|
||||
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo and sandbox sessions force the local backend and never attach to the
|
||||
* shared managed Hub (see the forceLocalBackend condition in the interactive
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import { describeOutdatedHubSessions } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
} from "./hub-update-required-helpers";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -15,11 +16,10 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
|
||||
import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
@@ -82,7 +83,7 @@ export function ProviderPickerContent(
|
||||
// just a model id and base URL) still render as configured.
|
||||
isConfigured: p.enabled === true,
|
||||
isOAuth: isOAuthProvider(p.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(p.id),
|
||||
isLocalAuth: isLocalAuthProvider(p.id),
|
||||
capabilities: p.capabilities,
|
||||
}));
|
||||
setProviders(providerItems);
|
||||
@@ -654,29 +655,25 @@ export function ProviderConfigInputContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexCliStatusContent(
|
||||
export function LocalCliStatusContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
cli?: ProviderLocalCli;
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const { resolve, dismiss, dialogId, cli, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [status, setStatus] = useState<LocalCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!cli) return;
|
||||
setStatus(undefined);
|
||||
setChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
checkLocalCliInstalled(cli)
|
||||
.then(setStatus)
|
||||
.catch((error: unknown) => {
|
||||
setStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => setChecking(false));
|
||||
}, []);
|
||||
}, [cli]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -691,7 +688,7 @@ export function CodexCliStatusContent(
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" && status?.installed) {
|
||||
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
|
||||
resolve(true);
|
||||
}
|
||||
}, dialogId);
|
||||
@@ -702,31 +699,37 @@ export function CodexCliStatusContent(
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
{checking && <text fg="gray">Checking for Codex CLI...</text>}
|
||||
{checking && <text fg="gray">Checking for {providerName}...</text>}
|
||||
|
||||
{status?.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={palette.success}>
|
||||
{"\u25cf"} {providerName} installed
|
||||
</text>
|
||||
<text fg="gray">{status.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{status && !status.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{providerName} was not found</text>
|
||||
<text fg="gray">{status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{cli?.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {providerName} from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{status?.installed
|
||||
{cli
|
||||
? "Enter to continue, R to recheck, Esc to go back"
|
||||
: "R to recheck, Esc to go back"}
|
||||
: "Enter to continue, Esc to go back"}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
};
|
||||
const availableWidth = Math.max(1, width - 4);
|
||||
const maxWidth = Math.min(44, availableWidth);
|
||||
// Border and horizontal padding take four columns. An explicit width (not
|
||||
// maxWidth) is what makes the text wrap instead of clipping at the edge.
|
||||
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
|
||||
const right = width < 32 ? 0 : 2;
|
||||
const color = variantColor[props.toast.variant];
|
||||
|
||||
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
zIndex={100}
|
||||
top={1}
|
||||
right={right}
|
||||
maxWidth={maxWidth}
|
||||
width={boxWidth}
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={color}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
|
||||
import { getLocalCliInfo } from "../../utils/local-cli";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
@@ -20,8 +20,8 @@ import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
LocalCliStatusContent,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type ThinkingLevel,
|
||||
ThinkingLevelContent,
|
||||
} from "../components/model-selector/model-selector";
|
||||
import { resolveProviderSetupRoute } from "../views/onboarding/model";
|
||||
|
||||
export interface OpenModelSelectorOptions {
|
||||
onCancel?: () => Promise<void> | void;
|
||||
@@ -178,6 +179,9 @@ async function runProviderChange(
|
||||
async () => await getProviderDisplayName(newProviderId),
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
const needsLocalCliSetup =
|
||||
resolveProviderSetupRoute(newProviderId) === "local_cli";
|
||||
const localCliProvider = getLocalCliInfo(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
@@ -246,12 +250,16 @@ async function runProviderChange(
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
} else if (needsLocalCliSetup) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<CodexCliStatusContent {...ctx} providerName={displayName} />
|
||||
<LocalCliStatusContent
|
||||
{...ctx}
|
||||
cli={localCliProvider}
|
||||
providerName={displayName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "node:path";
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
type CoreSettingsItem,
|
||||
type CoreSettingsSnapshot,
|
||||
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
@@ -80,6 +82,12 @@ export interface InteractiveConfigItem {
|
||||
| "global-plugin"
|
||||
| "workspace-plugin";
|
||||
description?: string;
|
||||
/** True when the hub discovered this through agent-plugins.org. */
|
||||
agentPlugin?: boolean;
|
||||
/** Explicitly overrides the default toggle policy for this item. */
|
||||
toggleable?: boolean;
|
||||
/** Explicitly overrides the default delete policy for this item. */
|
||||
deletable?: boolean;
|
||||
}
|
||||
|
||||
export interface InteractiveConfigData {
|
||||
@@ -100,8 +108,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): boolean {
|
||||
if (item.toggleable !== undefined) {
|
||||
return item.toggleable;
|
||||
}
|
||||
if (item.kind === "mcp") {
|
||||
return !item.pluginName;
|
||||
}
|
||||
@@ -324,12 +338,53 @@ export function applyPluginFailures(
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentPluginInteractiveItem(
|
||||
item: CoreSettingsItem,
|
||||
): InteractiveConfigItem {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
enabled: item.enabled,
|
||||
kind: item.kind,
|
||||
source: item.source,
|
||||
description: item.description,
|
||||
pluginName: item.pluginName,
|
||||
pluginPath: item.pluginPath,
|
||||
loadError: item.loadError,
|
||||
agentPlugin: true,
|
||||
toggleable: item.toggleable ?? false,
|
||||
deletable: false,
|
||||
...(item.kind === "plugin" ? { configKind: "plugin" as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendAgentPluginSnapshotItems(
|
||||
target: InteractiveConfigItem[],
|
||||
items: readonly CoreSettingsItem[],
|
||||
): void {
|
||||
const existing = new Set(
|
||||
target.map((item) => `${item.kind}\0${item.id}\0${item.path}`),
|
||||
);
|
||||
for (const item of items) {
|
||||
if (item.agentPlugin !== true) {
|
||||
continue;
|
||||
}
|
||||
const key = `${item.kind}\0${item.id}\0${item.path}`;
|
||||
if (!existing.has(key)) {
|
||||
target.push(toAgentPluginInteractiveItem(item));
|
||||
existing.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInteractiveConfigData(input: {
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
availabilityContext?: BuiltinToolAvailabilityContext;
|
||||
includePluginTools?: boolean;
|
||||
agentPluginSettings?: CoreSettingsSnapshot;
|
||||
}): Promise<InteractiveConfigData> {
|
||||
const workflows: InteractiveConfigItem[] = [];
|
||||
const rules: InteractiveConfigItem[] = [];
|
||||
@@ -518,14 +573,23 @@ export async function loadInteractiveConfigData(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.agentPluginSettings) {
|
||||
appendAgentPluginSnapshotItems(plugins, input.agentPluginSettings.plugins);
|
||||
appendAgentPluginSnapshotItems(skills, input.agentPluginSettings.skills);
|
||||
appendAgentPluginSnapshotItems(mcp, input.agentPluginSettings.mcp);
|
||||
}
|
||||
|
||||
const existsLocallyOrComesFromHub = (item: InteractiveConfigItem) =>
|
||||
item.agentPlugin === true || existsSync(item.path);
|
||||
|
||||
return {
|
||||
workflows: toSorted(workflows.filter((item) => existsSync(item.path))),
|
||||
rules: toSorted(rules.filter((item) => existsSync(item.path))),
|
||||
skills: toSorted(skills.filter((item) => existsSync(item.path))),
|
||||
hooks: toSorted(hooks.filter((item) => existsSync(item.path))),
|
||||
agents: toSorted(agents.filter((item) => existsSync(item.path))),
|
||||
plugins: toSorted(plugins.filter((item) => existsSync(item.path))),
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
workflows: toSorted(workflows.filter(existsLocallyOrComesFromHub)),
|
||||
rules: toSorted(rules.filter(existsLocallyOrComesFromHub)),
|
||||
skills: toSorted(skills.filter(existsLocallyOrComesFromHub)),
|
||||
hooks: toSorted(hooks.filter(existsLocallyOrComesFromHub)),
|
||||
agents: toSorted(agents.filter(existsLocallyOrComesFromHub)),
|
||||
plugins: toSorted(plugins.filter(existsLocallyOrComesFromHub)),
|
||||
mcp: toSorted(mcp.filter(existsLocallyOrComesFromHub)),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
|
||||
@@ -128,12 +128,61 @@ export function resolveActiveConfigItems(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigPluginSection {
|
||||
label: string;
|
||||
items: InteractiveConfigItem[];
|
||||
}
|
||||
|
||||
export function getConfigPluginSections(
|
||||
items: readonly InteractiveConfigItem[],
|
||||
): ConfigPluginSection[] {
|
||||
const clinePlugins = items.filter((item) => item.agentPlugin !== true);
|
||||
const agentPlugins = items.filter((item) => item.agentPlugin === true);
|
||||
return [
|
||||
...(clinePlugins.length > 0
|
||||
? [
|
||||
{
|
||||
label: `Cline Plugins (${clinePlugins.length})`,
|
||||
items: clinePlugins,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agentPlugins.length > 0
|
||||
? [
|
||||
{
|
||||
label: `Agent Plugins (${agentPlugins.length})`,
|
||||
items: agentPlugins,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function getConfigTabCountHeading(
|
||||
tab: InteractiveConfigTab,
|
||||
itemCount: number,
|
||||
): string | undefined {
|
||||
return tab === "plugins" ? undefined : `${toTabLabel(tab)} (${itemCount})`;
|
||||
}
|
||||
|
||||
export function shouldRenderConfigItemAsEnabled(
|
||||
item: InteractiveConfigItem,
|
||||
enabledState: "enabled" | "disabled" | "partial",
|
||||
): boolean {
|
||||
return (
|
||||
enabledState === "enabled" &&
|
||||
(isToggleableInteractiveConfigItem(item) || item.agentPlugin === true)
|
||||
);
|
||||
}
|
||||
|
||||
export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return isToggleableInteractiveConfigItem(item);
|
||||
}
|
||||
|
||||
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return item.kind === "plugin";
|
||||
return (
|
||||
item.deletable ?? (item.kind === "plugin" && item.agentPlugin !== true)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfigItemSelectAction(
|
||||
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigPluginSections,
|
||||
getConfigTabCountHeading,
|
||||
isDeletableConfigItem,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveConfigItemDeleteAction,
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
shouldRenderConfigItemAsEnabled,
|
||||
} from "./config-view-helpers";
|
||||
|
||||
function createItem(
|
||||
@@ -71,6 +76,65 @@ describe("config view helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lets users toggle hub-discovered Agent Plugins without deleting them", () => {
|
||||
const plugin = createItem({
|
||||
kind: "plugin",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
source: "global-plugin",
|
||||
});
|
||||
|
||||
expect(isToggleableConfigItem(plugin)).toBe(true);
|
||||
expect(isDeletableConfigItem(plugin)).toBe(false);
|
||||
expect(resolveConfigItemToggleAction(plugin)).toEqual({
|
||||
kind: "toggle-item",
|
||||
item: plugin,
|
||||
});
|
||||
expect(resolveConfigItemDeleteAction(plugin)).toBeUndefined();
|
||||
expect(resolveConfigItemSelectAction(plugin)).toEqual({
|
||||
kind: "toggle-item",
|
||||
item: plugin,
|
||||
});
|
||||
});
|
||||
|
||||
it("separates Cline and Agent Plugins into labeled sections", () => {
|
||||
const clinePlugin = createItem({
|
||||
kind: "plugin",
|
||||
name: "cline-plugin",
|
||||
source: "workspace-plugin",
|
||||
});
|
||||
const agentPlugin = createItem({
|
||||
kind: "plugin",
|
||||
name: "portable-plugin",
|
||||
source: "global-plugin",
|
||||
agentPlugin: true,
|
||||
});
|
||||
|
||||
expect(getConfigPluginSections([clinePlugin, agentPlugin])).toEqual([
|
||||
{ label: "Cline Plugins (1)", items: [clinePlugin] },
|
||||
{ label: "Agent Plugins (1)", items: [agentPlugin] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses section counts instead of a combined Plugins heading", () => {
|
||||
expect(getConfigTabCountHeading("plugins", 10)).toBeUndefined();
|
||||
expect(getConfigTabCountHeading("skills", 20)).toBe("Skills (20)");
|
||||
});
|
||||
|
||||
it("renders a loaded Agent Plugin as enabled", () => {
|
||||
const agentPlugin = createItem({
|
||||
kind: "plugin",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
});
|
||||
|
||||
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "enabled")).toBe(true);
|
||||
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "disabled")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Enter/Tab on a skill row to details", () => {
|
||||
const skill = createItem({
|
||||
kind: "skill",
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigPluginSections,
|
||||
getConfigTabCountHeading,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
shouldRenderConfigItemAsEnabled,
|
||||
toTabLabel,
|
||||
} from "./config-view-helpers";
|
||||
|
||||
@@ -298,6 +301,16 @@ function appendSkillRows(
|
||||
}
|
||||
}
|
||||
|
||||
function appendPluginRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
): void {
|
||||
for (const section of getConfigPluginSections(items)) {
|
||||
rows.push({ kind: "head", label: section.label });
|
||||
appendExtRows(rows, section.items);
|
||||
}
|
||||
}
|
||||
|
||||
function withOptimisticToggle(
|
||||
data: InteractiveConfigData,
|
||||
item: InteractiveConfigItem,
|
||||
@@ -477,10 +490,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
|
||||
} else {
|
||||
const activeItems = resolveActiveConfigItems(configData, activeTab);
|
||||
r.push({
|
||||
kind: "head",
|
||||
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
|
||||
});
|
||||
const countHeading = getConfigTabCountHeading(
|
||||
activeTab,
|
||||
activeItems.length,
|
||||
);
|
||||
if (countHeading) {
|
||||
r.push({ kind: "head", label: countHeading });
|
||||
}
|
||||
|
||||
if (activeItems.length === 0 && !pluginToolsLoading) {
|
||||
r.push({
|
||||
@@ -504,6 +520,21 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
} else if (activeTab === "skills") {
|
||||
appendSkillRows(r, activeItems);
|
||||
} else if (activeTab === "plugins") {
|
||||
appendPluginRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const item of activeItems) {
|
||||
r.push({
|
||||
@@ -523,19 +554,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: getPluginLoadErrorLabel(item),
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTab === "mcp") {
|
||||
@@ -871,11 +889,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: "○ "
|
||||
: "";
|
||||
const rightLabel = row.rightLabel ?? "";
|
||||
const toggleable = isToggleableConfigItem(row.item);
|
||||
const prefix = " ".repeat(row.indent ?? 0);
|
||||
const rowColor = row.item.loadError
|
||||
? "red"
|
||||
: toggleable && enabledState === "enabled"
|
||||
: shouldRenderConfigItemAsEnabled(row.item, enabledState)
|
||||
? palette.success
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
getLocalCliInfo,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
@@ -59,6 +60,7 @@ import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
@@ -103,6 +106,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [authError, setAuthError] = useState("");
|
||||
const [activeProviderId, setActiveProviderId] = useState("");
|
||||
const [activeProviderName, setActiveProviderName] = useState("");
|
||||
const localCli = useMemo(
|
||||
() => getLocalCliInfo(activeProviderId),
|
||||
[activeProviderId],
|
||||
);
|
||||
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
|
||||
{},
|
||||
);
|
||||
@@ -110,10 +117,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
|
||||
const [byoFocusedField, setByoFocusedField] =
|
||||
useState<ProviderConfigFieldKey>("apiKey");
|
||||
const [codexCliStatus, setCodexCliStatus] = useState<
|
||||
CodexCliStatus | undefined
|
||||
const [localCliStatus, setLocalCliStatus] = useState<
|
||||
LocalCliStatus | undefined
|
||||
>();
|
||||
const [codexCliChecking, setCodexCliChecking] = useState(false);
|
||||
const [localCliChecking, setLocalCliChecking] = useState(false);
|
||||
const localCliProbeRef = useRef(0);
|
||||
const authAbortRef = useRef(false);
|
||||
|
||||
// Device code flow
|
||||
@@ -486,18 +494,23 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
.then(setCodexCliStatus)
|
||||
.catch((error: unknown) => {
|
||||
setCodexCliStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const refreshLocalCliStatus = useCallback((provider: ProviderLocalCli) => {
|
||||
// Probing spawns the provider's CLI, so a result can land long after the
|
||||
// user moved on. Two local-CLI providers share this single status, so an
|
||||
// unlabelled result could mark the selected provider ready off a probe of
|
||||
// the previous one (or block it off a stale failure). Only the newest
|
||||
// probe may write.
|
||||
const probeId = ++localCliProbeRef.current;
|
||||
const isCurrentProbe = () => localCliProbeRef.current === probeId;
|
||||
setLocalCliStatus(undefined);
|
||||
setLocalCliChecking(true);
|
||||
checkLocalCliInstalled(provider)
|
||||
.then((status) => {
|
||||
if (isCurrentProbe()) setLocalCliStatus(status);
|
||||
})
|
||||
.finally(() => setCodexCliChecking(false));
|
||||
.finally(() => {
|
||||
if (isCurrentProbe()) setLocalCliChecking(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
@@ -510,12 +523,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
|
||||
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
|
||||
setActiveProviderId(provider.id);
|
||||
setActiveProviderName(provider.name);
|
||||
setCodexCliStatus(undefined);
|
||||
setStep("codex_cli_setup");
|
||||
refreshCodexCliStatus();
|
||||
setStep("local_cli_setup");
|
||||
// Only providers that name a CLI have something to probe; the
|
||||
// rest reach the screen with readiness simply unknown.
|
||||
const localCliProvider = getLocalCliInfo(provider.id);
|
||||
if (localCliProvider) refreshLocalCliStatus(localCliProvider);
|
||||
return;
|
||||
}
|
||||
const config = getProviderConfigFields(provider.id);
|
||||
@@ -575,11 +590,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setByoFocusedField(firstField ?? "apiKey");
|
||||
setStep("byo_apikey");
|
||||
},
|
||||
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
|
||||
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
|
||||
);
|
||||
|
||||
const saveCodexCliConfig = useCallback(() => {
|
||||
if (!codexCliStatus?.installed) {
|
||||
const recheckLocalCli = useCallback(() => {
|
||||
if (localCli) {
|
||||
refreshLocalCliStatus(localCli);
|
||||
}
|
||||
}, [localCli, refreshLocalCliStatus]);
|
||||
|
||||
const saveLocalCliConfig = useCallback(() => {
|
||||
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
|
||||
return;
|
||||
}
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
@@ -588,7 +609,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
transitionToModelPicker(activeProviderId);
|
||||
}, [
|
||||
activeProviderId,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliStatus,
|
||||
providerSettingsManager,
|
||||
transitionToModelPicker,
|
||||
]);
|
||||
@@ -798,13 +820,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
deviceAbortRef.current = true;
|
||||
},
|
||||
resetAuth,
|
||||
refreshCodexCliStatus,
|
||||
refreshLocalCliStatus: recheckLocalCli,
|
||||
startOAuthFlow,
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
saveThinkingLevel,
|
||||
@@ -820,8 +842,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
byoValues,
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliChecking,
|
||||
localCliStatus,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
@@ -860,7 +883,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providersLoading,
|
||||
recommendedLoading: recommended.loading,
|
||||
saveByoConfig,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveCustomModelId,
|
||||
selectedModelName,
|
||||
step,
|
||||
|
||||
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
refreshCodexCliStatus: () => void;
|
||||
refreshLocalCliStatus: () => void;
|
||||
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveLocalCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
saveThinkingLevel: (level: ThinkingLevel) => void;
|
||||
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
if (key.name === "r") {
|
||||
input.refreshCodexCliStatus();
|
||||
input.refreshLocalCliStatus();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
input.saveCodexCliConfig();
|
||||
input.saveLocalCliConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getLocalCliInfo } from "../../../utils/local-cli";
|
||||
|
||||
vi.mock("../../../utils/local-cli", () => ({
|
||||
getLocalCliInfo: () => undefined,
|
||||
}));
|
||||
|
||||
import {
|
||||
canContinueLocalCliSetup,
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -77,6 +85,20 @@ describe("onboarding model helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the Claude Code provider as local auth", () => {
|
||||
expect(
|
||||
toProviderEntry({
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
models: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "claude-code",
|
||||
isOAuth: false,
|
||||
isLocalAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps model names and reasoning support strictly", () => {
|
||||
expect(
|
||||
toModelEntry({
|
||||
@@ -166,3 +188,31 @@ describe("onboarding model helpers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-auth setup routing", () => {
|
||||
// A provider can declare `local-auth` without naming a CLI we can probe.
|
||||
// Routing must follow the capability; the descriptor is only for probing.
|
||||
// Otherwise it falls through to the API-key form, which renders no fields
|
||||
// for a local-auth provider.
|
||||
it("routes a local-auth provider with no CLI descriptor to local setup", () => {
|
||||
expect(getLocalCliInfo("claude-code")).toBeUndefined();
|
||||
expect(resolveProviderSetupRoute("claude-code")).toBe("local_cli");
|
||||
});
|
||||
|
||||
it("routes OAuth and API-key providers unchanged", () => {
|
||||
expect(resolveProviderSetupRoute("anthropic")).toBe("api_key");
|
||||
});
|
||||
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary. A PATH miss
|
||||
// therefore means "not on PATH", not "unusable", so it must not block.
|
||||
it("lets the user continue when the CLI is not found on PATH", () => {
|
||||
const cli = { command: "claude", docsUrl: "https://example.invalid" };
|
||||
expect(
|
||||
canContinueLocalCliSetup(cli, {
|
||||
installed: false,
|
||||
reason: "The claude executable was not found on PATH.",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,14 @@ import type {
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
} from "../../../utils/provider-auth";
|
||||
|
||||
export type OnboardingStep =
|
||||
| "menu"
|
||||
@@ -13,7 +19,7 @@ export type OnboardingStep =
|
||||
| "device_code"
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "local_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
@@ -85,6 +91,35 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Which setup flow a provider needs. Keyed off how the provider authenticates,
|
||||
* so every caller routes the same way.
|
||||
*/
|
||||
export type ProviderSetupRoute = "oauth" | "local_cli" | "api_key";
|
||||
|
||||
export function resolveProviderSetupRoute(
|
||||
providerId: string,
|
||||
): ProviderSetupRoute {
|
||||
if (isOAuthProvider(providerId)) return "oauth";
|
||||
if (isLocalAuthProvider(providerId)) return "local_cli";
|
||||
return "api_key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local-CLI setup screen lets the user connect.
|
||||
*/
|
||||
export function canContinueLocalCliSetup(
|
||||
_cli: ProviderLocalCli | undefined,
|
||||
_status: LocalCliStatus | undefined,
|
||||
): boolean {
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary, and Codex falls
|
||||
// back through `npx`. A PATH miss therefore means "not on PATH", not
|
||||
// "unusable", so the screen reports it without blocking — a provider that
|
||||
// really cannot start says so on the first turn, in its own words.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMainMenuOptions(options?: {
|
||||
isClinePassEnabled?: boolean;
|
||||
}): MenuOption[] {
|
||||
@@ -174,7 +209,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isOAuth: isOAuthProvider(provider.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(provider.id),
|
||||
isLocalAuth: isLocalAuthProvider(provider.id),
|
||||
hasAuth:
|
||||
Boolean(provider.apiKey) || provider.oauthAccessTokenPresent === true,
|
||||
...(provider.capabilities ? { capabilities: provider.capabilities } : {}),
|
||||
|
||||
@@ -2,10 +2,10 @@ import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
} from "../../../utils/codex-cli";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
ClineModelPicker,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,6 +25,7 @@ import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
@@ -362,18 +363,20 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingCodexCliScreen(props: {
|
||||
export function OnboardingLocalCliScreen(props: {
|
||||
activeProviderName: string;
|
||||
checking: boolean;
|
||||
cli?: ProviderLocalCli;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
status?: CodexCliStatus;
|
||||
status?: LocalCliStatus;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
const installedStatus =
|
||||
props.status?.installed === true ? props.status : undefined;
|
||||
const canContinue = canContinueLocalCliSetup(props.cli, props.status);
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -386,31 +389,37 @@ export function OnboardingCodexCliScreen(props: {
|
||||
{props.checking && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking for Codex CLI...</text>
|
||||
<text fg="gray">Checking for {props.activeProviderName}...</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{installedStatus && (
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={colors.success}>
|
||||
{"\u25cf"} {props.activeProviderName} installed
|
||||
</text>
|
||||
<text fg="gray">{installedStatus.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.status && !props.status.installed && (
|
||||
{props.cli && props.status && !props.status.installed && (
|
||||
<box flexDirection="column" gap={1} width={props.contentWidth}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{props.activeProviderName} was not found</text>
|
||||
<text fg="gray">{props.status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{props.cli.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {props.activeProviderName} from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{props.cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{installedStatus
|
||||
{canContinue
|
||||
? "Enter to continue, R to recheck, Esc to go back, Ctrl+C to exit"
|
||||
: "R to recheck, Esc to go back, Ctrl+C to exit"}
|
||||
</em>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
OnboardingDoneScreen,
|
||||
OnboardingLocalCliScreen,
|
||||
OnboardingMainMenuScreen,
|
||||
OnboardingModelPickerScreen,
|
||||
OnboardingOAuthPendingScreen,
|
||||
@@ -83,15 +83,16 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "codex_cli_setup") {
|
||||
if (state.step === "local_cli_setup" && state.localCli) {
|
||||
return (
|
||||
<OnboardingCodexCliScreen
|
||||
<OnboardingLocalCliScreen
|
||||
activeProviderName={state.activeProviderName}
|
||||
checking={state.codexCliChecking}
|
||||
checking={state.localCliChecking}
|
||||
cli={state.localCli}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
mouse={mouse}
|
||||
status={state.codexCliStatus}
|
||||
status={state.localCliStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const OPENAI_CODEX_CLI_PROVIDER_ID = "openai-codex-cli";
|
||||
export const CODEX_CLI_INSTALL_URL = "https://developers.openai.com/codex/cli";
|
||||
|
||||
export type CodexCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function isOpenAICodexCliProvider(providerId: string): boolean {
|
||||
return providerId.trim().toLowerCase() === OPENAI_CODEX_CLI_PROVIDER_ID;
|
||||
}
|
||||
|
||||
export async function checkCodexCliInstalled(): Promise<CodexCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync("codex", ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || "codex",
|
||||
};
|
||||
} catch (error) {
|
||||
const details =
|
||||
error && typeof error === "object"
|
||||
? (error as { code?: unknown; message?: unknown })
|
||||
: undefined;
|
||||
const code = typeof details?.code === "string" ? details.code : "";
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: "The codex executable was not found on PATH.",
|
||||
};
|
||||
}
|
||||
const message =
|
||||
typeof details?.message === "string"
|
||||
? details.message
|
||||
: "Could not run codex --version.";
|
||||
return {
|
||||
installed: false,
|
||||
reason: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isLocalAuthProvider } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLocalCliInfo } from "./local-cli";
|
||||
|
||||
describe("local CLI providers", () => {
|
||||
it("reads the CLI a local-auth provider borrows credentials from", () => {
|
||||
expect(getLocalCliInfo("openai-codex-cli")).toEqual({
|
||||
command: "codex",
|
||||
docsUrl: "https://developers.openai.com/codex/cli",
|
||||
});
|
||||
expect(getLocalCliInfo("claude-code")).toEqual({
|
||||
command: "claude",
|
||||
docsUrl: "https://code.claude.com/docs/en/setup",
|
||||
});
|
||||
});
|
||||
|
||||
it("names no CLI for providers that authenticate with an API key", () => {
|
||||
expect(getLocalCliInfo("anthropic")).toBeUndefined();
|
||||
expect(getLocalCliInfo("openai-codex")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Routing is keyed off the capability alone, so a local-auth provider whose
|
||||
// credentials come from somewhere unprobeable still reaches the local setup
|
||||
// screen instead of an empty API-key form.
|
||||
it("routes on the capability, not on knowing a CLI", () => {
|
||||
expect(isLocalAuthProvider("claude-code")).toBe(true);
|
||||
expect(isLocalAuthProvider("openai-codex-cli")).toBe(true);
|
||||
expect(isLocalAuthProvider("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { Llms } from "@cline/core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type ProviderLocalCli = Llms.ProviderLocalCli;
|
||||
|
||||
export type LocalCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The CLI a `local-auth` provider borrows credentials from, as declared in
|
||||
* the provider catalog. `undefined` for providers that name none — those are
|
||||
* connected without a readiness check rather than probing a guessed command.
|
||||
*/
|
||||
export function getLocalCliInfo(
|
||||
providerId: string,
|
||||
): ProviderLocalCli | undefined {
|
||||
return Llms.resolveProviderLocalCli(providerId);
|
||||
}
|
||||
|
||||
export async function checkLocalCliInstalled(
|
||||
cli: ProviderLocalCli,
|
||||
): Promise<LocalCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync(cli.command, ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || cli.command,
|
||||
};
|
||||
} catch (error) {
|
||||
const details = error as NodeJS.ErrnoException | undefined;
|
||||
if (details?.code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: `The ${cli.command} executable was not found on PATH.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: false,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Could not run ${cli.command} --version.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
@@ -21,7 +22,7 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
export { isOAuthProvider };
|
||||
export { isLocalAuthProvider, isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
|
||||
@@ -109,7 +109,9 @@ export async function handleDesktopCommand(
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
return await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
|
||||
@@ -85,7 +85,9 @@ export async function loadModels(
|
||||
if (!provider) return;
|
||||
const payload = await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.26
|
||||
|
||||
- The composer now shows the current branch's GitHub pull request — PR number, merge status, changed-line totals, and CI checks. Click through to open it in your browser, or expand CI to inspect individual checks and their logs; status refreshes every 30 seconds while visible, on window focus, and on demand. If the branch has no PR, **Create PR** opens GitHub's comparison form. Requires the GitHub CLI (`gh`) installed and signed in, plus a GitHub.com `origin` remote; the row hides itself on the default branch, detached HEAD, and unsupported repositories. Cline does not push commits or submit the PR for you
|
||||
- The Customize view's Tools, Skills, and Rules tabs now read as one consistent list instead of three different ones, matching the pattern Plugins already used. Tools gets a search bar that filters both sections and per-section Enable all/Disable all that only touches what's visible; Skills gets an in-place enable/disable toggle — previously the desktop app had no concept of a disabled skill, so disabled ones were hidden and unreachable — and the same Copy path / Uninstall menu Plugins use. Tabs are reordered to Tools, Plugins, Skills, Rules, MCP, Hooks and open on Tools, and the sidebar's "New Task" button is now "New Session"
|
||||
- Deleting a queued prompt no longer leaves it in the transcript as a message that never ran. The sidecar inferred a queued prompt had started whenever the pending list shrank and its head changed — which is exactly what deleting the first queued prompt (or discarding the queue) looks like. It now relies only on the runtime's real start event
|
||||
- A session forked from a checkpoint restore no longer comes back stuck on "Thinking...". Sessions that materialize with seeded history never passed their status when persisting, so the row and manifest were written as "running" while the live session was idle; resuming from that manifest then showed a session that was never going to finish
|
||||
- Sending an image with no text no longer fails with "session input requires a prompt string" — the composer asks for a message instead of letting the request through to a backend that rejects it
|
||||
- Image attachments in formats the model pipeline can't read (HEIC, TIFF, SVG, BMP, ICO, and friends) are now rejected at attach time — by picker, paste, or drag-and-drop — instead of failing later in the turn. Files whose type has to be inferred from the extension are classified the same way
|
||||
- Signing out of ChatGPT (Codex) now sticks. Sign-out removes the provider entry, but the runtime re-imports any missing provider from the classic extension's stored credentials on every command, so the next action signed you straight back in. The legacy Codex credentials are now cleared too, and a failed clear reports the sign-out as failed instead of quietly succeeding
|
||||
- Auth failures on providers that log in through a local CLI — Claude Code, Codex CLI, OpenCode — now point you at that CLI instead of Settings → Models, where there is nothing to fix. "OAuth session expired", "Not logged in · Please run /login", and similar messages are also recognized as credential failures now, so they get a hint at all
|
||||
- Model lists now refresh from the live catalog for every provider that uses the shared catalog, not just Cline and Cline Pass. Providers with their own endpoint-owned model lists are unaffected, and the first providers listing is still network-free
|
||||
- Cline Pass and free models now report zero cost instead of the upstream provider's price
|
||||
- Session history rows no longer overlap their own hover metadata when a label is wider than the fixed label column
|
||||
- Scheduled sessions no longer stall out. The poller could stop advancing, and capacity waits were counted as run attempts, so a schedule could burn through its retries without ever running. Execution lifecycle and capacity claims are now fenced atomically. New recurring schedules also default to your local timezone instead of UTC; existing schedules without a timezone keep it that way when edited
|
||||
- Automation event acceptance is now atomic and retryable, so an event can't be half-accepted and lost if delivery fails mid-way
|
||||
- Nested PowerShell commands no longer flood errors and look like a hang. Commands run through an outer PowerShell bootstrap, so a nested `powershell -Command "... $_ ..."` had `$_` interpolated away by the outer parser before the inner shell saw it — a `Where-Object { $_.Name ... }` pipeline then errored once per item over a large tree while still exiting 0. Redundant nested invocations are now unwrapped and run directly, only when that provably preserves semantics (same PowerShell edition, no profile loading, fully quoted command). Cline is also told which edition it's actually on — Windows PowerShell vs. Microsoft PowerShell — and to stop wrapping commands in a shell it's already running in
|
||||
- Prompt telemetry to Cline's tracing backend is now limited to Cline and Cline Pass requests. Turns run against your own provider keys are not traced
|
||||
- Cline Pass now appears in the composer's provider picker alongside Cline. One Cline sign-in configures both — Cline Pass stores its credentials under the Cline account rather than having its own — but the picker only listed providers with their own saved settings entry, and onboarding writes just the Cline one. A new Cline Pass user therefore saw a single row
|
||||
- The composer's provider picker has a **Set up another provider** row at the bottom that opens Settings → Models. The picker only lists what you have already configured, so there was no way to reach the rest of the catalog from the composer
|
||||
- Dropped the "Configured" checkmark from the composer's provider picker (added in 0.0.25). Every row in that picker has a saved settings entry, so for anyone who set their providers up in the app every row carried the same green check and it read as decoration. Settings is still where provider readiness is shown, and it distinguishes a real credential from an entry a legacy migration left behind
|
||||
|
||||
## 0.0.25
|
||||
|
||||
- ChatGPT Subscription (Codex) now lists only the models your plan can actually use. Two separate paths filled the picker from the shared OpenAI catalog, so GPT-4o, GPT-4.1, and `chatgpt-image-latest` showed up alongside the Codex models, and the runtime lost the Codex context caps. The model rules also match what the backend now accepts: `gpt-5.4` and `gpt-5.4-mini` were retired for ChatGPT accounts on 2026-08-31 and are gone, the default moves to `gpt-5.6-terra`, and every Codex model is capped at the real 400K/272K/128K backend budget instead of inheriting the API's 1.05M limits
|
||||
- Windows updates no longer fail with "Error opening file for writing". The compiled sidecar re-executes itself as the detached Cline Hub daemon, which outlives the app by design, and Tauri's NSIS installer only kills the main binary — so the daemon still held `code-sidecar.exe` and the install stopped until you killed the process by hand. The installer now stops it first, matched on the full path so updating one channel does not take down a side-by-side Cline Beta's sessions
|
||||
- Your prompt is no longer lost when a send fails before the turn starts — switching to Codex and having the OAuth refresh throw, for instance. The runtime never took the prompt, so post-send hydration wiped the optimistic bubble and you had to retype it. The text and attachments now come back to the composer, merged with anything you attached while the send was pending, and left alone if you have already started typing something else
|
||||
- Providers that authenticate through a local CLI — Claude Code, Codex CLI — can now start sessions without an API key. They showed as Configured in Settings via their local-auth capability, but session start still refused them with "Missing API key"
|
||||
- OpenCode is now treated as a local CLI provider rather than an OAuth one, so it shows the local CLI notice instead of a browser sign-in button that could not do anything. It authenticates from the credentials the opencode CLI itself stores
|
||||
- Session import from Claude Code, Codex, and opencode has its own page in Settings instead of a row buried in General
|
||||
- The composer's provider picker now marks which providers you have already configured
|
||||
- The model picker distinguishes models that share a name, and Cline Pass subscription models are listed separately from the free fallback tier
|
||||
- Published DMGs use the intended window layout and background again. Tauri skips the Finder AppleScript that applies them whenever `CI` is set, which GitHub Actions always sets, so every DMG since the artwork landed shipped with a stock Finder window even though the artwork was generated and validated
|
||||
- Cline's recommended, free, and subscribed model lists now ship with the app, so they are correct at first launch instead of waiting on a live catalog fetch
|
||||
- Refreshed the model catalog. Adds NaN (nan.builders) and changes the resolved default model for 36 providers — including Bedrock, Vertex, OpenRouter, Kilo, GitHub Copilot, Gemini, Cerebras, Fireworks, Requesty, and Vercel AI Gateway. Several move off Claude Fable 5.1 to GPT-6 Astra, Vertex goes to Gemini 3.8 Flash, and OpenRouter/Kilo to Inception Mercury 2.5. If you use one of those without pinning a model, expect a different default
|
||||
|
||||
## 0.0.24
|
||||
|
||||
- Fixed the live chat stream doubling text and dropping messages mid-turn. The sidecar has two Hub sockets that both receive a session's events — ClineCore's own client and the observer client — and a session that streams without a local send first (a run already in flight when you open the task, a resumed run, a scheduled run) had every delta rendered twice. The observer's copy is now skipped whenever ClineCore is subscribed to the session, asked directly rather than inferred from a timer, so long commands, slow first tokens, and unanswered tool approvals cannot let a duplicate slip through ahead of the core copy. Separately, when the sidecar was replaced under a live webview (crash-respawn, Hub drain-and-replace, stale-sidecar swap) its stream counter restarted at 1 and the webview silently discarded everything until the new process counted past the old run — this dropped your own message bubbles and tool rows, not just assistant text, which is why rows appeared to vanish mid-turn and come back afterwards
|
||||
- Fixed a queued prompt's own message vanishing from the chat. When you queue a prompt behind a running turn, the runtime drains the queue just before it answers the previous send, so the previous turn's completion path replaced the whole transcript from a canonical read that predated your queued message — erasing your bubble and leaving the reply streaming in under no user message. That path now defers to the newer turn instead of treating the transcript as its own. Two symptoms rode on the same bug: the composer no longer drops out of its busy state while the queued reply is still pending, and a finished reasoning row now reads "Thought for Ns" instead of a stuck "Thinking" — live rows are stamped on the webview's clock, so a sidecar whose clock trails it (a remote Hub, the browser-dev setup) no longer produces a negative duration that gets dropped
|
||||
- Cline no longer stops silently mid-task when a model gets stuck repeating itself. The loop detector stops a run after 5 identical tool calls and the mistake tracker after 6 consecutive failures, but the desktop never registered a decision callback, so the run just ended and the composer went idle with no message. You are now asked how to continue — "Try a different approach" or "Stop this run" — and the guidance is steered into the running turn so the model knows why it was paused instead of repeating the same call
|
||||
- The `editor` tool's error message now names the file, says whether `old_text` was null or omitted, and states how to recover. Models that fill optional parameters with null (seen with kimi-k3) hit a terse "old_text is required" and re-sent the identical call until the loop detector stopped the run
|
||||
- Fixed your Cline Pass model selection being replaced when you start a new chat. Catalogs are discovery data, not validation — the bundled catalog can omit live Cline Pass models and refreshes can return partial lists, so a model missing from the catalog was treated as invalid and silently swapped for a default
|
||||
- Cline Desktop now has a custom title bar on Windows, with caption controls that follow the compact title-bar height in narrow windows and stay above overlays. The Windows taskbar icon was also updated
|
||||
- Token counts and costs now fill in for every session you can see. The sessions view only ever hydrated the four most recent rows, so every other row showed "-" and paging never asked for more; the visible page is now hydrated on demand, with reads capped and re-run when a session's status changes underneath them
|
||||
- Sessions imported from Claude Code, Codex, and opencode now say so in the chat, and their foreign history is summarized on the first resumed turn. Imported transcripts keep the source tool's own tool names and schemas, which a model continuing them may try to call — the summary runs once, the original transcript stays intact, and the "Thinking..." indicator reads "Summarizing the imported <tool> history..." while it happens
|
||||
- Fixed session history rendering empty when one session had many subagent or team-task children. Child rows always sort after the root that spawned them, so a single busy session could hide itself and every older session from the sidebar with no way to load more
|
||||
- Checkpoints no longer re-hash every untracked file before each message. Checkpoint creation rebuilt a throwaway git index each turn, so multi-GB untracked data blocked every message for seconds to minutes (~90s in one report on a cloud-synced Windows workspace). One snapshot index is now kept per session, so from the second turn the cost is roughly git process overhead. Snapshot contents are byte-identical to before
|
||||
- Commands that background a child process (`cmd &`, `nohup`, and the same from Git Bash) no longer hang until the timeout. The inherited stdio pipes stay open after the shell exits, so the completion event never arrived even though the command was done; these now settle with the real exit code and a note that background output is no longer captured
|
||||
- Typing an `@` mention from your home directory no longer indexes your entire home folder. That could take memory into the gigabytes and get the process killed; the home directory and filesystem root are now skipped entirely
|
||||
- Web search is now enabled by default outside YOLO mode, and tool settings fail closed if they cannot be loaded
|
||||
- Claude Code no longer asks for an API key it never reads. It authenticates from the local `claude` CLI's own credential store, but was reported as an API-key provider, so a keyless entry was refused and the workaround was to save a dummy key
|
||||
- Pasted credentials with invisible characters no longer persist corrupted. A BOM or zero-width character carried in from a copy-paste produced 401s indistinguishable from a wrong key; credential fields are now stripped of control and format characters on save
|
||||
- Starting a new task no longer flickers through the idle state. The Hub publishes the new session's record as "idle" while the start request is still in flight, so the composer placeholder and the request indicator switched to idle and back for a frame on every new task. A transient idle arriving during a submission is now held back; a real failure or abort still applies immediately
|
||||
- The model picker keeps section headers visible while you search. Cline Pass lists the same model in both the Subscribed and Free tiers, so flattening the sections during search produced two identical-looking rows
|
||||
- `apply_patch` "Add File" now refuses to overwrite an existing file instead of silently replacing it
|
||||
- Fixed session import paths resolving incorrectly on Windows
|
||||
- The desktop backend now starts off the command path, so startup no longer blocks the UI
|
||||
- The SDK can now connect to authenticated remote Hubs
|
||||
|
||||
## 0.0.23
|
||||
|
||||
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
|
||||
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
|
||||
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
|
||||
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
|
||||
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
|
||||
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
|
||||
|
||||
## 0.0.22
|
||||
|
||||
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
|
||||
- Runs of a schedule now fold into a single collapsible sidebar row named after the schedule, with its run count, instead of one row per run all carrying the same prompt title. Expanding lists them newest-first as "Run N" with the usual status dot, time, hover card, context menu, and delete; the group holding the active session opens on its own
|
||||
- Voice input now works on macOS. The app shipped without a microphone usage description or entitlement, so dictation failed silently
|
||||
- Web search is now on by default
|
||||
- The marketplace detail panel now opens on click rather than hover, with left-aligned content, a single "Learn more" link, and the selected entry staying open while you filter the list
|
||||
- When the Hub is older than the app, you are now offered a choice — replace it, with a count of the sessions that would be interrupted, or keep it running — instead of the app quietly working against stale code. Replacing drains the Hub first so in-flight turns finish
|
||||
- Editing and resending a message now works on sessions with no checkpoint history, such as imported ones, instead of failing with "No checkpoint found at or before run N"
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty
|
||||
- The message the model receives when you reject a tool call now names the tool and reads as your decision rather than an error
|
||||
- Refreshed the model catalog. Adds eight providers (Bothub, OpenReason, SenseNova (China), TokenRouter, Vancine, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and changes the resolved default model for 36 providers — most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Kilo Gateway, DevPass, DigitalOcean, CrossModel, and Eden AI following. If you use a provider without pinning a model, expect a different default
|
||||
|
||||
## 0.0.21
|
||||
|
||||
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
|
||||
@@ -13,6 +95,7 @@
|
||||
|
||||
## 0.0.20
|
||||
|
||||
- Customize now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugin switches use Hub-managed enablement, contributed skills appear in the Skills inventory, and connected desktop views refresh when Hub settings change
|
||||
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
|
||||
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
|
||||
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
|
||||
|
||||
@@ -10,13 +10,59 @@ From `apps/examples/desktop-app/`:
|
||||
- `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:web` - build production web assets only (includes the shared UI build)
|
||||
- `bun run build` - build web assets and the sidecar binary
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
### Checking webview changes
|
||||
|
||||
Run `bun run build:web` from this directory when changing webview imports or shared browser APIs. Type checking and Vitest do not check the production browser bundle: a valid TypeScript import can still pull Node-only modules into a client chunk. Use `@cline/shared/browser` for runtime imports in the webview; the bare `@cline/shared` source alias points to the Node entry point.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
The composer shows the current branch's GitHub pull request, merge status,
|
||||
changed-line totals, and CI checks. Click the PR number to open it in your
|
||||
browser, or expand CI to inspect individual checks and their logs. Status
|
||||
refreshes every 30 seconds while visible, when the app regains focus, and
|
||||
when you click refresh.
|
||||
|
||||
This requires GitHub CLI (`gh`) installed and authenticated with `gh auth login`,
|
||||
and a GitHub.com `origin` remote (HTTPS or SSH). The row is hidden for the
|
||||
default branch, detached HEAD, and unsupported repositories. If the branch
|
||||
has no PR, **Create PR** opens GitHub's comparison form; push your commits
|
||||
before submitting the form. The app does not push commits or submit PRs itself.
|
||||
|
||||
Missing or unauthenticated GitHub CLI also hides the row. Availability checks
|
||||
are shared across workspaces and cached for five minutes, so unavailable CLI
|
||||
installs do not spawn a failing process on every poll or window focus. After
|
||||
installing or signing into `gh`, the feature becomes available on the first
|
||||
refresh after the cache expires (or after restarting the desktop backend).
|
||||
Initial lookup failures stay hidden. Errors after a successful status load
|
||||
can be dismissed and remain dismissed through retries until a load succeeds.
|
||||
|
||||
### Pull request telemetry
|
||||
|
||||
These events use the desktop telemetry service and respect telemetry opt-out:
|
||||
|
||||
| Event | Trigger |
|
||||
| --- | --- |
|
||||
| `desktop.pull_request.shown` | First visible PR/create row per mounted workspace and branch |
|
||||
| `desktop.pull_request.open_clicked` | Click the PR link |
|
||||
| `desktop.pull_request.create_clicked` | Click Create PR (intent only, not PR submission) |
|
||||
| `desktop.pull_request.checks_expanded` | Open the CI popover |
|
||||
| `desktop.pull_request.check_clicked` | Click a check's details link |
|
||||
| `desktop.pull_request.refresh_clicked` | Click manual refresh |
|
||||
|
||||
Each event contains only `prState`, `ciState`, and `mergeTone` categories.
|
||||
The sidecar validates these values and strips extra fields. Repository/branch
|
||||
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.
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.26",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -10,6 +10,8 @@
|
||||
"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:web": "bun run build:ui",
|
||||
"build:web": "next build webview",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
|
||||
@@ -16,6 +16,7 @@ sidecar/
|
||||
├── index.ts # Entry point: starts HTTP+WS server
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── client-context.ts # Desktop client/account identity for shared telemetry
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
@@ -81,6 +82,15 @@ The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
Every create, restart, fork, and restore also attaches the serializable Desktop
|
||||
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
|
||||
that context across the Hub transport and scopes the daemon-owned telemetry
|
||||
service to the originating surface. This keeps lifecycle events centralized in
|
||||
Core while reporting Desktop dimensions (`cline_type: "desktop"`, `platform:
|
||||
"Cline Desktop"`, and the Desktop app version) and the current account and
|
||||
organization. The shared Hub telemetry singleton is never mutated per session,
|
||||
so concurrent CLI and Desktop tasks retain their own attribution.
|
||||
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
@@ -134,6 +144,17 @@ The frontend `desktop-client.ts` connects directly to the sidecar WebSocket:
|
||||
|
||||
## Command Map
|
||||
|
||||
The model picker first uses `list_provider_catalog`, which reads the bundled and
|
||||
registered models without network access. It then calls `list_provider_models`
|
||||
for the active provider, both on mount and when the provider changes. All built-in
|
||||
providers backed by the shared catalog refresh from the live feed (including
|
||||
OpenCode); concurrent requests share one fetch and reuse its ten-minute cache.
|
||||
Endpoint-owned lists such as Baseten, Hicap, Poolside, LiteLLM, Ollama, and LM Studio use their existing
|
||||
discovery endpoints instead. Catalog and public endpoint requests time out after
|
||||
five seconds, and the initial picker remains usable while a refresh is pending.
|
||||
The sidecar omits bundled `knownModels` from the discovery config so they cannot
|
||||
override live metadata; explicitly registered model overrides retain precedence.
|
||||
|
||||
Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|
||||
@@ -13,6 +13,8 @@ import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
createDesktopMistakeLimitPrompt,
|
||||
createDesktopMistakeRecovery,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import { handleCoreSessionEvent } from "./context";
|
||||
import {
|
||||
handleCoreSessionEvent,
|
||||
requestSidecarAskQuestion,
|
||||
resolveSidecarAskQuestion,
|
||||
} from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("resolveDesktopSessionMode", () => {
|
||||
@@ -195,25 +201,49 @@ describe("hasProviderChanged", () => {
|
||||
|
||||
describe("pathless session starts", () => {
|
||||
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
});
|
||||
const start = vi.fn(
|
||||
async (input: {
|
||||
config: Record<string, unknown>;
|
||||
localRuntime?: {
|
||||
extensionContext?: {
|
||||
client?: Record<string, unknown>;
|
||||
user?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
expect(input.localRuntime?.extensionContext?.client).toMatchObject({
|
||||
name: "cline-desktop",
|
||||
platform: "Cline Desktop",
|
||||
});
|
||||
expect(input.localRuntime?.extensionContext?.user).toEqual({
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
telemetryUser: {
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
@@ -545,7 +575,7 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
@@ -588,8 +618,19 @@ describe("session forks", () => {
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingQuestions: new Map(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const pendingDecision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => sourceSessionId,
|
||||
)({
|
||||
iteration: 5,
|
||||
consecutiveMistakes: 6,
|
||||
maxConsecutiveMistakes: 6,
|
||||
reason: "tool_execution_failed",
|
||||
});
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
@@ -599,6 +640,8 @@ describe("session forks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pendingDecision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialMessages: sourceMessages }),
|
||||
@@ -1753,3 +1796,487 @@ Follow the desktop send workflow instructions.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mistake-limit prompt", () => {
|
||||
function createPromptContext() {
|
||||
const send = vi.fn();
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const ctx = {
|
||||
wsClients: new Set([{ send }]),
|
||||
streamIndices: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
liveSessions: new Map(),
|
||||
sessionManager: {
|
||||
send: steer,
|
||||
stop: vi.fn(async () => {}),
|
||||
abort: vi.fn(async () => {}),
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
const readQuestionRequest = () => {
|
||||
const raw = send.mock.calls
|
||||
.map(
|
||||
([encoded]) =>
|
||||
JSON.parse(String(encoded)) as {
|
||||
event: { name: string; payload: Record<string, unknown> };
|
||||
},
|
||||
)
|
||||
.find((message) => message.event.name === "ask_question_requested");
|
||||
return raw?.event.payload as
|
||||
| {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
return { ctx, steer, readQuestionRequest };
|
||||
}
|
||||
const limitContext = {
|
||||
iteration: 15,
|
||||
consecutiveMistakes: 6,
|
||||
maxConsecutiveMistakes: 6,
|
||||
reason: "tool_execution_failed" as const,
|
||||
details:
|
||||
"Detected 5 consecutive identical calls to `editor`; stopping to avoid a loop.",
|
||||
};
|
||||
|
||||
it("holds tool and model hooks until Continue has queued recovery guidance", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
let finishSteering!: () => void;
|
||||
steer.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<undefined>((resolve) => {
|
||||
finishSteering = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
expect(recovery.onConsecutiveMistakeLimitReached(limitContext)).toBe(
|
||||
decision,
|
||||
);
|
||||
let released = false;
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]).then((results) => {
|
||||
released = true;
|
||||
return results;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(released).toBe(false);
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(steer).toHaveBeenCalledTimes(1);
|
||||
expect(released).toBe(false);
|
||||
finishSteering();
|
||||
await expect(decision).resolves.toMatchObject({ action: "continue" });
|
||||
await expect(waiting).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(released).toBe(true);
|
||||
await expect(recovery.hooks.beforeModel()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves ordinary questions alone when cancelling a mistake prompt", async () => {
|
||||
const { ctx, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
const mistakeRequestId = readQuestionRequest()?.requestId;
|
||||
const normalQuestion = requestSidecarAskQuestion(
|
||||
ctx,
|
||||
"Which file?",
|
||||
["a", "b"],
|
||||
{ sessionId: "session-1", agentId: "desktop", iteration: 1 },
|
||||
);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
const remaining = [...ctx.pendingQuestions.values()][0];
|
||||
expect(remaining.item.requestId).not.toBe(mistakeRequestId);
|
||||
resolveSidecarAskQuestion(ctx, remaining.item.requestId, "a");
|
||||
await expect(normalQuestion).resolves.toBe("a");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"answer",
|
||||
"abort",
|
||||
] as const)("releases waiting hooks with Stop on %s", async (action) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]);
|
||||
if (action === "answer") {
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Stop this run",
|
||||
);
|
||||
} else {
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
}
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
for (const control of await waiting)
|
||||
expect(control).toMatchObject({ stop: true });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the active session's user instead of stopping silently", async () => {
|
||||
const { ctx, readQuestionRequest } = createPromptContext();
|
||||
// Session ids are only known after start() resolves; the prompt must
|
||||
// read the id at prompt time, not at construction time.
|
||||
let sessionId = "";
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => sessionId);
|
||||
sessionId = "session-late";
|
||||
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
expect(request).toMatchObject({
|
||||
sessionId: "session-late",
|
||||
options: ["Try a different approach", "Stop this run"],
|
||||
});
|
||||
expect(request?.question).toContain("repeated mistakes or tool calls");
|
||||
expect(request?.question).toContain("identical calls to `editor`");
|
||||
|
||||
expect(
|
||||
resolveSidecarAskQuestion(ctx, request?.requestId ?? "", "Stop this run"),
|
||||
).toBe(true);
|
||||
await expect(decision).resolves.toEqual({
|
||||
action: "stop",
|
||||
reason: "stopped after mistake_limit_reached prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers recovery guidance only through steering", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
request?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
|
||||
const result = await decision;
|
||||
expect(result).toEqual({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: "session-1",
|
||||
prompt: expect.stringContaining("Do not repeat the same call"),
|
||||
delivery: "steer",
|
||||
});
|
||||
expect(steer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining("identical calls to `editor`"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stop",
|
||||
" STOP THIS RUN ",
|
||||
"2",
|
||||
"no",
|
||||
])("treats the free-text answer %s as Stop, like the CLI", async (answer) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
answer,
|
||||
);
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rejected",
|
||||
"unavailable",
|
||||
])("stops waiting hooks when steering is %s", async (failure) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
if (failure === "rejected")
|
||||
steer.mockRejectedValueOnce(new Error("Disconnected"));
|
||||
else ctx.sessionManager = null;
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
await expect(decision).resolves.toMatchObject({
|
||||
action: "stop",
|
||||
reason: expect.stringContaining("Could not send recovery guidance"),
|
||||
});
|
||||
for (const result of await waiting)
|
||||
expect(result).toMatchObject({ stop: true });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("passes free-text answers through as user guidance", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"read the file first, then edit",
|
||||
);
|
||||
await expect(decision).resolves.toEqual({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: "session-1",
|
||||
prompt: expect.stringContaining(
|
||||
"User guidance: read the file first, then edit",
|
||||
),
|
||||
delivery: "steer",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses Continue for already-started iterations and asks again for new mistakes", async () => {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
ctx.liveSessions.set("session-1", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: 0,
|
||||
status: "running",
|
||||
});
|
||||
const startIteration = (iteration: number) =>
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: { type: "iteration_start", iteration },
|
||||
},
|
||||
});
|
||||
const answer = (value: string) => {
|
||||
const pending = [...ctx.pendingQuestions.values()][0];
|
||||
expect(pending).toBeDefined();
|
||||
resolveSidecarAskQuestion(ctx, pending.item.requestId, value);
|
||||
};
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
startIteration(15);
|
||||
const first = decide(limitContext);
|
||||
// The model can advance while the client decision is pending.
|
||||
startIteration(20);
|
||||
// Do not extend the covered iterations while waiting for the hub's
|
||||
// steering acknowledgement: a newer step may already have the guidance.
|
||||
steer.mockImplementationOnce(async () => {
|
||||
startIteration(21);
|
||||
return undefined;
|
||||
});
|
||||
answer("Try a different approach");
|
||||
await expect(first).resolves.toMatchObject({ action: "continue" });
|
||||
|
||||
// A batch can have many failures in one iteration, followed by more
|
||||
// failures queued before the user answered. None needs another prompt.
|
||||
for (const iteration of [
|
||||
...Array<number>(20).fill(15),
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
]) {
|
||||
await expect(decide({ ...limitContext, iteration })).resolves.toEqual({
|
||||
action: "continue",
|
||||
});
|
||||
}
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).toHaveBeenCalledTimes(1);
|
||||
|
||||
startIteration(21);
|
||||
const next = decide({ ...limitContext, iteration: 21 });
|
||||
answer("Try a different approach");
|
||||
await expect(next).resolves.toMatchObject({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A new user run must not inherit the previous run's decision, even
|
||||
// though its iteration numbers start over.
|
||||
startIteration(1);
|
||||
startIteration(5);
|
||||
const newRun = decide({ ...limitContext, iteration: 5 });
|
||||
answer("Stop this run");
|
||||
await expect(newRun).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to stopping when no session owns the question", async () => {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "");
|
||||
await expect(decide(limitContext)).resolves.toEqual({
|
||||
action: "stop",
|
||||
reason: `mistake_limit_reached: ${limitContext.details}`,
|
||||
});
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes an aborted run's question and rejects late answers", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
request?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stop",
|
||||
"abort",
|
||||
"reset",
|
||||
] as const)("cancels only the owning session's questions on %s", async (action) => {
|
||||
const { ctx } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const other = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-2",
|
||||
)(limitContext);
|
||||
const decision = decide(limitContext);
|
||||
await handleChatSessionCommand(ctx, { action, sessionId: "session-1" });
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(
|
||||
[...ctx.pendingQuestions.values()].map((p) => p.item.sessionId),
|
||||
).toEqual(["session-2"]);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-2",
|
||||
});
|
||||
await other;
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("times out an unanswered question and removes it from polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run",
|
||||
"session",
|
||||
])("cancels the question when the %s ends externally", async (kind) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
const requestId = readQuestionRequest()?.requestId ?? "";
|
||||
if (kind === "session")
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "ended",
|
||||
payload: { sessionId: "session-1", reason: "stopped", ts: Date.now() },
|
||||
});
|
||||
else
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "aborted",
|
||||
text: "",
|
||||
iterations: 5,
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(
|
||||
resolveSidecarAskQuestion(ctx, requestId, "Try a different approach"),
|
||||
).toBe(false);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is wired into freshly started sessions as a local runtime option", async () => {
|
||||
const start = vi.fn(
|
||||
async (input: {
|
||||
config: Record<string, unknown>;
|
||||
localRuntime?: Record<string, unknown>;
|
||||
}) => {
|
||||
expect(input.config).not.toHaveProperty(
|
||||
"onConsecutiveMistakeLimitReached",
|
||||
);
|
||||
expect(
|
||||
typeof input.localRuntime?.onConsecutiveMistakeLimitReached,
|
||||
).toBe("function");
|
||||
expect(input.config).not.toHaveProperty("hooks");
|
||||
expect(input.localRuntime?.hooks).toMatchObject({
|
||||
beforeModel: expect.any(Function),
|
||||
beforeTool: expect.any(Function),
|
||||
afterTool: expect.any(Function),
|
||||
});
|
||||
return {
|
||||
sessionId: "session-limit",
|
||||
manifest: { cwd: "/tmp/ws", workspace_root: "/tmp/ws" },
|
||||
manifestPath: "/tmp/session-limit.json",
|
||||
messagesPath: "/tmp/session-limit.messages.json",
|
||||
};
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/ws",
|
||||
},
|
||||
});
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,26 @@ import {
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
buildClineSystemPrompt,
|
||||
type ConsecutiveMistakeLimitContext,
|
||||
type ConsecutiveMistakeLimitDecision,
|
||||
formatUserCommandBlock,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { createDesktopExtensionContext } from "./client-context";
|
||||
import {
|
||||
cancelSidecarMistakeQuestions,
|
||||
emitChunk,
|
||||
nowMs,
|
||||
requestSidecarAskQuestion,
|
||||
sendEvent,
|
||||
} from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
@@ -401,7 +413,177 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
type MistakeLimitDecider = (
|
||||
context: ConsecutiveMistakeLimitContext,
|
||||
) => Promise<ConsecutiveMistakeLimitDecision>;
|
||||
|
||||
const MISTAKE_LIMIT_CONTINUE_OPTION = "Try a different approach";
|
||||
const MISTAKE_LIMIT_STOP_OPTION = "Stop this run";
|
||||
const MISTAKE_LIMIT_DETAIL_MAX_CHARS = 600;
|
||||
|
||||
/**
|
||||
* Desktop counterpart of the CLI's mistake-limit prompt
|
||||
* (apps/cli/src/runtime/interactive/mistakes.ts).
|
||||
*
|
||||
* When the core's loop detector or mistake tracker trips, it asks the client
|
||||
* how to proceed. Without a decision callback the default is "stop", which
|
||||
* reaches the webview as a plain aborted turn: indistinguishable from the
|
||||
* user pressing Stop, with no explanation. A model stuck re-issuing the same
|
||||
* failing tool call therefore looked like Cline randomly gave up mid-task.
|
||||
* Route the decision through the existing ask-question channel instead so
|
||||
* the user sees what went wrong and can choose.
|
||||
*
|
||||
* `getSessionId` is read at prompt time: for fresh starts the session id is
|
||||
* only known after `manager.start()` resolves, and the webview matches the
|
||||
* prompt to its active session by id.
|
||||
*/
|
||||
export function createDesktopMistakeLimitPrompt(
|
||||
ctx: SidecarContext,
|
||||
getSessionId: () => string,
|
||||
): MistakeLimitDecider {
|
||||
return async (context) => {
|
||||
const sessionId = getSessionId().trim();
|
||||
const recovery = ctx.liveSessions.get(sessionId)?.mistakeRecovery;
|
||||
if (
|
||||
recovery?.continuedThroughIteration !== undefined &&
|
||||
context.iteration <= recovery.continuedThroughIteration
|
||||
) {
|
||||
// The tracker serializes decisions, so old failures can arrive after
|
||||
// Continue. The user has already answered for these in-flight steps.
|
||||
return { action: "continue" };
|
||||
}
|
||||
const detail = context.details?.trim() ?? "";
|
||||
const truncatedDetail =
|
||||
detail.length > MISTAKE_LIMIT_DETAIL_MAX_CHARS
|
||||
? `${detail.slice(0, MISTAKE_LIMIT_DETAIL_MAX_CHARS)}…`
|
||||
: detail;
|
||||
const question = [
|
||||
"Cline detected repeated mistakes or tool calls and needs your guidance.",
|
||||
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
|
||||
"How should Cline continue?",
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n");
|
||||
|
||||
let answer: string;
|
||||
try {
|
||||
answer = await requestSidecarAskQuestion(
|
||||
ctx,
|
||||
question,
|
||||
[MISTAKE_LIMIT_CONTINUE_OPTION, MISTAKE_LIMIT_STOP_OPTION],
|
||||
{
|
||||
sessionId,
|
||||
agentId: "desktop-mistake-limit",
|
||||
iteration: context.iteration,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Prompt timed out or the session was torn down: fall back to the
|
||||
// core's default decision, but keep the reason so the stop is
|
||||
// attributable.
|
||||
ctx.logger?.log("Mistake-limit prompt unanswered; stopping run", {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `mistake_limit_reached: ${detail || context.reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
if (["2", "stop this run", "stop", "n", "no"].includes(normalized)) {
|
||||
return {
|
||||
action: "stop",
|
||||
reason: "stopped after mistake_limit_reached prompt",
|
||||
};
|
||||
}
|
||||
const customGuidance =
|
||||
normalized.length > 0 &&
|
||||
normalized !== "1" &&
|
||||
normalized !== MISTAKE_LIMIT_CONTINUE_OPTION.toLowerCase()
|
||||
? answer.trim()
|
||||
: "";
|
||||
const guidance = [
|
||||
"The run reached the limit for repeated mistakes or tool calls.",
|
||||
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
|
||||
"Do not repeat the same call. Re-check the tool's parameter requirements, fix the call, and try a different approach.",
|
||||
customGuidance ? `User guidance: ${customGuidance}` : "",
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join(" ");
|
||||
// Use the existing steering queue so the running model receives the
|
||||
// guidance, including any instructions entered in the desktop prompt.
|
||||
const manager = ctx.sessionManager;
|
||||
try {
|
||||
if (!manager) throw new Error("Desktop session manager is unavailable");
|
||||
const continuedThroughIteration = Math.max(
|
||||
context.iteration,
|
||||
recovery?.latestIteration ?? context.iteration,
|
||||
);
|
||||
await manager.send({ sessionId, prompt: guidance, delivery: "steer" });
|
||||
if (recovery) {
|
||||
recovery.continuedThroughIteration = continuedThroughIteration;
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
ctx.logger?.log("Failed to steer mistake-limit guidance", {
|
||||
sessionId,
|
||||
error: detail,
|
||||
});
|
||||
// Releasing the hooks without the guidance would resume the same
|
||||
// failing loop. Only Continue after the steering request succeeds.
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `Could not send recovery guidance: ${detail}`,
|
||||
};
|
||||
}
|
||||
// Steering already delivers the guidance; do not also append it via
|
||||
// the mistake tracker's recovery-notice path.
|
||||
return { action: "continue" };
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopMistakeRecovery(
|
||||
ctx: SidecarContext,
|
||||
getSessionId: () => string,
|
||||
) {
|
||||
const prompt = createDesktopMistakeLimitPrompt(ctx, getSessionId);
|
||||
let pendingDecision: Promise<ConsecutiveMistakeLimitDecision> | undefined;
|
||||
const waitForDecision = async () => {
|
||||
const decision = await pendingDecision;
|
||||
return decision?.action === "stop"
|
||||
? { stop: true, reason: decision.reason }
|
||||
: undefined;
|
||||
};
|
||||
return {
|
||||
onConsecutiveMistakeLimitReached: (
|
||||
context: ConsecutiveMistakeLimitContext,
|
||||
) => {
|
||||
if (!pendingDecision) {
|
||||
pendingDecision = prompt(context).finally(() => {
|
||||
pendingDecision = undefined;
|
||||
});
|
||||
}
|
||||
return pendingDecision;
|
||||
},
|
||||
hooks: {
|
||||
// The decision callback alone does not pause the SDK. These existing
|
||||
// awaited hooks hold desktop runs at tool/model boundaries until the
|
||||
// user answers. afterTool holds before the next iteration consumes
|
||||
// the recovery guidance queued by the prompt's Continue action.
|
||||
beforeModel: waitForDecision,
|
||||
beforeTool: waitForDecision,
|
||||
afterTool: waitForDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(
|
||||
config: JsonRecord,
|
||||
telemetryUser?: SidecarContext["telemetryUser"],
|
||||
mistakeRecovery?: ReturnType<typeof createDesktopMistakeRecovery>,
|
||||
): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
@@ -444,6 +626,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
checkpoint: { enabled: true },
|
||||
sessions: config.sessions,
|
||||
initialMessages: config.initialMessages,
|
||||
extensionContext: createDesktopExtensionContext(telemetryUser),
|
||||
...mistakeRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -674,8 +858,14 @@ async function handleStart(
|
||||
: requestedSessionId
|
||||
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
|
||||
: undefined;
|
||||
// Resolved once start() returns; the mistake-limit prompt reads it lazily.
|
||||
let startedSessionId = requestedSessionId;
|
||||
const coreConfig: JsonRecord = {
|
||||
...buildCoreSessionConfig(request.config),
|
||||
...buildCoreSessionConfig(
|
||||
request.config,
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => startedSessionId),
|
||||
),
|
||||
systemPrompt,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
};
|
||||
@@ -697,6 +887,7 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
startedSessionId = sessionId;
|
||||
const workspaceRoot = startResult.manifest.workspace_root;
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
@@ -794,6 +985,7 @@ async function handleAttach(
|
||||
|
||||
async function startRebuiltSession(
|
||||
manager: ClineCore,
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
@@ -805,11 +997,15 @@ async function startRebuiltSession(
|
||||
: undefined;
|
||||
const restarted = await manager.start({
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => sessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -854,11 +1050,13 @@ async function rebuildSessionForProviderChange(
|
||||
resolveSystemPrompt(nextConfig),
|
||||
]);
|
||||
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed");
|
||||
await manager.stop(sessionId);
|
||||
let replacementStarted = false;
|
||||
try {
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
ctx,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
nextSystemPrompt,
|
||||
@@ -880,6 +1078,7 @@ async function rebuildSessionForProviderChange(
|
||||
}
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
ctx,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
previousSystemPrompt,
|
||||
@@ -996,9 +1195,9 @@ async function handleSend(
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session?.attachedViaHub) {
|
||||
// Once ClineCore sends a turn, its HubRuntimeHost owns the session
|
||||
// subscription. Stop projecting the observer stream as well or every
|
||||
// assistant/tool update (including command chunks) is emitted twice.
|
||||
// Once ClineCore sends a turn it owns the session: the attach-time
|
||||
// connection refresh above has happened and the observer projection is
|
||||
// muted by its subscription, so the session is no longer attach-only.
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
if (delivery === "queue") {
|
||||
@@ -1133,6 +1332,7 @@ async function handleStop(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped");
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -1148,6 +1348,7 @@ async function handleAbort(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted");
|
||||
await getSessionManager(ctx).abort(sessionId, "user_abort");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -1289,12 +1490,18 @@ async function handleForkUnlocked(
|
||||
},
|
||||
};
|
||||
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
||||
// Assigned below once the forked session exists; read lazily by the prompt.
|
||||
let newSessionId = "";
|
||||
const startInput = {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => newSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1311,7 +1518,6 @@ async function handleForkUnlocked(
|
||||
readSessionCheckpointHistory({ metadata: sourceMetadata }),
|
||||
forkBeforeRunCount,
|
||||
) !== undefined;
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
@@ -1354,6 +1560,11 @@ async function handleForkUnlocked(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
cancelSidecarMistakeQuestions(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
"Session replaced by fork",
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -1378,6 +1589,7 @@ async function handleReset(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (
|
||||
session?.busy ||
|
||||
@@ -1416,6 +1628,8 @@ async function handleRestoreCheckpoint(
|
||||
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
return withWorkspaceRestoreLock(ctx, cwd, async () => {
|
||||
// Updated once restore() returns; read lazily by the mistake-limit prompt.
|
||||
let restoredSessionId = sourceSessionId;
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: runCount,
|
||||
@@ -1423,10 +1637,14 @@ async function handleRestoreCheckpoint(
|
||||
restore: { messages: true, workspace: true },
|
||||
start: {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1438,10 +1656,16 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
restoredSessionId = sessionId;
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
cancelSidecarMistakeQuestions(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
"Session checkpoint restored",
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as os from "node:os";
|
||||
import { resolveCoreDistinctId } from "@cline/core";
|
||||
import type {
|
||||
ClientContext,
|
||||
ExtensionContext,
|
||||
TelemetryMetadata,
|
||||
UserContext,
|
||||
} from "@cline/shared";
|
||||
import { version } from "../package.json";
|
||||
|
||||
/** Shared identity for request headers, Hub attribution, and telemetry. */
|
||||
export const DESKTOP_CLIENT_CONTEXT = {
|
||||
name: "cline-desktop",
|
||||
version,
|
||||
platform: "Cline Desktop",
|
||||
platformVersion: version,
|
||||
isMultiRoot: false,
|
||||
} as const satisfies ClientContext;
|
||||
|
||||
export const DESKTOP_TELEMETRY_METADATA = {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: DESKTOP_CLIENT_CONTEXT.platform,
|
||||
platform_version: DESKTOP_CLIENT_CONTEXT.platformVersion,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
} satisfies TelemetryMetadata;
|
||||
|
||||
export function resolveDesktopTelemetryUser(input?: {
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
organizationId?: string;
|
||||
}): UserContext {
|
||||
const accountId = input?.accountId?.trim();
|
||||
return accountId
|
||||
? {
|
||||
distinctId: accountId,
|
||||
accountId,
|
||||
email: input?.email,
|
||||
organizationId: input?.organizationId,
|
||||
}
|
||||
: {
|
||||
distinctId: resolveCoreDistinctId(),
|
||||
accountId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializable context attached to every Desktop session sent to the Hub. */
|
||||
export function createDesktopExtensionContext(
|
||||
user?: UserContext,
|
||||
): ExtensionContext {
|
||||
return {
|
||||
client: DESKTOP_CLIENT_CONTEXT,
|
||||
...(user ? { user: { ...user } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
saveProviderSettings = persistProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
@@ -31,11 +33,13 @@ vi.mock("@cline/core", async () => {
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const setDistinctId = vi.fn();
|
||||
const updateCommonProperties = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
telemetry: { capture, setDistinctId, updateCommonProperties },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
return { ctx, capture, setDistinctId, updateCommonProperties };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
@@ -53,12 +57,14 @@ beforeEach(() => {
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
persistProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
|
||||
const { ctx, capture, setDistinctId, updateCommonProperties } =
|
||||
createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -72,6 +78,14 @@ describe("cline_account command auth states", () => {
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(setDistinctId).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the account action unchanged when a fresh token resolves", async () => {
|
||||
@@ -171,7 +185,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
@@ -182,6 +196,62 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
expect(setDistinctId).toHaveBeenCalledWith("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: "acct-1", account_id: "acct-1" }),
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies and persists the active organization for task telemetry", async () => {
|
||||
const { ctx, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: { accountId: "acct-1", accessToken: "token" },
|
||||
});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: "acct-1",
|
||||
organization_id: "org-1",
|
||||
}),
|
||||
);
|
||||
expect(persistProviderSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
memberId: "member-1",
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
@@ -219,7 +289,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -233,10 +303,28 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(ctx.telemetryUser?.distinctId).not.toBe("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
account_email: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -259,6 +347,22 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
captureAuthRefreshSoftFailure,
|
||||
clearAccountTelemetryIdentity,
|
||||
createConfiguredStreamingTranscriptionSession,
|
||||
createUserInstructionConfigService,
|
||||
ensureCustomProvidersLoaded,
|
||||
@@ -23,14 +25,17 @@ import {
|
||||
fetchClineRecommendedModels,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
identifyAccount,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
parseMcpServerRegistration,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
probeMcpServerConnection,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
resolveLocalClineAuthToken,
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
@@ -66,6 +71,7 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import { resolveDesktopTelemetryUser } from "./client-context";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
@@ -86,6 +92,10 @@ import {
|
||||
identifyDesktopFeatureFlagsAccount,
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
import {
|
||||
clearLegacyCodexCredentials,
|
||||
OPENAI_CODEX_PROVIDER_ID,
|
||||
} from "./legacy-codex-credentials";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -115,6 +125,8 @@ import {
|
||||
sessionLogPath,
|
||||
sharedSessionDataDir,
|
||||
} from "./paths";
|
||||
import { getPullRequestStatus } from "./pull-request";
|
||||
import { capturePullRequestEvent } from "./pull-request-telemetry";
|
||||
import { listSessionAgents } from "./session-data/agents";
|
||||
import { readSessionHooks } from "./session-data/artifacts";
|
||||
import { normalizeSessionTitle } from "./session-data/common";
|
||||
@@ -310,14 +322,23 @@ function removePathIfExists(
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function syncFeatureFlagsAccountFromResult(
|
||||
function syncAccountContextFromResult(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
): void {
|
||||
if (operation === "fetchMe") {
|
||||
const user = result as { id?: string; email?: string } | undefined;
|
||||
const user = result as ClineAccountUser | undefined;
|
||||
if (user?.id) {
|
||||
const identity = resolveClineAccountTelemetryIdentity(user);
|
||||
ctx.telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId: identity.id,
|
||||
email: identity.email,
|
||||
organizationId: identity.organizationId,
|
||||
});
|
||||
identifyAccount(ctx.telemetry, identity);
|
||||
persistClineAccountTelemetryIdentity(manager, identity);
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: user.id, email: user.email },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
@@ -327,12 +348,39 @@ function syncFeatureFlagsAccountFromResult(
|
||||
}
|
||||
}
|
||||
|
||||
function syncFeatureFlagsAccountFromSettings(
|
||||
function syncAccountContextFromSettings(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): void {
|
||||
const auth = manager.getProviderSettings("cline")?.auth;
|
||||
const accountId = auth?.accountId?.trim();
|
||||
if (!auth || !accountId) {
|
||||
syncSignedOutAccountContext(ctx);
|
||||
return;
|
||||
}
|
||||
ctx.telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId,
|
||||
organizationId: auth.organizationId,
|
||||
});
|
||||
identifyAccount(ctx.telemetry, {
|
||||
id: accountId,
|
||||
provider: "cline",
|
||||
organizationId: auth.organizationId,
|
||||
organizationName: auth.organizationName,
|
||||
memberId: auth.memberId,
|
||||
});
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
|
||||
{ id: accountId },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
|
||||
function syncSignedOutAccountContext(ctx: SidecarContext): void {
|
||||
const telemetryUser = resolveDesktopTelemetryUser();
|
||||
ctx.telemetryUser = telemetryUser;
|
||||
clearAccountTelemetryIdentity(ctx.telemetry, telemetryUser.distinctId);
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
@@ -648,9 +696,13 @@ function toPositiveInt(value: unknown): number | undefined {
|
||||
return rounded > 0 ? rounded : undefined;
|
||||
}
|
||||
|
||||
function routineScheduleTiming(
|
||||
args?: Record<string, unknown>,
|
||||
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
|
||||
function routineScheduleTiming(args?: Record<string, unknown>):
|
||||
| {
|
||||
cronPattern: string;
|
||||
timezone?: string;
|
||||
metadata?: Record<string, number>;
|
||||
}
|
||||
| undefined {
|
||||
if (args?.schedule_type === "once") {
|
||||
const runAt =
|
||||
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
|
||||
@@ -662,7 +714,9 @@ function routineScheduleTiming(
|
||||
: undefined;
|
||||
}
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
return cronPattern ? { cronPattern } : undefined;
|
||||
return cronPattern
|
||||
? { cronPattern, timezone: asTrimmedString(args?.timezone) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
@@ -920,7 +974,7 @@ async function listHubSettings(
|
||||
async function toggleHubSetting(
|
||||
ctx: SidecarContext,
|
||||
input: {
|
||||
type: "plugins" | "tools";
|
||||
type: "plugins" | "tools" | "skills";
|
||||
path?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
@@ -959,13 +1013,18 @@ async function listUserInstructionConfigs(
|
||||
const items: unknown[] = [];
|
||||
for (const record of userInstructionService.listRecords(type)) {
|
||||
const item = record.item as unknown as JsonRecord;
|
||||
if (item.disabled === true) continue;
|
||||
const disabled = item.disabled === true;
|
||||
// Rules and workflows have no toggle UI, so keep hiding disabled
|
||||
// ones; skills need to stay visible (disabled) so they can be
|
||||
// re-enabled from the Skills tab.
|
||||
if (disabled && type !== "skill") continue;
|
||||
items.push({
|
||||
id: record.id,
|
||||
name: item.name ?? record.id,
|
||||
description: item.description,
|
||||
instructions: item.instructions,
|
||||
path: record.filePath,
|
||||
...(type === "skill" ? { enabled: !disabled } : {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -1036,6 +1095,34 @@ async function listUserInstructionConfigs(
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
}
|
||||
const knownSkillPaths = new Set(
|
||||
skills.flatMap((skill) => {
|
||||
if (!skill || typeof skill !== "object") return [];
|
||||
const path = (skill as JsonRecord).path;
|
||||
return typeof path === "string" ? [path] : [];
|
||||
}),
|
||||
);
|
||||
for (const skill of hubSettings.skills) {
|
||||
if (
|
||||
skill.agentPlugin !== true ||
|
||||
skill.enabled === false ||
|
||||
knownSkillPaths.has(skill.path)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
skills.push({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: "",
|
||||
path: skill.path,
|
||||
enabled: true,
|
||||
source: skill.source,
|
||||
agentPlugin: true,
|
||||
pluginName: skill.pluginName,
|
||||
});
|
||||
knownSkillPaths.add(skill.path);
|
||||
}
|
||||
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the hub's
|
||||
@@ -1055,9 +1142,15 @@ async function listUserInstructionConfigs(
|
||||
runtimeCommands,
|
||||
agents: loadAgents(),
|
||||
plugins: hubSettings.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
source: plugin.source,
|
||||
toggleable: plugin.toggleable === true,
|
||||
agentPlugin: plugin.agentPlugin === true,
|
||||
description: plugin.description,
|
||||
loadError: plugin.loadError,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
@@ -1797,10 +1890,7 @@ export async function handleCommand(
|
||||
// an expired or server-revoked token. Explicit sign-out is handled
|
||||
// at its source in `save_provider_settings`; this catches the rest
|
||||
// so a stale account never keeps serving its rollout cohort.
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
syncSignedOutAccountContext(ctx);
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
@@ -1813,7 +1903,7 @@ export async function handleCommand(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
syncFeatureFlagsAccountFromResult(ctx, operation, result);
|
||||
syncAccountContextFromResult(ctx, manager, operation, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1856,9 +1946,13 @@ export async function handleCommand(
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
// Known models are merged in unfiltered after the provider's own model
|
||||
// rules run, so including them here would leak e.g. the full OpenAI
|
||||
// catalog into the ChatGPT Subscription (codex) picker.
|
||||
return await getLocalProviderModels(
|
||||
String(args?.provider ?? ""),
|
||||
manager.getProviderConfig(String(args?.provider ?? "").trim()),
|
||||
provider,
|
||||
manager.getProviderConfig(provider, { includeKnownModels: false }),
|
||||
);
|
||||
}
|
||||
if (command === "list_cline_recommended_models") {
|
||||
@@ -2016,7 +2110,14 @@ export async function handleCommand(
|
||||
// authoritative signal — it fires the moment credentials are cleared
|
||||
// rather than waiting for the next account fetch.
|
||||
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
|
||||
syncFeatureFlagsAccountFromSettings(ctx, manager);
|
||||
syncAccountContextFromSettings(ctx, manager);
|
||||
}
|
||||
// Signing out of ChatGPT removes its providers.json entry; the legacy
|
||||
// import would restore it from the extension's secrets.json on the next
|
||||
// command unless those credentials go too. A failed write throws so
|
||||
// the webview reports the sign-out as failed and resyncs.
|
||||
if (saved.providerId === OPENAI_CODEX_PROVIDER_ID && !saved.enabled) {
|
||||
clearLegacyCodexCredentials();
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -2084,7 +2185,16 @@ export async function handleCommand(
|
||||
);
|
||||
});
|
||||
},
|
||||
{ owner: options?.connection },
|
||||
{
|
||||
owner: options?.connection,
|
||||
// Push the device sign-in confirmation code so the webview can
|
||||
// show it while the user confirms it in the browser.
|
||||
onUserCode: (userCode) =>
|
||||
broadcastEvent(ctx, "provider_oauth_user_code", {
|
||||
provider: providerId,
|
||||
userCode,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (command === "cancel_provider_oauth_login") {
|
||||
@@ -2345,6 +2455,17 @@ export async function handleCommand(
|
||||
}
|
||||
|
||||
// ── Git operations ─────────────────────────────────────────────────
|
||||
if (command === "capture_pull_request_event") {
|
||||
capturePullRequestEvent(ctx.telemetry, args);
|
||||
return null;
|
||||
}
|
||||
if (command === "get_pull_request_status") {
|
||||
return await getPullRequestStatus(
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
? args.cwd.trim()
|
||||
: ctx.workspaceRoot,
|
||||
);
|
||||
}
|
||||
if (command === "get_git_branch") {
|
||||
const cwd =
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
@@ -2464,6 +2585,18 @@ export async function handleCommand(
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
if (command === "set_skill_disabled") {
|
||||
const skillPath = String(args?.path ?? "").trim();
|
||||
if (!skillPath) {
|
||||
throw new Error("skill path is required");
|
||||
}
|
||||
const snapshot = await toggleHubSetting(ctx, {
|
||||
type: "skills",
|
||||
path: skillPath,
|
||||
enabled: args?.disabled !== true,
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
|
||||
@@ -531,6 +531,65 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not announce a queued prompt start when the head is deleted from the queue", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
let onEvent: ((event: unknown) => void) | undefined;
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn((handler: (event: unknown) => void) => {
|
||||
onEvent = handler;
|
||||
return () => {};
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
await initializeSessionManager(ctx);
|
||||
ctx.liveSessions.set("session-1", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [
|
||||
{ id: "prompt-1", prompt: "first", steer: false, attachmentCount: 0 },
|
||||
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
// Removing the head only produces a shrunken snapshot — no
|
||||
// pending_prompt_submitted — so nothing must reach the transcript.
|
||||
onEvent?.({
|
||||
type: "pending_prompts",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
prompts: [{ id: "prompt-2", prompt: "second", delivery: "queue" }],
|
||||
},
|
||||
});
|
||||
|
||||
const events = readEvents(ctx);
|
||||
expect(
|
||||
events.filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
events.find((message) => message.event.name === "prompts_in_queue_state")
|
||||
?.event.payload,
|
||||
).toEqual({
|
||||
sessionId: "session-1",
|
||||
items: [
|
||||
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("relays generated media for attach-only Hub sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
@@ -1193,6 +1252,33 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards Hub settings changes so open desktop views can refresh", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() } as never);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "settings.changed",
|
||||
payload: {
|
||||
types: ["plugins", "skills", "mcp"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(readEvents(ctx)).toEqual([
|
||||
{
|
||||
type: "event",
|
||||
event: {
|
||||
name: "settings.changed",
|
||||
payload: {
|
||||
types: ["plugins", "skills", "mcp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
@@ -1244,3 +1330,215 @@ describe("disposeSidecarContext attachment cleanup", () => {
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat chunk pipe selection", () => {
|
||||
async function createStreamingContext(
|
||||
sessionId: string,
|
||||
coreSubscriptions: Set<string> = new Set(),
|
||||
) {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set(sessionId, {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
ctx.sessionManager = {
|
||||
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
|
||||
} as never;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function coreTextEvent(sessionId: string, text: string) {
|
||||
return {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId,
|
||||
event: { type: "content_start", contentType: "text", text },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function eventsFor(ctx: SidecarContext, name: string) {
|
||||
return readEvents(ctx)
|
||||
.filter((message) => message.event.name === name)
|
||||
.map((message) => message.event.payload);
|
||||
}
|
||||
|
||||
function chunksFor(ctx: SidecarContext, stream: string): string[] {
|
||||
return eventsFor(ctx, "chat_event")
|
||||
.filter((payload) => (payload as { stream?: string }).stream === stream)
|
||||
.map((payload) => String((payload as { chunk?: string }).chunk));
|
||||
}
|
||||
|
||||
it("emits one copy when both pipes carry the same delta", async () => {
|
||||
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
// Opening a session arms both pipes: ClineCore subscribes to the session
|
||||
// and `attach` enables the observer projection, so the hub publishes each
|
||||
// delta to both sockets.
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "Pack " },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "Pack "));
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "my box" },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "my box"));
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["Pack ", "my box"]);
|
||||
});
|
||||
|
||||
it("still streams sessions only the observer delivers", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext("session-1");
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "remote " },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "run" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["remote ", "run"]);
|
||||
});
|
||||
|
||||
it("mutes the whole observer projection, not just text", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "tool.started",
|
||||
sessionId: "session-1",
|
||||
payload: { toolCallId: "call-1", toolName: "run_commands" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "run.completed",
|
||||
sessionId: "session-1",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
|
||||
expect(eventsFor(ctx, "chat_session_ended")).toEqual([]);
|
||||
expect(ctx.liveSessions.get("session-1")?.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the subscription as it comes and goes", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const coreSubscriptions = new Set<string>();
|
||||
const ctx = await createStreamingContext("session-1", coreSubscriptions);
|
||||
const delta = (text: string) =>
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
delta("observer first");
|
||||
// A send (or pending-prompt list) subscribes ClineCore.
|
||||
coreSubscriptions.add("session-1");
|
||||
delta("muted");
|
||||
// `stop` drops the subscription; a run another client starts on the
|
||||
// same session is the observer's to render again.
|
||||
coreSubscriptions.delete("session-1");
|
||||
delta("observer again");
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual([
|
||||
"observer first",
|
||||
"observer again",
|
||||
]);
|
||||
});
|
||||
|
||||
it("decides per session", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
ctx.liveSessions.set("session-2", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "one" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-2",
|
||||
payload: { text: "two" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("never drops chunks the sidecar produces itself", async () => {
|
||||
const { broadcastChunk } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
broadcastChunk(ctx, "session-1", "chat_queued_prompt_start", "{}");
|
||||
|
||||
expect(chunksFor(ctx, "chat_queued_prompt_start")).toEqual(["{}"]);
|
||||
});
|
||||
|
||||
it("stamps chunks with a stable per-process boot id", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const first = await createStreamingContext("session-1");
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "a" },
|
||||
});
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "b" },
|
||||
});
|
||||
|
||||
const boots = readEvents(first)
|
||||
.filter((message) => message.event.name === "chat_event")
|
||||
.map((message) => (message.event.payload as { boot?: string }).boot);
|
||||
expect(boots).toHaveLength(2);
|
||||
expect(boots[0]).toBeTruthy();
|
||||
expect(boots[1]).toBe(boots[0]);
|
||||
|
||||
// A replacement sidecar restarts `index` at 1, so it must be
|
||||
// distinguishable by boot id.
|
||||
const second = createSidecarContext("/workspace/project");
|
||||
expect(second.bootId).not.toBe(first.bootId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,6 +187,7 @@ function emitChunk(
|
||||
chunk,
|
||||
ts,
|
||||
index: nextIndex,
|
||||
boot: ctx.bootId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,6 +345,7 @@ function handleAgentEvent(
|
||||
message: event.message,
|
||||
noticeType: event.noticeType,
|
||||
reason: event.reason,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -367,6 +369,7 @@ function handleAgentEvent(
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -401,7 +404,19 @@ function handleAgentEvent(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "iteration_start":
|
||||
case "iteration_start": {
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
// Iterations restart at one for each user run. Keep the previous
|
||||
// answer only within the run in which it was supplied.
|
||||
if (event.iteration === 1 || !session.mistakeRecovery) {
|
||||
session.mistakeRecovery = { latestIteration: event.iteration };
|
||||
} else {
|
||||
session.mistakeRecovery.latestIteration = event.iteration;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "iteration_end":
|
||||
break;
|
||||
}
|
||||
@@ -411,10 +426,8 @@ function handleAgentEvent(
|
||||
// CoreSessionEvent routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
|
||||
// and a pending_prompt_submitted event for the same prompt back-to-back, and
|
||||
// both are translated here into chat_queued_prompt_start — dedupe by prompt
|
||||
// id or the UI renders the user message twice.
|
||||
// Dedupe by prompt id so a repeated pending_prompt_submitted for the same
|
||||
// prompt cannot render the user message twice.
|
||||
function emitQueuedPromptStart(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
@@ -475,20 +488,10 @@ export function handleCoreSessionEvent(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
const previous = session.promptsInQueue;
|
||||
// A shrinking snapshot is not evidence that the head started
|
||||
// running: the user may have deleted it or the queue may have been
|
||||
// discarded. Only pending_prompt_submitted announces a start.
|
||||
session.promptsInQueue = mapped;
|
||||
if (
|
||||
previous.length > mapped.length &&
|
||||
previous[0] &&
|
||||
previous[0].id !== mapped[0]?.id
|
||||
) {
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
});
|
||||
}
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
break;
|
||||
@@ -520,6 +523,7 @@ export function handleCoreSessionEvent(
|
||||
}
|
||||
case "ended": {
|
||||
const { sessionId, reason } = event.payload;
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -570,12 +574,14 @@ export function createSidecarContext(
|
||||
observability: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
telemetryUser?: SidecarContext["telemetryUser"];
|
||||
} = {},
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
bootId: randomUUID(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
@@ -584,6 +590,7 @@ export function createSidecarContext(
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
telemetryUser: observability.telemetryUser,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
@@ -724,6 +731,28 @@ export function resolveSidecarAskQuestion(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove prompts before their session is stopped or replaced in the UI. */
|
||||
export function cancelSidecarMistakeQuestions(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
): void {
|
||||
for (const pending of ctx.pendingQuestions?.values() ?? []) {
|
||||
if (
|
||||
pending.item.sessionId !== sessionId ||
|
||||
pending.item.context?.agentId !== "desktop-mistake-limit"
|
||||
)
|
||||
continue;
|
||||
ctx.pendingQuestions.delete(pending.item.requestId);
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error(reason));
|
||||
sendEvent(ctx, "ask_question_cancelled", {
|
||||
requestId: pending.item.requestId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSidecarRuntimeCapabilities(
|
||||
ctx: SidecarContext,
|
||||
): RuntimeCapabilities {
|
||||
@@ -811,6 +840,10 @@ export function handleHubLiveEvent(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.event === "settings.changed") {
|
||||
sendEvent(ctx, event.event, event.payload ?? {});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
@@ -820,6 +853,15 @@ export function handleHubLiveEvent(
|
||||
if (!session?.attachedViaHub) {
|
||||
return;
|
||||
}
|
||||
// The observer client and ClineCore's own hub client are separate sockets
|
||||
// that both receive this session's events. This projection only exists for
|
||||
// sessions ClineCore is not subscribed to (it subscribes as a side effect
|
||||
// of start/send/pending_prompts and unsubscribes on stop); once it is,
|
||||
// `handleCoreSessionEvent` carries everything below and a second copy here
|
||||
// would double every delta, tool row, and status change.
|
||||
if (ctx.sessionManager?.hasSessionSubscription(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearLegacyCodexCredentials } from "./legacy-codex-credentials";
|
||||
|
||||
describe("clearLegacyCodexCredentials", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes only the Codex credentials from the legacy secrets file", () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
|
||||
tempDirs.push(dataDir);
|
||||
const secretsPath = path.join(dataDir, "secrets.json");
|
||||
writeFileSync(
|
||||
secretsPath,
|
||||
JSON.stringify({
|
||||
"openai-codex-oauth-credentials": JSON.stringify({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
}),
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearLegacyCodexCredentials(dataDir)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the file is missing or has no Codex credentials", () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
|
||||
tempDirs.push(dataDir);
|
||||
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
|
||||
|
||||
const secretsPath = path.join(dataDir, "secrets.json");
|
||||
writeFileSync(secretsPath, JSON.stringify({ apiKey: "keep" }));
|
||||
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
|
||||
expect(readFileSync(secretsPath, "utf8")).toBe(
|
||||
JSON.stringify({ apiKey: "keep" }),
|
||||
);
|
||||
|
||||
writeFileSync(secretsPath, "{not json");
|
||||
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
||||
const LEGACY_CODEX_SECRET_KEY = "openai-codex-oauth-credentials";
|
||||
|
||||
/**
|
||||
* Removes the ChatGPT (Codex) OAuth credentials from the legacy VS Code
|
||||
* extension's secrets.json. The legacy import in ProviderSettingsManager runs
|
||||
* on construction and re-adds any provider missing from providers.json, so
|
||||
* leaving these credentials on disk would sign the user straight back in
|
||||
* after they sign out in the desktop app. Temporary until the legacy import
|
||||
* is retired.
|
||||
*
|
||||
* A missing or unparseable file is a no-op (the import ignores those too).
|
||||
* A failed write throws so the sign-out is reported as failed instead of
|
||||
* succeeding and then being undone by the next import.
|
||||
*/
|
||||
export function clearLegacyCodexCredentials(
|
||||
dataDir: string = resolveClineDataDir(),
|
||||
): boolean {
|
||||
const secretsPath = join(dataDir, "secrets.json");
|
||||
if (!existsSync(secretsPath)) {
|
||||
return false;
|
||||
}
|
||||
let secrets: unknown;
|
||||
try {
|
||||
secrets = JSON.parse(readFileSync(secretsPath, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!secrets ||
|
||||
typeof secrets !== "object" ||
|
||||
Array.isArray(secrets) ||
|
||||
!(LEGACY_CODEX_SECRET_KEY in secrets)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
delete (secrets as Record<string, unknown>)[LEGACY_CODEX_SECRET_KEY];
|
||||
writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -187,4 +187,19 @@ describe("official plugin install detection", () => {
|
||||
);
|
||||
expect(populated.installedKeys).toEqual(["plugin:goal"]);
|
||||
});
|
||||
|
||||
it("does not match portable Agent Plugins to Cline marketplace entries", () => {
|
||||
const result = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:goal",
|
||||
name: "goal",
|
||||
path: "/home/user/.agents/plugins/goal",
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
} as JsonRecord);
|
||||
|
||||
expect(result.installedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -714,6 +714,7 @@ function hasMatchingInventoryItem(
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
if (record.agentPlugin === true) return false;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
|
||||
@@ -14,6 +14,7 @@ function createContext(workspaceRoot: string): SidecarContext {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
bootId: "test-boot",
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderAuthStorageId,
|
||||
loginLocalProvider,
|
||||
markLocalProviderEnabled,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
startClineDeviceAuth,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export class OAuthLoginCancelledError extends Error {
|
||||
constructor(providerId: string) {
|
||||
@@ -26,13 +29,41 @@ type PendingOAuthLogin = {
|
||||
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
|
||||
|
||||
export type OAuthLoginDependencies = {
|
||||
login: typeof loginLocalProvider;
|
||||
login: typeof loginProviderForDesktop;
|
||||
save: typeof saveLocalProviderOAuthCredentials;
|
||||
markEnabled: typeof markLocalProviderEnabled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cline account providers sign in with the WorkOS device-code grant, whose
|
||||
* browser page asks the user to confirm a short code. `loginLocalProvider`
|
||||
* runs that flow but discards the code, so use the split helpers instead and
|
||||
* surface the code through `onUserCode` for the UI to display.
|
||||
*/
|
||||
async function loginProviderForDesktop(
|
||||
providerId: string,
|
||||
existing: Parameters<typeof loginLocalProvider>[1],
|
||||
openUrl: (url: string) => void,
|
||||
onUserCode?: (userCode: string) => void,
|
||||
): ReturnType<typeof loginLocalProvider> {
|
||||
if (providerId !== "cline" && providerId !== "cline-pass") {
|
||||
return loginLocalProvider(providerId, existing, openUrl);
|
||||
}
|
||||
const device = await startClineDeviceAuth();
|
||||
onUserCode?.(device.userCode);
|
||||
openUrl(device.verificationUriComplete ?? device.verificationUri);
|
||||
return completeClineDeviceAuth({
|
||||
deviceCode: device.deviceCode,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
pollIntervalSeconds: device.pollIntervalSeconds,
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
provider: providerId,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultDependencies: OAuthLoginDependencies = {
|
||||
login: loginLocalProvider,
|
||||
login: loginProviderForDesktop,
|
||||
save: saveLocalProviderOAuthCredentials,
|
||||
markEnabled: markLocalProviderEnabled,
|
||||
};
|
||||
@@ -47,7 +78,11 @@ export async function runCancellableProviderOAuthLogin(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
openUrl: (url: string) => void,
|
||||
options: { owner?: object } = {},
|
||||
options: {
|
||||
owner?: object;
|
||||
/** Receives the device sign-in confirmation code, when the flow has one. */
|
||||
onUserCode?: (userCode: string) => void;
|
||||
} = {},
|
||||
dependencies: OAuthLoginDependencies = defaultDependencies,
|
||||
): Promise<{ provider: string; accessToken: string }> {
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
@@ -74,7 +109,7 @@ export async function runCancellableProviderOAuthLogin(
|
||||
// after cancellation is observed and cannot become an unhandled
|
||||
// rejection that kills the sidecar.
|
||||
const credentials = await Promise.race([
|
||||
dependencies.login(providerId, existing, openUrl),
|
||||
dependencies.login(providerId, existing, openUrl, options.onUserCode),
|
||||
cancellation,
|
||||
]);
|
||||
if (entry.cancelled) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { version } from "../package.json";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureExtensionActivated: vi.fn(),
|
||||
@@ -28,7 +29,14 @@ vi.mock("@cline/core", async () => {
|
||||
identifyAccount: mocks.identifyAccount,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings() {
|
||||
return { auth: { accountId: "account-1" } };
|
||||
return {
|
||||
auth: {
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
setSdkLogger: mocks.setSdkLogger,
|
||||
@@ -57,8 +65,10 @@ describe("desktop observability", () => {
|
||||
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform: "Cline Desktop",
|
||||
platform_version: version,
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
@@ -67,9 +77,18 @@ describe("desktop observability", () => {
|
||||
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
|
||||
id: "account-1",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
});
|
||||
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
|
||||
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
|
||||
expect(observability.telemetryUser).toEqual({
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
email: undefined,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await observability.dispose();
|
||||
await observability.dispose();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig,
|
||||
@@ -8,7 +7,11 @@ import {
|
||||
ProviderSettingsManager,
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
import {
|
||||
DESKTOP_TELEMETRY_METADATA,
|
||||
resolveDesktopTelemetryUser,
|
||||
} from "./client-context";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
export interface DesktopObservability {
|
||||
readonly logger: DesktopLoggerAdapter["core"];
|
||||
readonly telemetry: ITelemetryService;
|
||||
readonly telemetryUser?: UserContext;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,23 +32,23 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
|
||||
const telemetryHandle = createConfiguredTelemetryHandle({
|
||||
...createClineTelemetryServiceConfig({
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
},
|
||||
metadata: DESKTOP_TELEMETRY_METADATA,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const telemetry = telemetryHandle.telemetry;
|
||||
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
|
||||
const telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId: auth?.accountId,
|
||||
organizationId: auth?.organizationId,
|
||||
});
|
||||
if (auth?.accountId) {
|
||||
identifyAccount(telemetry, {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: auth.organizationId,
|
||||
organizationName: auth.organizationName,
|
||||
memberId: auth.memberId,
|
||||
});
|
||||
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
return {
|
||||
logger,
|
||||
telemetry,
|
||||
telemetryUser,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { capturePullRequestEvent } from "./pull-request-telemetry";
|
||||
|
||||
const event = {
|
||||
action: "open_clicked",
|
||||
prState: "open",
|
||||
ciState: "success",
|
||||
mergeTone: "success",
|
||||
};
|
||||
function service(enabled = true) {
|
||||
const capture = vi.fn();
|
||||
return {
|
||||
capture,
|
||||
telemetry: {
|
||||
capture,
|
||||
isEnabled: () => enabled,
|
||||
} as unknown as ITelemetryService,
|
||||
};
|
||||
}
|
||||
|
||||
it("captures only allowlisted status categories and strips identifiers", () => {
|
||||
const { capture, telemetry } = service();
|
||||
capturePullRequestEvent(telemetry, {
|
||||
...event,
|
||||
repository: "private/repo",
|
||||
cwd: "/private/path",
|
||||
url: "https://github.com/private/repo",
|
||||
title: "Secret",
|
||||
number: 42,
|
||||
});
|
||||
expect(capture).toHaveBeenCalledExactlyOnceWith({
|
||||
event: "desktop.pull_request.open_clicked",
|
||||
properties: {
|
||||
prState: "open",
|
||||
ciState: "success",
|
||||
mergeTone: "success",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...event, action: "arbitrary.event" },
|
||||
{ ...event, prState: "private/repo" },
|
||||
{ ...event, ciState: "test name" },
|
||||
{ ...event, mergeTone: "secret" },
|
||||
{},
|
||||
null,
|
||||
])("drops invalid payloads", (input) => {
|
||||
const { capture, telemetry } = service();
|
||||
capturePullRequestEvent(telemetry, input);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects telemetry opt-out", () => {
|
||||
const { capture, telemetry } = service(false);
|
||||
capturePullRequestEvent(telemetry, event);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(() => capturePullRequestEvent(undefined, event)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not fail the command when the provider throws", () => {
|
||||
const { capture, telemetry } = service();
|
||||
capture.mockImplementation(() => {
|
||||
throw new Error("Telemetry unavailable");
|
||||
});
|
||||
expect(() => capturePullRequestEvent(telemetry, event)).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import { pullRequestTelemetrySchema } from "../webview/lib/pull-request-telemetry-schema";
|
||||
|
||||
export function capturePullRequestEvent(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
input: unknown,
|
||||
): void {
|
||||
const parsed = pullRequestTelemetrySchema.safeParse(input);
|
||||
if (!parsed.success) return;
|
||||
try {
|
||||
if (!telemetry?.isEnabled()) return;
|
||||
const { action, ...properties } = parsed.data;
|
||||
telemetry.capture({ event: `desktop.pull_request.${action}`, properties });
|
||||
} catch {
|
||||
// Product interactions must continue if the telemetry provider fails.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getMergeStatus, summarizeChecks } from "../webview/lib/pull-request";
|
||||
import {
|
||||
createPullRequestStatusReader,
|
||||
GITHUB_AVAILABILITY_CACHE_MS,
|
||||
githubRepository,
|
||||
normalizeCheck,
|
||||
} from "./pull-request";
|
||||
|
||||
const pr = {
|
||||
number: 42,
|
||||
title: "Feature",
|
||||
url: "https://github.com/cline/cline/pull/42",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
mergeable: "MERGEABLE",
|
||||
mergeStateStatus: "CLEAN",
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
headRepositoryOwner: { login: "cline" },
|
||||
headRepository: { name: "cline" },
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
name: "Test",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SUCCESS",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function runner(prs: unknown[] = [pr], branch = "feature/pr-ui") {
|
||||
return vi.fn(async (file: string, args: string[], _cwd: string) => {
|
||||
if (file === "git")
|
||||
return args[0] === "branch" ? branch : "git@github.com:cline/cline.git";
|
||||
if (args[0] === "pr" && args[1] === "view")
|
||||
return JSON.stringify(
|
||||
prs.find((item) => (item as typeof pr).number === Number(args[2])),
|
||||
);
|
||||
return JSON.stringify(
|
||||
args[0] === "repo" ? { defaultBranchRef: { name: "main" } } : prs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("pull request status", () => {
|
||||
it("reads the active workspace, filters same-named fork branches and prefers an open PR", async () => {
|
||||
const run = runner([
|
||||
{ ...pr, number: 99, headRepositoryOwner: { login: "someone" } },
|
||||
{ ...pr, number: 41, state: "MERGED" },
|
||||
pr,
|
||||
]);
|
||||
const result = await createPullRequestStatusReader({ run })("/worktree");
|
||||
expect(result?.pullRequest?.number).toBe(42);
|
||||
expect(result?.pullRequest?.checks[0].state).toBe("success");
|
||||
expect(result?.createUrl).toBe(
|
||||
"https://github.com/cline/cline/compare/main...feature%2Fpr-ui?expand=1",
|
||||
);
|
||||
expect(run.mock.calls.every((call) => call[2] === "/worktree")).toBe(true);
|
||||
expect(run.mock.calls.find((call) => call[1][0] === "pr")?.[1]).toContain(
|
||||
"feature/pr-ui",
|
||||
);
|
||||
});
|
||||
it("offers creation for a feature branch and hides it for the default branch", async () => {
|
||||
expect(
|
||||
(await createPullRequestStatusReader({ run: runner([]) })("/repo"))
|
||||
?.pullRequest,
|
||||
).toBeNull();
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: runner([], "main") })("/repo"),
|
||||
).toBeNull();
|
||||
});
|
||||
it("keeps merged and closed PR states", async () => {
|
||||
for (const state of ["MERGED", "CLOSED"] as const) {
|
||||
const result = await createPullRequestStatusReader({
|
||||
run: runner([{ ...pr, state }]),
|
||||
})("/repo");
|
||||
expect(result?.pullRequest?.state).toBe(state);
|
||||
}
|
||||
});
|
||||
it("does not invoke GitHub for detached HEAD or unsupported remotes", async () => {
|
||||
const detached = runner([], "");
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: detached })("/repo"),
|
||||
).toBeNull();
|
||||
expect(detached).toHaveBeenCalledTimes(1);
|
||||
const local = vi.fn(async () => "local");
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: local })("/repo"),
|
||||
).toBeNull();
|
||||
expect(local).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([
|
||||
"ENOENT",
|
||||
1,
|
||||
4,
|
||||
])("hides unavailable GitHub CLI (%s), shares the cooldown, and recovers after login", async (code) => {
|
||||
let time = 0;
|
||||
let authenticated = false;
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (file === "gh" && args[0] === "auth" && !authenticated)
|
||||
throw Object.assign(new Error("private stderr"), { code });
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run, now: () => time });
|
||||
expect(await read("/repo")).toBeNull();
|
||||
const attempts = run.mock.calls.length;
|
||||
authenticated = true;
|
||||
time = GITHUB_AVAILABILITY_CACHE_MS - 1;
|
||||
expect(await read("/other-workspace")).toBeNull();
|
||||
expect(run).toHaveBeenCalledTimes(attempts);
|
||||
time++;
|
||||
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
|
||||
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares an in-flight availability check across concurrent workspaces", async () => {
|
||||
let finish!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "auth") await waiting;
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run });
|
||||
const first = read("/first");
|
||||
const second = read("/second");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
run.mock.calls.filter((call) => call[1][0] === "auth"),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides default-branch authentication failures before any repository query", async () => {
|
||||
const base = runner([], "main");
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (file === "gh")
|
||||
throw Object.assign(new Error("Not logged in"), { code: 1 });
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
expect(await createPullRequestStatusReader({ run })("/repo")).toBeNull();
|
||||
expect(
|
||||
run.mock.calls.filter((call) => call[0] === "gh").map((call) => call[1]),
|
||||
).toEqual([["auth", "status", "--active", "--hostname", "github.com"]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 4 },
|
||||
{ code: 1, stderr: "HTTP 401: Bad credentials" },
|
||||
])("invalidates cached availability if authentication expires during a lookup", async (failure) => {
|
||||
let expired = false;
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "repo" && expired)
|
||||
throw Object.assign(new Error("Failed"), failure);
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run });
|
||||
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
|
||||
expired = true;
|
||||
expect(await read("/repo")).toBeNull();
|
||||
const attempts = run.mock.calls.length;
|
||||
expect(await read("/other")).toBeNull();
|
||||
expect(run).toHaveBeenCalledTimes(attempts);
|
||||
});
|
||||
|
||||
it("preserves transient lookup errors after successful authentication", async () => {
|
||||
const base = runner();
|
||||
const run = async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "repo")
|
||||
throw Object.assign(new Error("private stderr"), {
|
||||
code: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
return base(file, args, cwd);
|
||||
};
|
||||
await expect(
|
||||
createPullRequestStatusReader({ run })("/repo"),
|
||||
).rejects.toThrow(
|
||||
"Could not load pull request status. Check your connection and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts GitHub SSH/HTTPS remotes only", () => {
|
||||
for (const remote of [
|
||||
"git@github.com:cline/cline.git",
|
||||
"https://github.com/cline/cline.git",
|
||||
"ssh://git@github.com/cline/cline",
|
||||
])
|
||||
expect(githubRepository(remote)).toBe("cline/cline");
|
||||
expect(
|
||||
githubRepository("https://github.com.evil.test/cline/cline"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("check and merge states", () => {
|
||||
it("handles check runs, legacy statuses, skipped checks and unsafe links", () => {
|
||||
const pending = normalizeCheck({
|
||||
__typename: "CheckRun",
|
||||
status: "IN_PROGRESS",
|
||||
conclusion: "SUCCESS",
|
||||
});
|
||||
const failed = normalizeCheck({
|
||||
__typename: "StatusContext",
|
||||
state: "ERROR",
|
||||
context: "Build",
|
||||
targetUrl: "javascript:alert(1)",
|
||||
});
|
||||
const skipped = normalizeCheck({
|
||||
__typename: "CheckRun",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SKIPPED",
|
||||
});
|
||||
expect(pending.state).toBe("pending");
|
||||
expect(failed).toEqual({ name: "Build", state: "failure", url: undefined });
|
||||
expect(summarizeChecks([pending, failed])).toBe("failure");
|
||||
expect(summarizeChecks([skipped])).toBe("skipped");
|
||||
expect(summarizeChecks([])).toBe("none");
|
||||
});
|
||||
it("never calls an unknown, draft or blocked PR ready to merge", async () => {
|
||||
const result = await createPullRequestStatusReader({ run: runner() })(
|
||||
"/repo",
|
||||
);
|
||||
const value = result!.pullRequest!;
|
||||
expect(
|
||||
getMergeStatus({ ...value, mergeStateStatus: "BLOCKED" }).label,
|
||||
).toBe("Blocked");
|
||||
expect(getMergeStatus({ ...value, isDraft: true }).label).toBe("Draft");
|
||||
expect(
|
||||
getMergeStatus({
|
||||
...value,
|
||||
mergeable: "UNKNOWN",
|
||||
mergeStateStatus: "UNKNOWN",
|
||||
}).label,
|
||||
).toBe("Merge status pending");
|
||||
expect(getMergeStatus({ ...value, mergeable: "CONFLICTING" }).label).toBe(
|
||||
"Conflicts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
PullRequestCheck,
|
||||
PullRequestStatus,
|
||||
} from "../webview/lib/pull-request";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type RunCommand = (
|
||||
file: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
) => Promise<string>;
|
||||
const runCommand: RunCommand = async (file, args, cwd) => {
|
||||
const { stdout } = await execFileAsync(file, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
||||
});
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
type GitHubCheck = {
|
||||
__typename: string;
|
||||
name?: string;
|
||||
context?: string;
|
||||
status?: string;
|
||||
conclusion?: string;
|
||||
state?: string;
|
||||
detailsUrl?: string;
|
||||
targetUrl?: string;
|
||||
};
|
||||
|
||||
export function normalizeCheck(check: GitHubCheck): PullRequestCheck {
|
||||
const result =
|
||||
check.__typename === "CheckRun"
|
||||
? check.status === "COMPLETED"
|
||||
? check.conclusion
|
||||
: "PENDING"
|
||||
: check.state;
|
||||
return {
|
||||
name: check.name || check.context || "Check",
|
||||
state:
|
||||
result === "SUCCESS"
|
||||
? "success"
|
||||
: result === "NEUTRAL" || result === "SKIPPED"
|
||||
? "skipped"
|
||||
: [
|
||||
"FAILURE",
|
||||
"ERROR",
|
||||
"CANCELLED",
|
||||
"TIMED_OUT",
|
||||
"ACTION_REQUIRED",
|
||||
"STALE",
|
||||
"STARTUP_FAILURE",
|
||||
].includes(result ?? "")
|
||||
? "failure"
|
||||
: "pending",
|
||||
url: safeHttpUrl(check.detailsUrl || check.targetUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHttpUrl(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function githubRepository(remote: string): string | null {
|
||||
const match = remote.match(
|
||||
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)\/?$/,
|
||||
);
|
||||
return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}` : null;
|
||||
}
|
||||
|
||||
type GitHubPullRequest = Omit<
|
||||
NonNullable<PullRequestStatus["pullRequest"]>,
|
||||
"checks"
|
||||
> & {
|
||||
headRepositoryOwner: { login: string } | null;
|
||||
headRepository: { name: string } | null;
|
||||
statusCheckRollup: GitHubCheck[] | null;
|
||||
};
|
||||
|
||||
// Shared across workspaces: missing credentials are a machine-level capability,
|
||||
// not a repository failure. Retry after installation/login without polling gh.
|
||||
export const GITHUB_AVAILABILITY_CACHE_MS = 5 * 60_000;
|
||||
|
||||
function commandErrorCode(error: unknown): unknown {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? error.code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isAuthenticationFailure(error: unknown): boolean {
|
||||
if (commandErrorCode(error) === 4) return true;
|
||||
const stderr =
|
||||
error && typeof error === "object" && "stderr" in error
|
||||
? String(error.stderr)
|
||||
: "";
|
||||
return /HTTP 401|Bad credentials/i.test(stderr);
|
||||
}
|
||||
|
||||
export function createPullRequestStatusReader({
|
||||
run = runCommand,
|
||||
now = Date.now,
|
||||
}: {
|
||||
run?: RunCommand;
|
||||
now?: () => number;
|
||||
} = {}) {
|
||||
let available: boolean | undefined;
|
||||
let expiresAt = 0;
|
||||
let probe: Promise<boolean> | undefined;
|
||||
|
||||
function markUnavailable() {
|
||||
available = false;
|
||||
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
|
||||
}
|
||||
|
||||
async function isAvailable(cwd: string): Promise<boolean> {
|
||||
if (available !== undefined && now() < expiresAt) return available;
|
||||
if (probe) return probe;
|
||||
probe = (async () => {
|
||||
try {
|
||||
await run(
|
||||
"gh",
|
||||
["auth", "status", "--active", "--hostname", "github.com"],
|
||||
cwd,
|
||||
);
|
||||
available = true;
|
||||
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// gh auth status documents exit 1 for missing/invalid authentication.
|
||||
const code = commandErrorCode(error);
|
||||
if (code === "ENOENT" || code === 1 || code === 4) {
|
||||
markUnavailable();
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await probe;
|
||||
} finally {
|
||||
probe = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only: creation is reviewed and submitted in GitHub's compare form. */
|
||||
return async function readPullRequestStatus(
|
||||
cwd: string,
|
||||
): Promise<PullRequestStatus | null> {
|
||||
if (available === false && now() < expiresAt) return null;
|
||||
const branch = await run("git", ["branch", "--show-current"], cwd).catch(
|
||||
() => "",
|
||||
);
|
||||
if (!branch) return null;
|
||||
const remote = await run("git", ["remote", "get-url", "origin"], cwd).catch(
|
||||
() => "",
|
||||
);
|
||||
const repository = githubRepository(remote);
|
||||
if (!repository) return null;
|
||||
try {
|
||||
if (!(await isAvailable(cwd))) return null;
|
||||
const repo = JSON.parse(
|
||||
await run(
|
||||
"gh",
|
||||
["repo", "view", repository, "--json", "defaultBranchRef"],
|
||||
cwd,
|
||||
),
|
||||
) as { defaultBranchRef: { name: string } | null };
|
||||
const base = repo.defaultBranchRef?.name;
|
||||
// The default branch can have years-old PRs from earlier branch workflows.
|
||||
// Those do not describe the current work, and it is not a PR source branch.
|
||||
if (branch === base) return null;
|
||||
const prsJson = await run(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
repository,
|
||||
"--head",
|
||||
branch,
|
||||
"--state",
|
||||
"all",
|
||||
"--limit",
|
||||
"100",
|
||||
"--json",
|
||||
"number,state,headRepositoryOwner,headRepository",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
const [owner, name] = repository.split("/");
|
||||
const candidates = (
|
||||
JSON.parse(prsJson) as Pick<
|
||||
GitHubPullRequest,
|
||||
"number" | "state" | "headRepositoryOwner" | "headRepository"
|
||||
>[]
|
||||
).filter(
|
||||
(pr) =>
|
||||
pr.headRepositoryOwner?.login.toLowerCase() === owner.toLowerCase() &&
|
||||
pr.headRepository?.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
const candidate =
|
||||
candidates.find((pr) => pr.state === "OPEN") ?? candidates[0];
|
||||
const pr = candidate
|
||||
? (JSON.parse(
|
||||
await run(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
String(candidate.number),
|
||||
"--repo",
|
||||
repository,
|
||||
"--json",
|
||||
"number,title,url,state,isDraft,mergeable,mergeStateStatus,additions,deletions,statusCheckRollup",
|
||||
],
|
||||
cwd,
|
||||
),
|
||||
) as GitHubPullRequest)
|
||||
: null;
|
||||
return {
|
||||
repository,
|
||||
branch,
|
||||
createUrl:
|
||||
base && branch !== base
|
||||
? `https://github.com/${repository}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`
|
||||
: null,
|
||||
pullRequest: pr
|
||||
? {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
isDraft: pr.isDraft,
|
||||
mergeable: pr.mergeable,
|
||||
mergeStateStatus: pr.mergeStateStatus,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
checks: (pr.statusCheckRollup ?? []).map(normalizeCheck),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// Authentication can expire while a positive availability result is cached.
|
||||
if (
|
||||
commandErrorCode(error) === "ENOENT" ||
|
||||
isAuthenticationFailure(error)
|
||||
) {
|
||||
markUnavailable();
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
"Could not load pull request status. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getPullRequestStatus = createPullRequestStatusReader();
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -60,6 +61,11 @@ export type LiveSession = {
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
/** Iterations already in flight when the user supplied recovery guidance. */
|
||||
mistakeRecovery?: {
|
||||
latestIteration: number;
|
||||
continuedThroughIteration?: number;
|
||||
};
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
|
||||
@@ -115,6 +121,12 @@ export type SidecarContext = {
|
||||
liveSessions: Map<string, LiveSession>;
|
||||
restoringWorkspacePaths: Set<string>;
|
||||
streamIndices: Map<string, number>;
|
||||
/**
|
||||
* Identifies this sidecar process. `streamIndices` restarts whenever the
|
||||
* sidecar does, so the webview needs to tell "index 1 of a new process"
|
||||
* apart from a replay of the run it already rendered.
|
||||
*/
|
||||
bootId: string;
|
||||
wsClients: Set<SidecarWebSocketClient>;
|
||||
pendingApprovals: Map<string, PendingToolApproval>;
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
@@ -123,6 +135,8 @@ export type SidecarContext = {
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
/** Analytics identity and explicit account state forwarded with each session. */
|
||||
telemetryUser?: UserContext;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
/**
|
||||
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging",
|
||||
"notification:default"
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,28 @@
|
||||
; Tauri's installer only stops the main binary (CheckIfAppIsRunning in its
|
||||
; utils.nsh). The bundled sidecar re-executes itself as the detached Cline Hub
|
||||
; daemon, which by design outlives the app and so keeps code-sidecar.exe
|
||||
; locked. Without this, updating fails with "Error opening file for writing"
|
||||
; (and uninstalling leaves the exe behind) until the user kills that process
|
||||
; by hand.
|
||||
;
|
||||
; Match on the full path rather than the image name: the production Hub is
|
||||
; shared per machine, and a code-sidecar.exe from another install (e.g. the
|
||||
; side-by-side Cline Beta) may be hosting it without locking ours. The path
|
||||
; travels through an environment variable so it never needs quoting inside
|
||||
; the PowerShell command ($INSTDIR contains the username).
|
||||
!macro STOP_SIDECAR_PROCESSES
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t "CLINE_SIDECAR_EXE", t "$INSTDIR\code-sidecar.exe")'
|
||||
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -Command "Get-Process code-sidecar -ErrorAction SilentlyContinue | Where-Object { $$_.Path -eq $$env:CLINE_SIDECAR_EXE } | Stop-Process -Force"`
|
||||
Pop $R0
|
||||
; TerminateProcess returns before the file handle is released; same wait
|
||||
; Tauri uses after killing the main binary.
|
||||
Sleep 500
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
!insertmacro STOP_SIDECAR_PROCESSES
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
!insertmacro STOP_SIDECAR_PROCESSES
|
||||
!macroend
|
||||
@@ -9,7 +9,8 @@ use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -283,21 +284,16 @@ async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
|
||||
struct DesktopBackendState {
|
||||
ws_endpoint: Mutex<Option<String>>,
|
||||
process: Mutex<Option<Child>>,
|
||||
shutting_down: Mutex<bool>,
|
||||
shutting_down: AtomicBool,
|
||||
}
|
||||
|
||||
impl DesktopBackendState {
|
||||
fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down
|
||||
.lock()
|
||||
.map(|guard| *guard)
|
||||
.unwrap_or(true)
|
||||
self.shutting_down.load(AtomicOrdering::Acquire)
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
if let Ok(mut guard) = self.shutting_down.lock() {
|
||||
*guard = true;
|
||||
}
|
||||
self.shutting_down.store(true, AtomicOrdering::Release);
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
@@ -511,10 +507,26 @@ fn ensure_desktop_backend_started_with(
|
||||
// callers (setup, the health-check loop, endpoint fetches from the
|
||||
// webview) serialize: the second caller blocks here, then sees the live
|
||||
// child and returns instead of spawning a duplicate.
|
||||
let mut process_guard = state
|
||||
let process_guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock desktop backend process state")?;
|
||||
ensure_desktop_backend_started_locked(state, process_guard, spawn_backend)
|
||||
}
|
||||
|
||||
/// The check-and-spawn that runs under the process lock. Split from the lock
|
||||
/// acquisition so a test can establish "shutdown began after the unlocked
|
||||
/// check but before the lock was taken" deterministically.
|
||||
fn ensure_desktop_backend_started_locked(
|
||||
state: &Arc<DesktopBackendState>,
|
||||
mut process_guard: MutexGuard<'_, Option<Child>>,
|
||||
spawn_backend: impl FnOnce() -> Result<Child, String>,
|
||||
) -> Result<(), String> {
|
||||
// stop() marks shutdown before taking this same process lock. Recheck
|
||||
// under the lock so a queued startup cannot spawn after shutdown.
|
||||
if state.is_shutting_down() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(existing) = process_guard.as_mut() {
|
||||
match existing.try_wait() {
|
||||
// A live child owns startup even while its endpoint is still
|
||||
@@ -667,17 +679,29 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_desktop_backend_endpoint(
|
||||
async fn get_desktop_backend_endpoint(
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
context: State<'_, AppContext>,
|
||||
) -> Result<String, String> {
|
||||
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
|
||||
let backend_state = backend_state.inner().clone();
|
||||
let context = context.inner().clone();
|
||||
let state_for_start = backend_state.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ensure_desktop_backend_started(&state_for_start, &context)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("desktop backend startup task failed: {error}"))??;
|
||||
|
||||
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
|
||||
// see sidecar/shell-path.ts) plus session-manager init, whose duration
|
||||
// varies by machine. Poll well past that combined worst case; the loop
|
||||
// returns as soon as the ready line arrives, so only failure waits long.
|
||||
// While pending this only waits — respawning is ensure's job, and it
|
||||
// refuses to start a second sidecar while the first one is still alive.
|
||||
// A child that dies mid-poll makes this return an error rather than
|
||||
// respawn: the next ensure call — the health-check loop within 5 seconds,
|
||||
// or this command when the webview reconnects — replaces the dead child.
|
||||
// Async sleeps keep Tauri's window event loop responsive while pending.
|
||||
for _ in 0..150 {
|
||||
if let Some(endpoint) = backend_state
|
||||
.ws_endpoint
|
||||
@@ -700,7 +724,7 @@ fn get_desktop_backend_endpoint(
|
||||
if child_exited {
|
||||
return Err("desktop backend exited before publishing its endpoint".to_string());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err("desktop backend endpoint not ready".to_string())
|
||||
}
|
||||
@@ -789,56 +813,82 @@ 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", "midnight", "hologram", "chip"];
|
||||
/// webview/lib/app-icon.ts. Every id has a matching bundled resource at
|
||||
/// icons/app/<id>.png.
|
||||
const APP_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
|
||||
|
||||
#[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"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| format!("failed resolving app icon resource: {e}"))?;
|
||||
if !icon_path.exists() {
|
||||
return Err(format!(
|
||||
"app icon resource missing: {}",
|
||||
icon_path.display()
|
||||
));
|
||||
}
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
|
||||
async fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_ICONS.contains(&icon.as_str()) {
|
||||
return Err(format!("unknown app icon: {icon}"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// "classic" also ships as a dock resource, so every choice loads the
|
||||
// same way; setApplicationIconImage's binding warns that passing nil
|
||||
// to restore the bundled icon may not be allowed.
|
||||
let icon_path = app
|
||||
.path()
|
||||
.resolve(
|
||||
format!("icons/dock/{icon}.png"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
|
||||
if !icon_path.exists() {
|
||||
return Err(format!(
|
||||
"dock icon resource missing: {}",
|
||||
icon_path.display()
|
||||
));
|
||||
}
|
||||
// Every choice uses a resource because AppKit does not support restoring
|
||||
// the bundled icon by passing a nil application icon.
|
||||
let icon_path = resolve_app_icon(&app, &icon)?;
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
use objc2::{AllocAnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
let Some(image) = NSImage::initWithContentsOfFile(
|
||||
NSImage::alloc(),
|
||||
&NSString::from_str(&icon_path.to_string_lossy()),
|
||||
) else {
|
||||
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
|
||||
return;
|
||||
};
|
||||
// SAFETY: called on the main thread with a valid, non-nil image.
|
||||
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
||||
let result: Result<(), String> = (|| {
|
||||
let mtm = MainThreadMarker::new().ok_or_else(|| {
|
||||
"app icon update did not run on the main thread".to_string()
|
||||
})?;
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
let image = NSImage::initWithContentsOfFile(
|
||||
NSImage::alloc(),
|
||||
&NSString::from_str(&icon_path.to_string_lossy()),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
format!("failed loading app icon image: {}", icon_path.display())
|
||||
})?;
|
||||
// SAFETY: called on the main thread with a valid, non-nil image.
|
||||
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
||||
Ok(())
|
||||
})();
|
||||
let _ = result_tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("failed switching dock icon: {e}"))?;
|
||||
.map_err(|e| format!("failed switching app icon: {e}"))?;
|
||||
result_rx
|
||||
.await
|
||||
.map_err(|_| "app icon update ended before AppKit completed".to_string())??;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let icon_path = resolve_app_icon(&app, &icon)?;
|
||||
let image = tauri::image::Image::from_path(&icon_path)
|
||||
.map_err(|e| format!("failed loading app icon image: {e}"))?;
|
||||
let window = app
|
||||
.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.ok_or_else(|| "main window is unavailable".to_string())?;
|
||||
window
|
||||
.set_icon(image)
|
||||
.map_err(|e| format!("failed switching taskbar icon: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(false)
|
||||
@@ -1190,9 +1240,15 @@ fn main() {
|
||||
setup_tray_icon(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
||||
eprintln!("[desktop-backend] startup failed: {error}");
|
||||
}
|
||||
let state_for_start = backend_state.clone();
|
||||
let context_for_start = app_context.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) =
|
||||
ensure_desktop_backend_started(&state_for_start, &context_for_start)
|
||||
{
|
||||
eprintln!("[desktop-backend] startup failed: {error}");
|
||||
}
|
||||
});
|
||||
// Dev builds are not installed app bundles, so there is nothing the
|
||||
// updater could meaningfully check or replace.
|
||||
if !cfg!(debug_assertions) {
|
||||
@@ -1443,6 +1499,28 @@ mod tests {
|
||||
state.stop();
|
||||
}
|
||||
|
||||
/// The interleaving where only the recheck under the lock stands between
|
||||
/// shutdown and a fresh spawn: startup has passed its unlocked shutdown
|
||||
/// check, stop() marks shutdown while startup is still waiting for the
|
||||
/// process lock, and then startup acquires the lock. Played out directly
|
||||
/// on one thread so the ordering is exact rather than scheduled.
|
||||
#[test]
|
||||
fn startup_queued_on_process_lock_does_not_spawn_after_shutdown() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
let spawn_count = AtomicUsize::new(0);
|
||||
|
||||
assert!(!state.is_shutting_down(), "the unlocked check passes");
|
||||
state.shutting_down.store(true, AtomicOrdering::Release);
|
||||
let process_guard = state.process.lock().expect("process lock should succeed");
|
||||
ensure_desktop_backend_started_locked(&state, process_guard, || {
|
||||
spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
spawn_pending_sidecar()
|
||||
})
|
||||
.expect("shutdown should make startup a no-op");
|
||||
|
||||
assert_eq!(spawn_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exited_child_is_replaced_on_next_startup_check() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.26",
|
||||
"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/dock/*.png"],
|
||||
"resources": ["icons/app/*.png"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Cline",
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"shadow": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "nsis/installer-hooks.nsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
:root {
|
||||
--window-title-bar-height: 3rem;
|
||||
}
|
||||
|
||||
@variant max-md {
|
||||
:root {
|
||||
--window-title-bar-height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@@ -53,6 +63,17 @@
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* The Windows caption controls occupy the right edge of the shared title-bar row. */
|
||||
html[data-windows-custom-titlebar]
|
||||
[data-slot="window-title-bar-content-host"] {
|
||||
padding-right: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile toasts start below the caption row so both close buttons remain reachable. */
|
||||
html[data-windows-custom-titlebar] [data-slot="toast-viewport"] {
|
||||
@apply max-sm:top-(--window-title-bar-height);
|
||||
}
|
||||
|
||||
/* Chat Markdown polish and the streaming-title shimmer live in
|
||||
|
||||
@@ -66,6 +66,10 @@ import {
|
||||
watchDesktopTrayStatus,
|
||||
} from "@/lib/desktop-tray";
|
||||
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
|
||||
import {
|
||||
imageAttachmentMediaType,
|
||||
isUnsupportedImageAttachment,
|
||||
} from "@/lib/image-attachments";
|
||||
import { createLatestSuccessfulRequestGate } from "@/lib/latest-successful-request";
|
||||
import {
|
||||
hasCompletedOnboarding,
|
||||
@@ -89,6 +93,7 @@ import {
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { readImportedFromTool } from "@/lib/session-import";
|
||||
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
@@ -217,8 +222,8 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The dock reverts to the bundled icon every launch; re-apply the
|
||||
// user's choice once the shell is up.
|
||||
// The native app icon reverts to the bundled icon every launch; re-apply
|
||||
// the user's choice once the shell is up.
|
||||
void syncAppIcon();
|
||||
}, []);
|
||||
|
||||
@@ -601,6 +606,7 @@ function ChatThreadPane({
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
activityLabel,
|
||||
config,
|
||||
messages,
|
||||
error,
|
||||
@@ -1080,6 +1086,33 @@ function ChatThreadPane({
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
const supportedFiles = files.filter(
|
||||
(file) => !isUnsupportedImageAttachment(file),
|
||||
);
|
||||
if (supportedFiles.length !== files.length) {
|
||||
toast({
|
||||
title: "Unsupported image format",
|
||||
description:
|
||||
"Convert the image to PNG, JPEG, GIF, or WebP before attaching it.",
|
||||
});
|
||||
}
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of supportedFiles) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (prompt: string) => {
|
||||
const trimmed = prompt.trim();
|
||||
@@ -1093,9 +1126,23 @@ function ChatThreadPane({
|
||||
setPromptInput("");
|
||||
const toSend = [...pendingAttachments];
|
||||
setPendingAttachments([]);
|
||||
await sendPrompt(trimmed, toSend);
|
||||
const promptTaken = await sendPrompt(trimmed, toSend);
|
||||
// The prompt never reached the runtime (e.g. the provider connection
|
||||
// failed): hand it back so the user can fix the provider and resend
|
||||
// without retyping. Leave anything they typed meanwhile alone.
|
||||
if (!promptTaken && promptInputRef.current.trim() === "") {
|
||||
setPromptInput(trimmed);
|
||||
handleAttachFiles(toSend);
|
||||
}
|
||||
},
|
||||
[onThreadStarted, pendingAttachments, sendPrompt, setPromptInput, threadId],
|
||||
[
|
||||
handleAttachFiles,
|
||||
onThreadStarted,
|
||||
pendingAttachments,
|
||||
sendPrompt,
|
||||
setPromptInput,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
@@ -1270,29 +1317,12 @@ function ChatThreadPane({
|
||||
setPromptInput,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
isImage: file.type.startsWith("image/"),
|
||||
isImage: imageAttachmentMediaType(file) !== undefined,
|
||||
})),
|
||||
[pendingAttachments],
|
||||
);
|
||||
@@ -1373,6 +1403,9 @@ function ChatThreadPane({
|
||||
: (sessionId ?? visibleHistorySession?.sessionId ?? null);
|
||||
const displayedMessages = hideDeletedSessionUi ? [] : messages;
|
||||
const displayedError = hideDeletedSessionUi ? null : error;
|
||||
const importedFromTool = readImportedFromTool(
|
||||
visibleHistorySession?.metadata,
|
||||
);
|
||||
const displayedStatus = hideDeletedSessionUi ? "idle" : status;
|
||||
const displayedSessionId = hideDeletedSessionUi ? null : sessionId;
|
||||
const displayedIsSwitching = hideDeletedSessionUi
|
||||
@@ -1520,6 +1553,7 @@ function ChatThreadPane({
|
||||
onModeToggle={handleModeToggle}
|
||||
onPromptInputChange={handlePromptInputChange}
|
||||
onOpenVoiceInputSettings={onOpenVoiceInputSettings}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={steerPromptInQueue}
|
||||
onEditPromptInQueue={updatePromptInQueue}
|
||||
@@ -1594,7 +1628,9 @@ function ChatThreadPane({
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
activityLabel={activityLabel}
|
||||
error={displayedError}
|
||||
importedFromTool={importedFromTool}
|
||||
messages={displayedMessages}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRestoreCheckpoint={handleRestoreCheckpoint}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Import,
|
||||
Loader2,
|
||||
Mic,
|
||||
PanelLeftOpen,
|
||||
@@ -145,6 +146,7 @@ const SETTINGS_SECTION_ICONS = {
|
||||
Voice: Mic,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Import: Import,
|
||||
Account: CircleUserRound,
|
||||
Customize: Blocks,
|
||||
Marketplace: Store,
|
||||
@@ -914,12 +916,12 @@ export function AgentSidebar({
|
||||
newTaskActive && "bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openHome}
|
||||
title="Start a new task"
|
||||
title="Start a new session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Plus className="size-4 shrink-0" />
|
||||
<span className="truncate">New</span>
|
||||
<span className="truncate">Session</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Schedule"
|
||||
@@ -1621,7 +1623,7 @@ function ThreadItem({
|
||||
<div className="wrap-break-word text-sm font-medium">
|
||||
{overviewTitle}
|
||||
</div>
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1.5 text-xs">
|
||||
<div className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-2 gap-y-1.5 text-xs">
|
||||
{infoItems.map(([label, value, fullValue]) => (
|
||||
<div className="contents" key={label}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { describeOutdatedHubSessions } from "@cline/shared/browser";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
import {
|
||||
checkForUpdateNow,
|
||||
restartToApplyUpdate,
|
||||
useAppUpdateStatus,
|
||||
} from "@/hooks/use-app-update";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
type HubBuildMismatchPayload = {
|
||||
@@ -38,6 +42,36 @@ type UpdatePhase = "idle" | "updating" | "restarting";
|
||||
/** Generous deadline: drain wait + graceful retire + fresh daemon startup. */
|
||||
const HUB_UPGRADE_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* "Later" must survive webview remounts and reconnects: the sidecar replays
|
||||
* a pending mismatch on every new webview connection (session switches,
|
||||
* reloads, relaunches), and in-memory dismissal state resurrected the modal
|
||||
* each time. Storage keeps one key - a different hub build prompts again.
|
||||
*/
|
||||
const DISMISSED_MISMATCH_STORAGE_KEY = "cline.hub-mismatch-dismissed";
|
||||
|
||||
function readPersistedDismissedKey(): string | null {
|
||||
try {
|
||||
const key = localStorage.getItem(DISMISSED_MISMATCH_STORAGE_KEY);
|
||||
return isPersistableHubMismatchKey(key) ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedKey(key: string): void {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_MISMATCH_STORAGE_KEY, key);
|
||||
} catch {
|
||||
// Best effort: without storage the dismissal lasts this mount only.
|
||||
}
|
||||
}
|
||||
|
||||
// One updater kick per observed mismatch per page lifetime. Module scope
|
||||
// survives component remounts (session switches) so the update feed is not
|
||||
// re-hit every time the dialog mounts.
|
||||
let updateCheckKickedForKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Blocking prompt shown when the sidecar reports that the shared Cline Hub
|
||||
* does not match this app's build.
|
||||
@@ -58,9 +92,12 @@ export function HubUpdateRequiredDialog() {
|
||||
const [mismatch, setMismatch] = useState<HubBuildMismatchPayload | null>(
|
||||
null,
|
||||
);
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(
|
||||
readPersistedDismissedKey,
|
||||
);
|
||||
const [phase, setPhase] = useState<UpdatePhase>("idle");
|
||||
const [updateHint, setUpdateHint] = useState<string | null>(null);
|
||||
const updateStatus = useAppUpdateStatus();
|
||||
|
||||
useEffect(() => {
|
||||
return desktopClient.subscribe("hub_build_mismatch", (payload) => {
|
||||
@@ -74,16 +111,43 @@ export function HubUpdateRequiredDialog() {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
setMismatch(payload as HubBuildMismatchPayload);
|
||||
const incoming = payload as HubBuildMismatchPayload;
|
||||
setMismatch(incoming);
|
||||
// A new mismatch is a fresh prompt: drop any "no update available"
|
||||
// hint left over from a previous dialog so it reopens in its
|
||||
// initial state instead of pre-set to "Try again".
|
||||
setUpdateHint(null);
|
||||
// Delivery includes replays on in-place transport reconnects, where
|
||||
// this component never remounts: a non-persistable dismissal
|
||||
// (unsupported_protocol) must not survive them, or the warning about
|
||||
// a Hub the app cannot talk to stays silenced indefinitely.
|
||||
setDismissedKey((previous) =>
|
||||
retainDismissalForIncomingMismatch(previous, mismatchKeyOf(incoming)),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mismatchKey = mismatch ? mismatchKeyOf(mismatch) : null;
|
||||
|
||||
// When a newer Hub appears, stage the matching app update right away (if
|
||||
// a release exists) so the prompt can open actionable instead of waiting
|
||||
// for the next 30s background cycle. Without a staged update the
|
||||
// build_mismatch modal stays hidden entirely - see
|
||||
// shouldShowHubMismatchDialog.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!mismatch ||
|
||||
mismatch.reason !== "build_mismatch" ||
|
||||
mismatchKey === null ||
|
||||
mismatchKey === dismissedKey ||
|
||||
updateCheckKickedForKey === mismatchKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateCheckKickedForKey = mismatchKey;
|
||||
void checkForUpdateNow();
|
||||
}, [mismatch, mismatchKey, dismissedKey]);
|
||||
|
||||
const handleUpdateAndRestart = useCallback(async () => {
|
||||
setPhase("updating");
|
||||
setUpdateHint(null);
|
||||
@@ -191,14 +255,23 @@ export function HubUpdateRequiredDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
|
||||
const open =
|
||||
mismatchKey !== null &&
|
||||
mismatchKey !== dismissedKey &&
|
||||
shouldShowHubMismatchDialog(mismatch?.reason, updateStatus.state);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && phase === "idle") {
|
||||
if (!nextOpen && phase === "idle" && mismatchKey !== null) {
|
||||
setDismissedKey(mismatchKey);
|
||||
// unsupported_protocol never persists: hub-backed features
|
||||
// stay broken against that Hub, so its warning must return
|
||||
// on the next reconnect or relaunch.
|
||||
if (isPersistableHubMismatchKey(mismatchKey)) {
|
||||
persistDismissedKey(mismatchKey);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,38 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
describe("describeOutdatedHubSessions", () => {
|
||||
it("quantifies sessions and clients when the hub reported both", () => {
|
||||
expect(
|
||||
describeOutdatedHubSessions({
|
||||
activeSessionCount: 2,
|
||||
participantClientCount: 1,
|
||||
}),
|
||||
).toBe("2 active sessions from 1 connected Cline client");
|
||||
expect(
|
||||
describeOutdatedHubSessions({
|
||||
activeSessionCount: 1,
|
||||
participantClientCount: 3,
|
||||
}),
|
||||
).toBe("1 active session from 3 connected Cline clients");
|
||||
describe("shouldShowHubMismatchDialog", () => {
|
||||
it("always allows the truly-broken and blocking reasons", () => {
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"ready",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("unsupported_protocol", state)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldShowHubMismatchDialog("outdated_hub", state)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the client clause when participant ids were unavailable", () => {
|
||||
expect(
|
||||
describeOutdatedHubSessions({
|
||||
activeSessionCount: 4,
|
||||
participantClientCount: 0,
|
||||
}),
|
||||
).toBe("4 active sessions");
|
||||
});
|
||||
|
||||
it("falls back to an unquantified phrase when the hub could not answer", () => {
|
||||
expect(describeOutdatedHubSessions({})).toBe(
|
||||
"active sessions from other Cline clients",
|
||||
it("persists dismissals only for the advisory build_mismatch case", () => {
|
||||
expect(isPersistableHubMismatchKey("build_mismatch:abc123")).toBe(true);
|
||||
expect(isPersistableHubMismatchKey("unsupported_protocol:abc123")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPersistableHubMismatchKey("outdated_hub:abc123")).toBe(false);
|
||||
expect(isPersistableHubMismatchKey(null)).toBe(false);
|
||||
expect(isPersistableHubMismatchKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("reopens a dismissed protocol warning on redelivery, keeps advisory and unrelated dismissals", () => {
|
||||
// A replayed unsupported_protocol mismatch clears its own dismissal:
|
||||
// the app cannot talk to that Hub, so "Later" must not outlive an
|
||||
// in-place reconnect replay.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"unsupported_protocol:abc",
|
||||
"unsupported_protocol:abc",
|
||||
),
|
||||
).toBeNull();
|
||||
// The advisory newer-hub dismissal stands across replays.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"build_mismatch:abc",
|
||||
"build_mismatch:abc",
|
||||
),
|
||||
).toBe("build_mismatch:abc");
|
||||
// A dismissal for a different mismatch is untouched.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"build_mismatch:abc",
|
||||
"unsupported_protocol:def",
|
||||
),
|
||||
).toBe("build_mismatch:abc");
|
||||
expect(retainDismissalForIncomingMismatch(null, "build_mismatch:abc")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a newer-hub prompt only once an app update is staged", () => {
|
||||
expect(shouldShowHubMismatchDialog("build_mismatch", "ready")).toBe(true);
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("build_mismatch", state)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,59 @@ export type HubUpdateRestartDecision =
|
||||
| { action: "stay"; hint: string };
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* blocking "Hub update required" dialog. Falls back to an unquantified
|
||||
* phrase when the Hub could not answer the activity query.
|
||||
* Whether a hub build mismatch may interrupt with a modal at all.
|
||||
*
|
||||
* - `unsupported_protocol` and `outdated_hub` always may: the first means
|
||||
* the app cannot talk to the Hub, the second is the blocking
|
||||
* replace-or-quit decision.
|
||||
* - `build_mismatch` may only once an app update is actually staged. A
|
||||
* newer Hub is advisory while the wire protocol still works, and without
|
||||
* a staged update the modal's only exit is "no update available yet",
|
||||
* which loops on every launch and webview reconnect until a release
|
||||
* ships - so it stays silent until it can offer a real action.
|
||||
*/
|
||||
export function describeOutdatedHubSessions(counts: {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}): string {
|
||||
const sessions = counts.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return "active sessions from other Cline clients";
|
||||
export function shouldShowHubMismatchDialog(
|
||||
reason: string | undefined,
|
||||
updateState: AppUpdateStatus["state"] | undefined,
|
||||
): boolean {
|
||||
if (reason === "unsupported_protocol" || reason === "outdated_hub") {
|
||||
return true;
|
||||
}
|
||||
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
|
||||
const clients = counts.participantClientCount;
|
||||
if (typeof clients !== "number" || clients <= 0) {
|
||||
return sessionsPhrase;
|
||||
return updateState === "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the advisory `build_mismatch` dismissal may persist across webview
|
||||
* mounts and app relaunches. An `unsupported_protocol` Hub leaves hub-backed
|
||||
* features broken, so that warning must return on every reconnect and
|
||||
* relaunch - its "Later" lasts only for the current mount. Applied on both
|
||||
* write and read, so a key persisted by any other path is ignored too.
|
||||
* (Mismatch keys are `${reason}:${hubBuildId}`.)
|
||||
*/
|
||||
export function isPersistableHubMismatchKey(key: string | null): key is string {
|
||||
return typeof key === "string" && key.startsWith("build_mismatch:");
|
||||
}
|
||||
|
||||
/**
|
||||
* What a dismissal becomes when the sidecar delivers a mismatch again - it
|
||||
* replays the pending mismatch on every webview (re)connection, including
|
||||
* in-place transport reconnects where the dialog never remounts. A reason
|
||||
* whose dismissal may not outlive the moment (`unsupported_protocol`: the
|
||||
* app cannot talk to the Hub) drops its matching in-memory "Later" so the
|
||||
* warning reopens on the replay; the advisory `build_mismatch` dismissal
|
||||
* stands. An unrelated dismissed key is kept either way.
|
||||
*/
|
||||
export function retainDismissalForIncomingMismatch(
|
||||
previousDismissedKey: string | null,
|
||||
incomingKey: string,
|
||||
): string | null {
|
||||
if (
|
||||
previousDismissedKey === incomingKey &&
|
||||
!isPersistableHubMismatchKey(incomingKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
|
||||
return previousDismissedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-surface-hover [a&]:hover:text-foreground",
|
||||
muted:
|
||||
"text-muted-foreground [a&]:hover:bg-surface-hover [a&]:hover:text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -15,6 +15,7 @@ const ToastViewport = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-105",
|
||||
className,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { act, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
parseModelSelectionStorage,
|
||||
} from "@/lib/model-selection";
|
||||
import type { ProviderModel } from "@/lib/provider-schema";
|
||||
import {
|
||||
buildUserInstructionSlashCommands,
|
||||
ChatInputBar,
|
||||
@@ -27,7 +29,11 @@ const {
|
||||
current: null as MockSpeechInputProps | null,
|
||||
},
|
||||
startVercelStreamingTranscriptionMock: vi.fn(),
|
||||
subscribeToProviderModelsMock: vi.fn(() => vi.fn()),
|
||||
subscribeToProviderModelsMock: vi.fn<
|
||||
(
|
||||
listener: (providerId: string, models: ProviderModel[]) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
}));
|
||||
|
||||
type MockSpeechInputProps = {
|
||||
@@ -152,6 +158,8 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
async function renderVoiceComposer({
|
||||
attachments = [],
|
||||
model = "test-model",
|
||||
hasRunningAgents = false,
|
||||
onAbort = vi.fn(),
|
||||
onPromptInputChange = vi.fn(),
|
||||
@@ -160,6 +168,8 @@ async function renderVoiceComposer({
|
||||
promptVersion = 0,
|
||||
status = "idle",
|
||||
}: {
|
||||
attachments?: Parameters<typeof ChatInputBar>[0]["attachments"];
|
||||
model?: string;
|
||||
hasRunningAgents?: boolean;
|
||||
onAbort?: ReturnType<typeof vi.fn>;
|
||||
onPromptInputChange?: ReturnType<typeof vi.fn>;
|
||||
@@ -172,11 +182,11 @@ async function renderVoiceComposer({
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
attachments={attachments}
|
||||
gitBranch="main"
|
||||
hasRunningAgents={hasRunningAgents}
|
||||
mode="act"
|
||||
model="test-model"
|
||||
model={model}
|
||||
onAbort={onAbort}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
@@ -209,6 +219,72 @@ async function renderVoiceComposer({
|
||||
}
|
||||
|
||||
describe("ChatInputBar", () => {
|
||||
it("blocks sending existing draft images after switching models and preserves the draft", async () => {
|
||||
const onSend = vi.fn();
|
||||
const attachments = [{ id: "image", name: "photo.jfif", isImage: true }];
|
||||
await renderVoiceComposer({ onSend, attachments, prompt: "Describe it" });
|
||||
await renderVoiceComposer({
|
||||
onSend,
|
||||
attachments,
|
||||
prompt: "Describe it",
|
||||
model: "text-only",
|
||||
});
|
||||
await act(async () => {
|
||||
subscribeToProviderModelsMock.mock.calls.at(-1)?.[0]("cline", [
|
||||
{ id: "text-only", name: "Text only", inputModalities: ["text"] },
|
||||
]);
|
||||
});
|
||||
expect(container.querySelector("output")?.textContent).toContain(
|
||||
"doesn’t support",
|
||||
);
|
||||
const textarea = container.querySelector("textarea");
|
||||
await act(async () => {
|
||||
textarea?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(textarea?.value).toBe("Describe it");
|
||||
await renderVoiceComposer({
|
||||
onSend,
|
||||
attachments: [],
|
||||
prompt: "Describe it",
|
||||
model: "text-only",
|
||||
});
|
||||
await act(async () => {
|
||||
textarea?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith("Describe it");
|
||||
});
|
||||
|
||||
it("does not send attachments without a text prompt", async () => {
|
||||
const onSend = vi.fn();
|
||||
const attachments = [{ id: "image", name: "photo.png", isImage: true }];
|
||||
await renderVoiceComposer({ onSend, attachments });
|
||||
const textarea = container.querySelector("textarea");
|
||||
await act(async () => {
|
||||
textarea?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
await renderVoiceComposer({
|
||||
onSend,
|
||||
attachments,
|
||||
prompt: "What is this?",
|
||||
promptVersion: 1,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith("What is this?");
|
||||
});
|
||||
|
||||
it("allows a parent session with a running child agent to be stopped", async () => {
|
||||
const onAbort = vi.fn();
|
||||
await renderVoiceComposer({
|
||||
@@ -817,7 +893,7 @@ describe("ChatInputBar", () => {
|
||||
subscribeToProviderModelsMock.mock.calls[0]?.[0];
|
||||
await act(async () => {
|
||||
providerModelsListener?.("cline", [
|
||||
{ id: "refreshed-model", name: "Refreshed model" },
|
||||
{ id: "test-model", name: "Refreshed model" },
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
@@ -1462,6 +1538,85 @@ describe("ChatInputBar", () => {
|
||||
expect(optionLabels[2]).toContain("Other Model");
|
||||
});
|
||||
|
||||
it("opens model settings from the provider picker's set-up row", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline", "cline-pass"],
|
||||
providerModels: {
|
||||
cline: ["anthropic/claude-opus-5"],
|
||||
"cline-pass": ["anthropic/claude-opus-5"],
|
||||
},
|
||||
providerModelDetails: {},
|
||||
providerNames: { cline: "Cline", "cline-pass": "Cline Pass" },
|
||||
providerReasoningModels: {},
|
||||
});
|
||||
const onOpenModelSettings = vi.fn();
|
||||
const onProviderChange = vi.fn();
|
||||
|
||||
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()}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={onProviderChange}
|
||||
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();
|
||||
});
|
||||
const providerTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(providerTrigger?.textContent).toContain("Cline");
|
||||
});
|
||||
|
||||
await act(async () => providerTrigger?.click());
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
const options = [...(panel?.querySelectorAll('[role="option"]') ?? [])];
|
||||
// Both Cline entries list (one sign-in configures both); the set-up
|
||||
// row trails the real providers.
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"Cline",
|
||||
"Cline Pass",
|
||||
"Set up another provider",
|
||||
]);
|
||||
|
||||
await act(async () => (options[2] as HTMLButtonElement).click());
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
expect(onProviderChange).not.toHaveBeenCalled();
|
||||
// The row is an action, not a selection: the trigger still shows Cline.
|
||||
expect(providerTrigger?.textContent).toContain("Cline");
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
describe("cline-pass picker offer", () => {
|
||||
const renderComposer = async (props: {
|
||||
model: string;
|
||||
@@ -1551,6 +1706,185 @@ describe("ChatInputBar", () => {
|
||||
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
|
||||
});
|
||||
|
||||
const kimi: ProviderModel = {
|
||||
id: "cline-pass/kimi-k3",
|
||||
name: "Kimi K3",
|
||||
featured: { tier: "subscribed", rank: 0, tags: [] },
|
||||
};
|
||||
const flash: ProviderModel = {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
featured: { tier: "free", rank: 0, tags: [] },
|
||||
};
|
||||
|
||||
function mockBundledCatalog() {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline", "cline-pass"],
|
||||
providerModels: {
|
||||
cline: ["test-model"],
|
||||
"cline-pass": [flash.id],
|
||||
},
|
||||
providerModelDetails: { "cline-pass": [flash] },
|
||||
providerNames: { cline: "Cline", "cline-pass": "ClinePass" },
|
||||
providerReasoningModels: { cline: [], "cline-pass": [] },
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
"success",
|
||||
"failure",
|
||||
])("preserves the saved model through a delayed live catalog %s", async (outcome) => {
|
||||
mockBundledCatalog();
|
||||
const selection = {
|
||||
lastProvider: "cline-pass",
|
||||
lastModelByProvider: { "cline-pass": kimi.id },
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify(selection),
|
||||
);
|
||||
let resolveModels!: (models: ProviderModel[]) => void;
|
||||
let rejectModels!: (error: Error) => void;
|
||||
loadProviderModelsMock.mockReturnValue(
|
||||
new Promise<ProviderModel[]>((resolve, reject) => {
|
||||
resolveModels = resolve;
|
||||
rejectModels = reject;
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: kimi.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline-pass");
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(kimi.id);
|
||||
|
||||
await act(async () => {
|
||||
if (outcome === "success") resolveModels([flash, kimi]);
|
||||
else rejectModels(new Error("offline"));
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(outcome === "success" ? kimi.name : kimi.id);
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
),
|
||||
).toEqual(selection);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"catalog refresh",
|
||||
"new chat",
|
||||
])("preserves an explicit pick through a %s with an incomplete catalog", async (transition) => {
|
||||
mockBundledCatalog();
|
||||
loadProviderModelsMock.mockResolvedValue([flash, kimi]);
|
||||
let publishModels!: (providerId: string, models: ProviderModel[]) => void;
|
||||
subscribeToProviderModelsMock.mockImplementation((listener) => {
|
||||
publishModels = listener;
|
||||
return vi.fn();
|
||||
});
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: flash.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label^="Model:"]')
|
||||
?.click(),
|
||||
);
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
|
||||
].find((entry) => entry.textContent?.includes(kimi.name));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
|
||||
await renderComposer({
|
||||
model: kimi.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
onModelChange.mockClear();
|
||||
if (transition === "new chat") {
|
||||
// New chat remounts the pane and seeds its config from storage.
|
||||
// The app stays open, but this picker has to load live models again.
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
const initial = getInitialChatConfig();
|
||||
expect(initial).toMatchObject({
|
||||
provider: "cline-pass",
|
||||
model: kimi.id,
|
||||
});
|
||||
let resolveModels!: (models: ProviderModel[]) => void;
|
||||
loadProviderModelsMock.mockReturnValue(
|
||||
new Promise<ProviderModel[]>((resolve) => {
|
||||
resolveModels = resolve;
|
||||
}),
|
||||
);
|
||||
await renderComposer({
|
||||
model: initial.model,
|
||||
provider: initial.provider,
|
||||
onModelChange,
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(kimi.id);
|
||||
await act(async () => resolveModels([flash, kimi]));
|
||||
} else {
|
||||
await act(async () => publishModels("cline-pass", [flash]));
|
||||
}
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(transition === "new chat" ? kimi.name : kimi.id);
|
||||
});
|
||||
|
||||
it("restores a remembered live model when switching back before live models load", async () => {
|
||||
mockBundledCatalog();
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model", "cline-pass": kimi.id },
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
const onProviderChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "test-model",
|
||||
provider: "cline",
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label^="Provider:"]')
|
||||
?.click(),
|
||||
);
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
|
||||
].find((entry) => entry.textContent?.includes("ClinePass"));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
expect(onProviderChange).toHaveBeenCalledWith("cline-pass");
|
||||
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
|
||||
expect(onModelChange).not.toHaveBeenCalledWith(flash.id);
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
).lastModelByProvider["cline-pass"],
|
||||
).toBe(kimi.id);
|
||||
});
|
||||
|
||||
it("does not resurrect a stale remembered model the picker hides", async () => {
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
@@ -1582,6 +1916,25 @@ describe("ChatInputBar", () => {
|
||||
expect(panel?.textContent).not.toContain("Current model");
|
||||
});
|
||||
|
||||
it("does not apply another provider's remembered model to an empty selection", async () => {
|
||||
mockBundledCatalog();
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model" },
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "",
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
expect(onModelChange).toHaveBeenCalledWith(flash.id);
|
||||
expect(onModelChange).not.toHaveBeenCalledWith("test-model");
|
||||
});
|
||||
|
||||
it("keeps an explicitly active out-of-offer model visible and selectable", async () => {
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
@@ -1660,7 +2013,11 @@ describe("ChatInputBar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches clipboard images on paste instead of inserting text", async () => {
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
])("handles clipboard and file images with image support %s", async (supportsImages) => {
|
||||
const onAttachFiles = vi.fn();
|
||||
const onPromptInputChange = vi.fn();
|
||||
await act(async () => {
|
||||
@@ -1728,22 +2085,70 @@ describe("ChatInputBar", () => {
|
||||
return event;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
subscribeToProviderModelsMock.mock.calls.at(-1)?.[0]("cline", [
|
||||
{
|
||||
id: "test-model",
|
||||
name: "Test model",
|
||||
inputModalities:
|
||||
supportsImages === undefined
|
||||
? undefined
|
||||
: supportsImages
|
||||
? ["text", "image"]
|
||||
: ["text"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(container.querySelector('[aria-label="Attach images"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Attach files"]')
|
||||
?.disabled,
|
||||
).toBe(false);
|
||||
|
||||
const png = new File(["fake"], "image.png", { type: "image/png" });
|
||||
const imagePaste = await pasteWithClipboard([
|
||||
{ kind: "file", type: "image/png", getAsFile: () => png },
|
||||
]);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(1);
|
||||
const attached = onAttachFiles.mock.calls[0][0] as File[];
|
||||
expect(attached).toHaveLength(1);
|
||||
expect(attached[0].name).toMatch(/^pasted-image-.+\.png$/);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(
|
||||
supportsImages === false ? 0 : 1,
|
||||
);
|
||||
if (supportsImages !== false) {
|
||||
const attached = onAttachFiles.mock.calls[0][0] as File[];
|
||||
expect(attached).toHaveLength(1);
|
||||
expect(attached[0].name).toMatch(/^pasted-image-.+\.png$/);
|
||||
}
|
||||
expect(imagePaste.defaultPrevented).toBe(true);
|
||||
|
||||
// Plain-text pastes stay untouched so normal text pasting keeps working.
|
||||
const textPaste = await pasteWithClipboard([
|
||||
{ kind: "string", type: "text/plain", getAsFile: () => null },
|
||||
]);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(1);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(
|
||||
supportsImages === false ? 0 : 1,
|
||||
);
|
||||
expect(textPaste.defaultPrevented).toBe(false);
|
||||
|
||||
onAttachFiles.mockClear();
|
||||
const textFile = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
const imageWithoutMime = new File(["fake"], "photo.JFIF");
|
||||
const genericImage = new File(["fake"], "photo.jpe", {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
const fileInput = container.querySelector<HTMLInputElement>(
|
||||
'input[type="file"][accept="*/*"]',
|
||||
);
|
||||
if (!fileInput) throw new Error("File input missing");
|
||||
Object.defineProperty(fileInput, "files", {
|
||||
value: [png, textFile, imageWithoutMime, genericImage],
|
||||
});
|
||||
await act(async () => {
|
||||
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onAttachFiles).toHaveBeenCalledWith(
|
||||
supportsImages === false
|
||||
? [textFile]
|
||||
: [png, textFile, imageWithoutMime, genericImage],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,15 @@ import {
|
||||
formatDisplayUserInput,
|
||||
} from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
CircleStop,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
SpeechInput,
|
||||
@@ -36,6 +44,7 @@ import {
|
||||
buildModelPickerData,
|
||||
type ModelPickerData,
|
||||
} from "@/lib/featured-models";
|
||||
import { imageAttachmentMediaType } from "@/lib/image-attachments";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
@@ -54,6 +63,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
|
||||
import { PullRequestBar } from "./pull-request-bar";
|
||||
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
|
||||
|
||||
// Memoized: the workspace/branch selector fans out into popovers and lists
|
||||
@@ -311,6 +321,7 @@ type ChatInputBarProps = {
|
||||
) => Promise<void> | void;
|
||||
onRemovePromptInQueue: (promptId: string) => Promise<void> | void;
|
||||
onOpenVoiceInputSettings?: () => void;
|
||||
onOpenModelSettings?: () => void;
|
||||
summary: {
|
||||
toolCalls: number;
|
||||
tokensIn: number;
|
||||
@@ -348,6 +359,8 @@ function ChatInputBarImpl({
|
||||
onSteerPromptInQueue,
|
||||
onEditPromptInQueue,
|
||||
onRemovePromptInQueue,
|
||||
onOpenVoiceInputSettings,
|
||||
onOpenModelSettings,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -450,13 +463,67 @@ function ChatInputBarImpl({
|
||||
},
|
||||
[model, provider],
|
||||
);
|
||||
const [imageCapability, setImageCapability] = useState<{
|
||||
provider: string;
|
||||
model: string;
|
||||
supported: boolean | null;
|
||||
} | null>(null);
|
||||
const imagesUnsupported =
|
||||
imageCapability?.provider === provider &&
|
||||
imageCapability.model === model &&
|
||||
imageCapability.supported === false;
|
||||
const handleModelSupportsImagesChange = useCallback(
|
||||
(supported: boolean | null) => {
|
||||
setImageCapability({ provider, model, supported });
|
||||
},
|
||||
[provider, model],
|
||||
);
|
||||
const reportUnsupportedImages = useCallback(() => {
|
||||
toast({
|
||||
title: "This model doesn’t support image input",
|
||||
description:
|
||||
"Choose a model that supports images or remove the images before sending. Other files can still be attached.",
|
||||
});
|
||||
}, []);
|
||||
const handleAttachFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const allowed = imagesUnsupported
|
||||
? files.filter((file) => !imageAttachmentMediaType(file))
|
||||
: files;
|
||||
if (allowed.length !== files.length) reportUnsupportedImages();
|
||||
if (allowed.length > 0) onAttachFiles(allowed);
|
||||
},
|
||||
[imagesUnsupported, onAttachFiles, reportUnsupportedImages],
|
||||
);
|
||||
const unsupportedDraftImageCount = imagesUnsupported
|
||||
? attachments.filter((attachment) => attachment.isImage).length
|
||||
: 0;
|
||||
const canSend = hasDraft && !speechInputActive;
|
||||
const handleSend = useCallback(() => {
|
||||
if (speechInputActive) return;
|
||||
if (unsupportedDraftImageCount > 0) {
|
||||
reportUnsupportedImages();
|
||||
return;
|
||||
}
|
||||
const prompt = promptInput.trim();
|
||||
if (!prompt) {
|
||||
toast({
|
||||
title: "Add a message to go with your attachments",
|
||||
description:
|
||||
"Describe what you want Cline to do with the attached files before sending.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPromptInput("");
|
||||
onSend(prompt);
|
||||
}, [onSend, promptInput, setPromptInput, speechInputActive]);
|
||||
}, [
|
||||
onSend,
|
||||
promptInput,
|
||||
setPromptInput,
|
||||
speechInputActive,
|
||||
unsupportedDraftImageCount,
|
||||
reportUnsupportedImages,
|
||||
]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [transcriptionTarget, setTranscriptionTarget] =
|
||||
useState<TranscriptionModelTarget | null>(null);
|
||||
@@ -752,24 +819,38 @@ function ChatInputBarImpl({
|
||||
[transcriptionTarget],
|
||||
);
|
||||
|
||||
const handleSpeechInputError = useCallback((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Check microphone permission and audio provider settings.";
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-input",
|
||||
level: "error",
|
||||
message: "Speech input failed in the webview",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { failure: message },
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Speech input failed",
|
||||
description: message,
|
||||
});
|
||||
}, []);
|
||||
const handleSpeechInputError = useCallback(
|
||||
(error: unknown) => {
|
||||
// Microphone failures surface as DOMExceptions (getUserMedia) or
|
||||
// capture-layer events; provider failures (credentials, transcription
|
||||
// setup) as plain Errors, and are fixed in Settings → Voice.
|
||||
const isMicrophoneError =
|
||||
error instanceof DOMException || error instanceof Event;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Check microphone permission and audio provider settings.";
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-input",
|
||||
level: "error",
|
||||
message: "Speech input failed in the webview",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { failure: message },
|
||||
});
|
||||
if (!isMicrophoneError && onOpenVoiceInputSettings) {
|
||||
onOpenVoiceInputSettings();
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Speech input failed",
|
||||
description: isMicrophoneError
|
||||
? "Check the microphone permission for Cline and try again."
|
||||
: message,
|
||||
});
|
||||
},
|
||||
[onOpenVoiceInputSettings],
|
||||
);
|
||||
|
||||
const effortIndex = useMemo(
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
@@ -1018,6 +1099,7 @@ function ChatInputBarImpl({
|
||||
)}
|
||||
>
|
||||
{/* Input area */}
|
||||
<PullRequestBar cwd={workspaceRoot} branch={gitBranch} />
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-3",
|
||||
@@ -1187,7 +1269,7 @@ function ChatInputBarImpl({
|
||||
// Attach the image instead of pasting its fallback
|
||||
// text representation (e.g. a file path or URL).
|
||||
e.preventDefault();
|
||||
onAttachFiles(images);
|
||||
handleAttachFiles(images);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1354,6 +1436,12 @@ function ChatInputBarImpl({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{unsupportedDraftImageCount > 0 && (
|
||||
<output className="block px-2 text-sm text-destructive">
|
||||
This model doesn’t support the attached images. Remove them or
|
||||
choose a model that supports images before sending.
|
||||
</output>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
@@ -1381,6 +1469,11 @@ function ChatInputBarImpl({
|
||||
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
|
||||
<button
|
||||
aria-label="Attach files"
|
||||
title={
|
||||
imagesUnsupported
|
||||
? "Attach files (this model doesn’t support images)"
|
||||
: "Attach files"
|
||||
}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-surface-hover"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
@@ -1393,7 +1486,7 @@ function ChatInputBarImpl({
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length > 0) onAttachFiles(files);
|
||||
if (files.length > 0) handleAttachFiles(files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
@@ -1436,9 +1529,11 @@ function ChatInputBarImpl({
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsImagesChange={handleModelSupportsImagesChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
/>
|
||||
@@ -1513,6 +1608,9 @@ export const ChatInputBar = memo(ChatInputBarImpl);
|
||||
|
||||
// Memoized: the selectors load/hold the full provider-model catalog, so they
|
||||
// should not re-render for every keystroke in the composer textarea.
|
||||
/** Sentinel provider-picker row that opens Settings → Models instead of selecting. */
|
||||
const ADD_PROVIDER_OPTION_VALUE = "__add-provider__";
|
||||
|
||||
const ModelSelector = memo(function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
@@ -1520,6 +1618,8 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
onModelSupportsImagesChange,
|
||||
onOpenModelSettings,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -1527,6 +1627,9 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
onModelSupportsImagesChange: (supported: boolean | null) => void;
|
||||
/** Opens Settings → Models; adds a "set up another provider" row when set. */
|
||||
onOpenModelSettings?: () => void;
|
||||
}) {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
@@ -1594,6 +1697,17 @@ const ModelSelector = memo(function ModelSelector({
|
||||
},
|
||||
[modelDetails, visibleProviderModels],
|
||||
);
|
||||
useEffect(() => {
|
||||
const selected = modelDetails[normalizedProvider]?.find(
|
||||
(entry) => entry.id === model,
|
||||
);
|
||||
onModelSupportsImagesChange(
|
||||
selected?.inputModalities !== undefined
|
||||
? selected.inputModalities.includes("image")
|
||||
: (selected?.supportsVision ?? null),
|
||||
);
|
||||
}, [modelDetails, normalizedProvider, model, onModelSupportsImagesChange]);
|
||||
|
||||
const modelPicker = useMemo(
|
||||
() => pickerDataForProvider(resolvedProvider),
|
||||
[pickerDataForProvider, resolvedProvider],
|
||||
@@ -1603,21 +1717,29 @@ const ModelSelector = memo(function ModelSelector({
|
||||
[modelPicker],
|
||||
);
|
||||
const resolvedModel = useMemo(() => {
|
||||
if (modelsForProvider.length === 0) {
|
||||
return "";
|
||||
}
|
||||
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)) {
|
||||
(normalizeProviderId(rememberedLastProvider) === resolvedProvider
|
||||
? lastSelection.lastModelByProvider[rememberedLastProvider]
|
||||
: undefined);
|
||||
// Catalogs are discovery data, not validation: the bundled catalog can
|
||||
// omit live ClinePass models, and refreshes can return partial lists.
|
||||
// Keep the configured model for the current provider even if absent;
|
||||
// otherwise loading the catalog silently changes the session's model.
|
||||
if (
|
||||
model &&
|
||||
(normalizedProvider === resolvedProvider ||
|
||||
modelsForProvider.includes(model))
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
if (rememberedModel && pickerModelIds.has(rememberedModel)) {
|
||||
// Missing remembered models may also be live-only. Models present in
|
||||
// the catalog but deliberately hidden from the offer still fall back.
|
||||
if (
|
||||
rememberedModel &&
|
||||
(pickerModelIds.has(rememberedModel) ||
|
||||
!modelsForProvider.includes(rememberedModel))
|
||||
) {
|
||||
return rememberedModel;
|
||||
}
|
||||
return (
|
||||
@@ -1629,6 +1751,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
modelsForProvider,
|
||||
normalizedProvider,
|
||||
pickerModelIds,
|
||||
rememberedLastProvider,
|
||||
resolvedProvider,
|
||||
@@ -1636,8 +1759,8 @@ const ModelSelector = memo(function ModelSelector({
|
||||
// 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.
|
||||
// model outside the current offer or missing from the catalog. Surface it
|
||||
// under its own section so the selected value always exists in the list.
|
||||
const visibleModelPicker = useMemo((): ModelPickerData => {
|
||||
if (!resolvedModel || pickerModelIds.has(resolvedModel)) {
|
||||
return modelPicker;
|
||||
@@ -1862,17 +1985,23 @@ const ModelSelector = memo(function ModelSelector({
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === ADD_PROVIDER_OPTION_VALUE) {
|
||||
setMobileOpen(false);
|
||||
onOpenModelSettings?.();
|
||||
return;
|
||||
}
|
||||
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.
|
||||
// Preserve live-only remembered models missing from the bundled
|
||||
// catalog. Only fall back when a known model is hidden by the offer.
|
||||
const providerOptionIds = new Set(
|
||||
pickerDataForProvider(value).options.map((option) => option.value),
|
||||
);
|
||||
const nextModel =
|
||||
rememberedModel && providerOptionIds.has(rememberedModel)
|
||||
rememberedModel &&
|
||||
(providerOptionIds.has(rememberedModel) ||
|
||||
!providerModelIds.includes(rememberedModel))
|
||||
? rememberedModel
|
||||
: (providerModelIds.find((id) => providerOptionIds.has(id)) ??
|
||||
providerModelIds[0]);
|
||||
@@ -1885,6 +2014,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
onModelChange,
|
||||
onOpenModelSettings,
|
||||
onProviderChange,
|
||||
pickerDataForProvider,
|
||||
rememberSelection,
|
||||
@@ -1898,13 +2028,25 @@ const ModelSelector = memo(function ModelSelector({
|
||||
},
|
||||
[onModelChange, rememberSelection, resolvedProvider],
|
||||
);
|
||||
// The picker only lists providers with saved settings, so it is also the
|
||||
// natural place to reach the rest of the catalog.
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
providers.map((value) => ({
|
||||
() => [
|
||||
...providers.map((value) => ({
|
||||
label: providerNames[value]?.trim() || value,
|
||||
value,
|
||||
})),
|
||||
[providerNames, providers],
|
||||
...(onOpenModelSettings
|
||||
? [
|
||||
{
|
||||
icon: <Plus className="size-3 shrink-0 text-muted-foreground" />,
|
||||
label: "Set up another provider",
|
||||
value: ADD_PROVIDER_OPTION_VALUE,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[onOpenModelSettings, providerNames, providers],
|
||||
);
|
||||
const selectedModelLabel =
|
||||
visibleModelPicker.options.find((option) => option.value === resolvedModel)
|
||||
@@ -1930,7 +2072,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
<SearchCombobox
|
||||
ariaLabel="Model"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
disabled={isBusy || visibleModelPicker.options.length === 0}
|
||||
emptyText="No models found."
|
||||
onValueChange={(value) => {
|
||||
handleModelSelect(value);
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("ChatMessages tool disclosures", () => {
|
||||
}),
|
||||
createdAt: 1,
|
||||
};
|
||||
await renderMessages([pendingTool]);
|
||||
await renderMessages([pendingTool], { status: "running" });
|
||||
|
||||
const pendingTitle = container.querySelector(
|
||||
".cline-chat-tool-label > span",
|
||||
@@ -162,6 +162,93 @@ describe("ChatMessages tool disclosures", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"cancelled",
|
||||
"failed",
|
||||
"completed",
|
||||
"idle",
|
||||
] as const)("stops animating missing tool results when the run is %s, including on reopen", async (status) => {
|
||||
const tool: ChatMessage = {
|
||||
id: "unfinished",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
createdAt: 1,
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: null,
|
||||
}),
|
||||
meta: { toolName: "read_files", hookEventName: "tool_call_start" },
|
||||
};
|
||||
const snapshot = JSON.stringify(tool);
|
||||
await renderMessages([tool], { status: "running" });
|
||||
expect(
|
||||
container.querySelector(".cline-chat-streaming-title"),
|
||||
).not.toBeNull();
|
||||
await renderMessages([tool], { status });
|
||||
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
expect(JSON.stringify(tool)).toBe(snapshot);
|
||||
await renderMessages(
|
||||
[{ ...tool, meta: { ...tool.meta, hookEventName: "history_tool_use" } }],
|
||||
{ status },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
// A later turn must not reactivate the old unfinished tool.
|
||||
await renderMessages(
|
||||
[
|
||||
tool,
|
||||
{
|
||||
id: "next-turn",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Continue",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps late results renderable after an inactive status", async () => {
|
||||
const tool: ChatMessage = {
|
||||
id: "late-result",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
createdAt: 1,
|
||||
content: JSON.stringify({
|
||||
toolName: "custom_tool",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: null,
|
||||
}),
|
||||
meta: { toolName: "custom_tool", hookEventName: "history_tool_use" },
|
||||
};
|
||||
await renderMessages([tool], { status: "completed" });
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
await renderMessages([tool], { status: "running" });
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).not.toBeNull();
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
...tool,
|
||||
content: JSON.stringify({
|
||||
toolName: "custom_tool",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: "Actual late result",
|
||||
}),
|
||||
meta: { ...tool.meta, hookEventName: "tool_call_end" },
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
const trigger = container.querySelector("button.cline-chat-tool-trigger");
|
||||
await act(async () => (trigger as HTMLButtonElement).click());
|
||||
expect(container.textContent).toContain("Actual late result");
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
@@ -331,6 +418,61 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(container.textContent).not.toContain("Scheduled task completed");
|
||||
});
|
||||
|
||||
it("keeps the scheduled-task report visible when the run collapses", async () => {
|
||||
// A follow-up prompt settles the scheduled run's span and folds its
|
||||
// working rows into the work summary; the submit_and_exit row is the
|
||||
// run's final report and must stay visible below it.
|
||||
const summary = "All feeds healthy.";
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-schedule",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Check the feeds",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "tool-read",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["feeds.json"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "tool-submit",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary, verified: true },
|
||||
result: summary,
|
||||
}),
|
||||
createdAt: 3_000,
|
||||
},
|
||||
{
|
||||
id: "user-followup",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Thanks!",
|
||||
createdAt: 9_000,
|
||||
},
|
||||
]);
|
||||
|
||||
// The working rows folded into a collapsed work summary…
|
||||
const workTrigger = container.querySelector(
|
||||
"button.cline-chat-work-trigger",
|
||||
);
|
||||
expect(workTrigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
// …but the report row did not fold with them: it stays visible and
|
||||
// expanded outside the summary.
|
||||
expect(container.textContent).toContain("Scheduled task completed");
|
||||
expect(container.textContent).toContain(summary);
|
||||
});
|
||||
|
||||
it("renders consecutive tool calls as individual rows", async () => {
|
||||
const tools: ChatMessage[] = [
|
||||
{
|
||||
@@ -2062,4 +2204,25 @@ describe("ChatMessages tool approvals", () => {
|
||||
await act(async () => reject?.click());
|
||||
expect(onReject).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
|
||||
it("leads an imported transcript with a notice naming the source tool", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "imported prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
await renderMessages(messages, { importedFromTool: "claude-code" });
|
||||
|
||||
const notice = container.querySelector("output");
|
||||
expect(notice?.textContent).toContain("Imported from Claude Code");
|
||||
expect(notice?.parentElement?.firstElementChild).toBe(notice);
|
||||
expect(notice?.parentElement?.textContent).toContain("imported prompt");
|
||||
|
||||
await renderMessages(messages);
|
||||
expect(container.querySelector("output")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,9 @@ import type {
|
||||
ChatMessageImage,
|
||||
ChatSessionStatus,
|
||||
} from "@/lib/chat-schema";
|
||||
import type { SessionImportTool } from "@/lib/session-import";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImportedSessionNotice } from "./imported-session-notice";
|
||||
import { STREAMING_TITLE_CLASS } from "./messages/constants";
|
||||
import {
|
||||
buildPreviousTimestampMap,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
collapseCompletedWork,
|
||||
getThoughtDurationMilliseconds,
|
||||
groupChatMessages,
|
||||
isSystemSteeringMessage,
|
||||
} from "./messages/group-messages";
|
||||
import { ChatImageLightbox } from "./messages/image-lightbox";
|
||||
import { MessageBubble } from "./messages/message-bubble";
|
||||
@@ -57,6 +60,10 @@ type ChatMessagesProps = {
|
||||
isSessionSwitching?: boolean;
|
||||
messages: ChatMessage[];
|
||||
error: string | null;
|
||||
/** Set when the session's history was imported from another coding agent. */
|
||||
importedFromTool?: SessionImportTool;
|
||||
/** Replaces "Thinking..." while the runtime reports a named pre-output step. */
|
||||
activityLabel?: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
pendingToolApprovals: ToolApprovalRequestItem[];
|
||||
pendingAskQuestions: AskQuestionRequestItem[];
|
||||
@@ -99,6 +106,8 @@ function ChatMessagesImpl({
|
||||
isSessionSwitching = false,
|
||||
messages,
|
||||
error,
|
||||
importedFromTool,
|
||||
activityLabel = null,
|
||||
streamingMessageId = null,
|
||||
pendingToolApprovals,
|
||||
pendingAskQuestions,
|
||||
@@ -205,6 +214,14 @@ function ChatMessagesImpl({
|
||||
}),
|
||||
[messages, collapseTrailingRun],
|
||||
);
|
||||
const isRunActive =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const lastUserItemIndex = renderItems.findLastIndex(
|
||||
(item) =>
|
||||
item.type === "message" &&
|
||||
item.message.role === "user" &&
|
||||
!isSystemSteeringMessage(item.message),
|
||||
);
|
||||
// Mid-run the thinking indicator's replacement (the next tool or thinking
|
||||
// row) joins the tight run group, so the indicator must sit at that same
|
||||
// tight offset; only at the start of a run, directly under the user
|
||||
@@ -532,6 +549,9 @@ function ChatMessagesImpl({
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-4">
|
||||
{importedFromTool ? (
|
||||
<ImportedSessionNotice tool={importedFromTool} />
|
||||
) : null}
|
||||
{renderItems.map((item, itemIndex) => {
|
||||
// Working rows — live (`run`) or folded (`work`) — render
|
||||
// through one child renderer so a row keeps its exact look
|
||||
@@ -543,6 +563,9 @@ function ChatMessagesImpl({
|
||||
if (child.type === "tools") {
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
isRunActive={
|
||||
isRunActive && itemIndex > lastUserItemIndex
|
||||
}
|
||||
key={`tools_${child.messages[0]?.id ?? "empty"}`}
|
||||
messages={child.messages}
|
||||
onExpandImage={handleExpandImage}
|
||||
@@ -676,7 +699,9 @@ function ChatMessagesImpl({
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
<span className={STREAMING_TITLE_CLASS}>
|
||||
{activityLabel ?? "Thinking..."}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Import } from "lucide-react";
|
||||
import {
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
type SessionImportTool,
|
||||
} from "@/lib/session-import";
|
||||
|
||||
/**
|
||||
* Heads a transcript imported from another coding agent. Its turns keep that
|
||||
* agent's tool names and schemas, which Cline does not translate; without the
|
||||
* notice the session looks native and the user has no way to know why
|
||||
* continuing it may go differently.
|
||||
*/
|
||||
export function ImportedSessionNotice({ tool }: { tool: SessionImportTool }) {
|
||||
const label = SESSION_IMPORT_TOOL_LABELS[tool];
|
||||
return (
|
||||
<output className="flex items-start gap-3 rounded-xl border border-amber-400/40 bg-amber-500/5 px-4 py-3">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-500">
|
||||
<Import className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Imported from {label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
The earlier turns were recorded by {label}, whose tools and workflow
|
||||
differ from Cline's. When you continue, the model works from a
|
||||
summary of them rather than the original tool calls, so results may
|
||||
not be as reliable as in a session started with Cline.
|
||||
</p>
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
}
|
||||
@@ -420,6 +420,224 @@ describe("collapseCompletedWork", () => {
|
||||
expect(work.durationMilliseconds).toBe(4_000);
|
||||
});
|
||||
|
||||
function makeSubmitTool(id: string, createdAt: number): ChatMessage {
|
||||
return makeMessage({
|
||||
id,
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary: "Report ready." },
|
||||
result: "Report ready.",
|
||||
}),
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a trailing submit_and_exit row visible as the collapsed run's answer", () => {
|
||||
// Scheduled runs end on submit_and_exit — its row carries the final
|
||||
// report, so it must not fold into the work summary.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 5_000),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(1);
|
||||
expect(work.durationMilliseconds).toBe(4_000);
|
||||
const submit = items[2];
|
||||
if (submit?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
|
||||
});
|
||||
|
||||
it("keeps the submit_and_exit row visible once a later user message exists", () => {
|
||||
// A follow-up prompt in a finished scheduled session settles the run's
|
||||
// span; the report row must survive the collapse instead of hiding
|
||||
// inside the work summary.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 5_000),
|
||||
makeMessage({
|
||||
id: "u2",
|
||||
role: "user",
|
||||
content: "thanks, one more thing",
|
||||
createdAt: 9_000,
|
||||
}),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
"message",
|
||||
]);
|
||||
const submit = items[2];
|
||||
if (submit?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
|
||||
});
|
||||
|
||||
it("keeps a live run's trailing submit_and_exit with its working rows", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeMessage({
|
||||
id: "r1",
|
||||
reasoning: "wrapping up",
|
||||
createdAt: 1_500,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 3_000),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual(["message", "run"]);
|
||||
const run = items[1];
|
||||
if (run?.type !== "run") throw new Error("expected run item");
|
||||
const tools = run.items.at(-1);
|
||||
if (tools?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(tools.messages.map((message) => message.id)).toEqual([
|
||||
"t1",
|
||||
"submit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a mid-run submit_and_exit as ordinary work", () => {
|
||||
// Only a submit the run actually ended on is its deliverable; one
|
||||
// followed by more work folds with everything else.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeSubmitTool("submit", 2_000),
|
||||
makeTool("t1", 3_000),
|
||||
makeMessage({ id: "a1", content: "Done.", createdAt: 5_000 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"message",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it("prefers trailing assistant text over an earlier submit as the answer", () => {
|
||||
// When the model narrates after submitting, the narration is the
|
||||
// answer and the run folds exactly as it did before the submit
|
||||
// special-case existed.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 3_000),
|
||||
makeMessage({ id: "a1", content: "All wrapped up.", createdAt: 4_000 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"message",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(2);
|
||||
const answer = items[2];
|
||||
if (answer?.type !== "message") throw new Error("expected message item");
|
||||
expect(answer.message.id).toBe("a1");
|
||||
});
|
||||
|
||||
it("detects submit_and_exit from message meta when the content is not JSON", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeMessage({
|
||||
id: "submit-meta",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
meta: { toolName: "submit_and_exit" },
|
||||
createdAt: 3_000,
|
||||
}),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat other trailing tool calls as the run's answer", () => {
|
||||
// A finished tail ending on an ordinary tool call still reads as an
|
||||
// interrupted run: rows stay visible, nothing collapses.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeTool("t2", 3_000),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual(["message", "tools"]);
|
||||
});
|
||||
|
||||
it("measures duration from the first working row when no user message precedes it", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentMessageRole } from "@cline/ui/components/agent-chat";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import { parseToolPayload } from "./tool-summaries";
|
||||
|
||||
export type ChatRenderItem =
|
||||
| {
|
||||
@@ -174,6 +175,18 @@ function maxFiniteTimestamp(
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `submit_and_exit` call carries the run's final report (scheduled tasks
|
||||
* end with it), so a run that ends on one treats that row as its deliverable
|
||||
* — it must stay visible when the working rows fold into a work summary.
|
||||
*/
|
||||
function isSubmitAndExitMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "tool") return false;
|
||||
const toolName =
|
||||
message.meta?.toolName || parseToolPayload(message.content)?.toolName;
|
||||
return toolName?.toLowerCase() === "submit_and_exit";
|
||||
}
|
||||
|
||||
function firstMessageId(item: ChatRenderItem): string | undefined {
|
||||
if (item.type === "tools") return item.messages[0]?.id;
|
||||
if (item.type === "message") {
|
||||
@@ -185,7 +198,8 @@ function firstMessageId(item: ChatRenderItem): string | undefined {
|
||||
/**
|
||||
* Folds each finished run's working rows (tool calls, thinking traces,
|
||||
* intermediate narration) into a single expandable `work` item, keeping the
|
||||
* run's final answer — the assistant text the run ended on — visible after it.
|
||||
* run's final answer — the assistant text or submit_and_exit report the run
|
||||
* ended on — visible after it.
|
||||
* Working rows that stay visible (live stream, tool-less runs, tails that
|
||||
* never produced an answer) are grouped into a `run` item instead, so they
|
||||
* share one tight rhythm and hold their position when the collapse happens.
|
||||
@@ -215,15 +229,33 @@ export function collapseCompletedWork(
|
||||
|
||||
const flushSpan = (nextIndex: number) => {
|
||||
if (span.length === 0) return;
|
||||
// "Done" means assistant text not followed by more tool calls: that
|
||||
// message is the run's answer and stays visible below the summary.
|
||||
// "Done" means the run ended on its deliverable: assistant text not
|
||||
// followed by more tool calls, or a submit_and_exit call carrying the
|
||||
// run's final report. That item is the run's answer and stays visible
|
||||
// below the summary.
|
||||
const last = span.at(-1);
|
||||
const answer =
|
||||
let answer: ChatRenderItem | undefined;
|
||||
let workRows = span;
|
||||
if (
|
||||
last?.type === "message" &&
|
||||
last.message.role === "assistant" &&
|
||||
last.message.content.trim()
|
||||
? last
|
||||
: undefined;
|
||||
) {
|
||||
answer = last;
|
||||
workRows = span.slice(0, -1);
|
||||
} else if (last?.type === "tools") {
|
||||
const lastToolMessage = last.messages.at(-1);
|
||||
if (lastToolMessage && isSubmitAndExitMessage(lastToolMessage)) {
|
||||
answer = { type: "tools", messages: [lastToolMessage] };
|
||||
workRows =
|
||||
last.messages.length > 1
|
||||
? [
|
||||
...span.slice(0, -1),
|
||||
{ type: "tools", messages: last.messages.slice(0, -1) },
|
||||
]
|
||||
: span.slice(0, -1);
|
||||
}
|
||||
}
|
||||
// A span is settled once a later user message exists. The trailing span
|
||||
// settles only when the session stopped running AND the run actually
|
||||
// ended on an answer — a cancelled or failed tail keeps its rows
|
||||
@@ -231,7 +263,7 @@ export function collapseCompletedWork(
|
||||
const complete =
|
||||
nextIndex <= lastUserIndex ||
|
||||
(collapseTrailingRun && answer !== undefined);
|
||||
const collapsed = complete && answer ? span.slice(0, -1) : span;
|
||||
const collapsed = complete && answer ? workRows : span;
|
||||
const toolCallCount = collapsed.reduce(
|
||||
(count, item) =>
|
||||
item.type === "tools" ? count + item.messages.length : count,
|
||||
@@ -242,8 +274,11 @@ export function collapseCompletedWork(
|
||||
// Not collapsed: group the working rows (everything but a trailing
|
||||
// answer-looking message) so they render with the tight in-run
|
||||
// rhythm instead of full transcript spacing. Pure prose spans have
|
||||
// no tool work to group and keep normal spacing.
|
||||
const body = answer ? span.slice(0, -1) : span;
|
||||
// no tool work to group and keep normal spacing. A trailing submit
|
||||
// row stays inside the group here — it only pops out once the run
|
||||
// actually collapses.
|
||||
const messageAnswer = answer?.type === "message" ? answer : undefined;
|
||||
const body = messageAnswer ? span.slice(0, -1) : span;
|
||||
const firstBody = body[0];
|
||||
const hasToolWork = body.some((item) => item.type === "tools");
|
||||
if (body.length >= 2 && hasToolWork && firstBody !== undefined) {
|
||||
@@ -252,8 +287,8 @@ export function collapseCompletedWork(
|
||||
id: firstMessageId(firstBody) ?? "run",
|
||||
items: body,
|
||||
});
|
||||
if (answer) {
|
||||
out.push(answer);
|
||||
if (messageAnswer) {
|
||||
out.push(messageAnswer);
|
||||
}
|
||||
} else {
|
||||
out.push(...span);
|
||||
|
||||
@@ -61,15 +61,20 @@ function ToolLabel({
|
||||
|
||||
const ToolCallRow = memo(function ToolCallRow({
|
||||
message,
|
||||
isRunActive,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isRunActive: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
const { payload, toolName, inProgress, summary } =
|
||||
buildToolPresentation(message);
|
||||
// A missing result can outlive its run. Only gate the running display;
|
||||
// keep the message intact so a later result can still replace it.
|
||||
const isRunning = inProgress && isRunActive;
|
||||
const isCommand = summary.kind === "command";
|
||||
// submit_and_exit carries the run's final answer (scheduled tasks end with
|
||||
// it), so surface it expanded and rendered as markdown rather than leaving
|
||||
@@ -103,7 +108,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
const toolSessionId = message.sessionId;
|
||||
const toolCallId = message.meta?.toolCallId;
|
||||
const canProceed = Boolean(
|
||||
inProgress &&
|
||||
isRunning &&
|
||||
isCommand &&
|
||||
message.meta?.toolDetachable === true &&
|
||||
toolSessionId &&
|
||||
@@ -216,9 +221,9 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={<ToolLabel isRunning={inProgress} parts={labelParts} />}
|
||||
label={<ToolLabel isRunning={isRunning} parts={labelParts} />}
|
||||
showDisclosureIcon={false}
|
||||
status={hasError ? "error" : inProgress ? "running" : "success"}
|
||||
status={hasError ? "error" : isRunning ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent presentation="rail">
|
||||
{details.length > 0 ? (
|
||||
@@ -253,10 +258,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
),
|
||||
)}
|
||||
{commandOutput ? (
|
||||
<CommandOutputTerminal
|
||||
isRunning={inProgress}
|
||||
output={commandOutput}
|
||||
/>
|
||||
<CommandOutputTerminal isRunning={isRunning} output={commandOutput} />
|
||||
) : submitText ? (
|
||||
// The summary is the run's final answer: full foreground color,
|
||||
// not the panel's muted tool-detail gray.
|
||||
@@ -391,10 +393,12 @@ function CommandOutputTerminal({
|
||||
export const ToolMessageBlock = memo(
|
||||
function ToolMessageBlock({
|
||||
messages,
|
||||
isRunActive,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isRunActive: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
@@ -403,6 +407,7 @@ export const ToolMessageBlock = memo(
|
||||
<div className="flex flex-col gap-1">
|
||||
{messages.map((message) => (
|
||||
<ToolCallRow
|
||||
isRunActive={isRunActive}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onExpandImage={onExpandImage}
|
||||
@@ -413,6 +418,7 @@ export const ToolMessageBlock = memo(
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isRunActive === next.isRunActive &&
|
||||
prev.messages.length === next.messages.length &&
|
||||
prev.messages.every((message, index) => message === next.messages[index]) &&
|
||||
prev.onExpandImage === next.onExpandImage &&
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { PullRequestStatus } from "@/lib/pull-request";
|
||||
import { PullRequestBar } from "./pull-request-bar";
|
||||
|
||||
const { invoke, openExternalUrl } = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl,
|
||||
}));
|
||||
const data: PullRequestStatus = {
|
||||
repository: "cline/cline",
|
||||
branch: "feature",
|
||||
createUrl: "https://github.com/cline/cline/compare/main...feature?expand=1",
|
||||
pullRequest: {
|
||||
number: 42,
|
||||
title: "Feature",
|
||||
url: "https://github.com/cline/cline/pull/42",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
mergeable: "CONFLICTING",
|
||||
mergeStateStatus: "DIRTY",
|
||||
additions: 1234,
|
||||
deletions: 12,
|
||||
checks: [
|
||||
{
|
||||
name: "Tests",
|
||||
state: "failure",
|
||||
url: "https://github.com/cline/cline/actions/runs/1",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
vi.useFakeTimers();
|
||||
invoke.mockReset().mockResolvedValue(data);
|
||||
openExternalUrl.mockReset().mockResolvedValue(undefined);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
async function render(cwd = "/repo", branch = "feature") {
|
||||
await act(async () =>
|
||||
root.render(<PullRequestBar cwd={cwd} branch={branch} />),
|
||||
);
|
||||
}
|
||||
async function click(label: string) {
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
|
||||
.click(),
|
||||
);
|
||||
}
|
||||
|
||||
it("opens the PR, shows conflicts and expands CI details", async () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain("Conflicts");
|
||||
expect(container.textContent).toContain("+1,234");
|
||||
expect(container.textContent).toContain("CI failed");
|
||||
await click("Open pull request #42: Feature");
|
||||
expect(openExternalUrl).toHaveBeenCalledWith(data.pullRequest!.url);
|
||||
await click("CI failed");
|
||||
expect(document.body.textContent).toContain("Tests");
|
||||
});
|
||||
|
||||
it.each<{
|
||||
status: Partial<NonNullable<PullRequestStatus["pullRequest"]>>;
|
||||
label: string;
|
||||
color: string;
|
||||
}>([
|
||||
{
|
||||
status: { mergeStateStatus: "BLOCKED" },
|
||||
label: "Blocked",
|
||||
color: "text-yellow-500",
|
||||
},
|
||||
{
|
||||
status: { mergeStateStatus: "BEHIND" },
|
||||
label: "Behind base",
|
||||
color: "text-yellow-500",
|
||||
},
|
||||
{
|
||||
status: { mergeStateStatus: "UNSTABLE" },
|
||||
label: "Checks failing",
|
||||
color: "text-red-400",
|
||||
},
|
||||
{
|
||||
status: { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" },
|
||||
label: "Merge status pending",
|
||||
color: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
status: { mergeable: "UNKNOWN" },
|
||||
label: "Merge status pending",
|
||||
color: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
status: { mergeStateStatus: "UNKNOWN" },
|
||||
label: "No conflicts",
|
||||
color: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
status: { mergeStateStatus: "DIRTY" },
|
||||
label: "Conflicts",
|
||||
color: "text-red-400",
|
||||
},
|
||||
{
|
||||
status: { mergeable: "CONFLICTING" },
|
||||
label: "Conflicts",
|
||||
color: "text-red-400",
|
||||
},
|
||||
{
|
||||
status: { isDraft: true, mergeable: "CONFLICTING" },
|
||||
label: "Draft",
|
||||
color: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
status: { state: "MERGED", mergeable: "CONFLICTING" },
|
||||
label: "Merged",
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{ status: { state: "CLOSED" }, label: "Closed", color: "text-red-400" },
|
||||
{ status: {}, label: "Ready to merge", color: "text-green-500" },
|
||||
])("uses $color for the $label label and PR icon", async ({
|
||||
status,
|
||||
label,
|
||||
color,
|
||||
}) => {
|
||||
invoke.mockResolvedValue({
|
||||
...data,
|
||||
pullRequest: {
|
||||
...data.pullRequest,
|
||||
mergeable: "MERGEABLE",
|
||||
mergeStateStatus: "CLEAN",
|
||||
...status,
|
||||
},
|
||||
});
|
||||
await render();
|
||||
const statusLabel = [...container.querySelectorAll("span")].find(
|
||||
(element) => element.textContent === label,
|
||||
);
|
||||
expect(statusLabel).toBeDefined();
|
||||
expect(statusLabel?.classList.contains(color)).toBe(true);
|
||||
expect(container.querySelector("svg")?.classList.contains(color)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers the compare form when no PR exists", async () => {
|
||||
invoke.mockResolvedValue({ ...data, pullRequest: null });
|
||||
await render();
|
||||
await act(async () =>
|
||||
[...container.querySelectorAll("button")]
|
||||
.find((button) => button.textContent?.includes("Create PR"))!
|
||||
.click(),
|
||||
);
|
||||
expect(openExternalUrl).toHaveBeenCalledWith(data.createUrl);
|
||||
});
|
||||
it("discards late responses after switching workspaces", async () => {
|
||||
let finish!: (value: PullRequestStatus) => void;
|
||||
invoke
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(null);
|
||||
await render();
|
||||
await render("/other");
|
||||
await act(async () => finish(data));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
it("refreshes and replaces stale status with an actionable error on failure", async () => {
|
||||
await render();
|
||||
invoke.mockRejectedValue(new Error("Check GitHub CLI access"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(container.textContent).toContain("Check GitHub CLI access");
|
||||
expect(container.textContent).not.toContain("#42");
|
||||
invoke.mockResolvedValue(data);
|
||||
await click("Refresh pull request status");
|
||||
expect(container.textContent).toContain("#42");
|
||||
});
|
||||
|
||||
it("silently hides initial lookup failures and can recover on a later refresh", async () => {
|
||||
invoke.mockRejectedValue(new Error("GitHub unavailable"));
|
||||
await render();
|
||||
expect(container.textContent).toBe("");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(container.textContent).toBe("");
|
||||
invoke.mockResolvedValue(data);
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toContain("#42");
|
||||
});
|
||||
|
||||
it("keeps a dismissed error hidden through polling and focus until recovery", async () => {
|
||||
await render();
|
||||
invoke.mockRejectedValue(new Error("Connection failed"));
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toContain("Connection failed");
|
||||
await click("Dismiss pull request error");
|
||||
expect(container.textContent).toBe("");
|
||||
await clickRefreshViaFocus();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(container.textContent).toBe("");
|
||||
invoke.mockResolvedValue(data);
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toContain("#42");
|
||||
invoke.mockRejectedValue(new Error("New connection failure"));
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toContain("New connection failure");
|
||||
});
|
||||
|
||||
it("hides a formerly working row when GitHub becomes unavailable", async () => {
|
||||
await render();
|
||||
invoke.mockResolvedValue(null);
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toBe("");
|
||||
invoke.mockRejectedValue(new Error("Connection failed"));
|
||||
await clickRefreshViaFocus();
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
it("does not fetch for a non-repository", async () => {
|
||||
await render("/repo", "no-git");
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function telemetryEvents() {
|
||||
return invoke.mock.calls
|
||||
.filter(([command]) => command === "capture_pull_request_event")
|
||||
.map(([, event]) => event);
|
||||
}
|
||||
|
||||
it("reports exposure once per workspace/branch, without polling impressions", async () => {
|
||||
await render();
|
||||
expect(telemetryEvents()).toEqual([
|
||||
{
|
||||
action: "shown",
|
||||
prState: "open",
|
||||
ciState: "failure",
|
||||
mergeTone: "failure",
|
||||
},
|
||||
]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
expect(telemetryEvents()).toHaveLength(1);
|
||||
await render("/other");
|
||||
expect(telemetryEvents()).toHaveLength(2);
|
||||
await render("/other", "another-branch");
|
||||
expect(telemetryEvents()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("reports PR, CI, check, and refresh interactions without identifying data", async () => {
|
||||
await render();
|
||||
await click("Open pull request #42: Feature");
|
||||
await click("CI failed");
|
||||
const check = [...document.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.trim() === "Tests",
|
||||
);
|
||||
if (!check) throw new Error("Expected check link");
|
||||
await act(async () => check.click());
|
||||
await click("CI failed"); // Closing the popover is not another expansion.
|
||||
await click("Refresh pull request status");
|
||||
expect(telemetryEvents().map((event) => event.action)).toEqual([
|
||||
"shown",
|
||||
"open_clicked",
|
||||
"checks_expanded",
|
||||
"check_clicked",
|
||||
"refresh_clicked",
|
||||
]);
|
||||
for (const event of telemetryEvents()) {
|
||||
expect(Object.keys(event).sort()).toEqual([
|
||||
"action",
|
||||
"ciState",
|
||||
"mergeTone",
|
||||
"prState",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("records create intent without claiming a PR was created", async () => {
|
||||
invoke.mockResolvedValue({ ...data, pullRequest: null });
|
||||
await render();
|
||||
const create = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Create PR"),
|
||||
);
|
||||
if (!create) throw new Error("Expected create button");
|
||||
await act(async () => create.click());
|
||||
expect(telemetryEvents()).toEqual([
|
||||
{
|
||||
action: "shown",
|
||||
prState: "none",
|
||||
ciState: "none",
|
||||
mergeTone: "neutral",
|
||||
},
|
||||
{
|
||||
action: "create_clicked",
|
||||
prState: "none",
|
||||
ciState: "none",
|
||||
mergeTone: "neutral",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps PR links usable when telemetry delivery fails", async () => {
|
||||
invoke.mockImplementation(async (command) => {
|
||||
if (command === "capture_pull_request_event")
|
||||
throw new Error("Telemetry unavailable");
|
||||
return data;
|
||||
});
|
||||
await render();
|
||||
await click("Open pull request #42: Feature");
|
||||
expect(openExternalUrl).toHaveBeenCalledWith(data.pullRequest?.url);
|
||||
});
|
||||
|
||||
it("does not record exposure for hidden or failed status rows", async () => {
|
||||
invoke.mockResolvedValue(null);
|
||||
await render();
|
||||
expect(telemetryEvents()).toEqual([]);
|
||||
invoke.mockRejectedValue(new Error("GitHub unavailable"));
|
||||
await clickRefreshViaFocus();
|
||||
expect(telemetryEvents()).toEqual([]);
|
||||
});
|
||||
|
||||
async function clickRefreshViaFocus() {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
GitPullRequestDraft,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
getMergeStatus,
|
||||
type MergeStatus,
|
||||
type PullRequestStatus,
|
||||
summarizeChecks,
|
||||
} from "@/lib/pull-request";
|
||||
import { trackPullRequestEvent } from "@/lib/pull-request-telemetry";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const checkLabels = {
|
||||
none: "No checks",
|
||||
pending: "CI pending",
|
||||
success: "CI passed",
|
||||
failure: "CI failed",
|
||||
skipped: "CI skipped",
|
||||
};
|
||||
const checkColors = {
|
||||
none: "bg-muted-foreground",
|
||||
pending: "bg-yellow-500",
|
||||
success: "bg-green-500",
|
||||
failure: "bg-red-500",
|
||||
skipped: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
const mergeStatusColors: Record<MergeStatus["tone"], string> = {
|
||||
merged: "text-purple-400",
|
||||
failure: "text-red-400",
|
||||
warning: "text-yellow-500",
|
||||
neutral: "text-muted-foreground",
|
||||
success: "text-green-500",
|
||||
};
|
||||
|
||||
export function PullRequestBar({
|
||||
cwd,
|
||||
branch,
|
||||
}: {
|
||||
cwd: string;
|
||||
branch: string | null;
|
||||
}) {
|
||||
// Remount on workspace/branch changes so another repository's PR never flashes.
|
||||
if (!cwd || !branch || branch === "no-git") return null;
|
||||
return <WorkspacePullRequestBar key={`${cwd}:${branch}`} cwd={cwd} />;
|
||||
}
|
||||
|
||||
function WorkspacePullRequestBar({ cwd }: { cwd: string }) {
|
||||
const [data, setData] = useState<PullRequestStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const refresh = useRef<() => void>(() => {});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasReportedShown = useRef(false);
|
||||
const hasLoadedStatus = useRef(false);
|
||||
const errorDismissed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReportedShown.current || document.visibilityState === "hidden")
|
||||
return;
|
||||
if (data?.pullRequest || data?.createUrl) {
|
||||
hasReportedShown.current = true;
|
||||
trackPullRequestEvent("shown", data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let inFlight = false;
|
||||
async function update() {
|
||||
if (inFlight || document.visibilityState === "hidden") return;
|
||||
inFlight = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await desktopClient.invoke<PullRequestStatus | null>(
|
||||
"get_pull_request_status",
|
||||
{ cwd },
|
||||
);
|
||||
if (!disposed) {
|
||||
hasLoadedStatus.current = result !== null;
|
||||
errorDismissed.current = false;
|
||||
setData(result);
|
||||
setError(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!disposed) {
|
||||
setData(null);
|
||||
if (hasLoadedStatus.current && !errorDismissed.current) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Could not load pull request status.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!disposed) setLoading(false);
|
||||
}
|
||||
}
|
||||
refresh.current = () => void update();
|
||||
void update();
|
||||
const timer = window.setInterval(() => void update(), 30_000);
|
||||
const onFocus = () => void update();
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onFocus);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onFocus);
|
||||
};
|
||||
}, [cwd]);
|
||||
|
||||
async function open(url: string) {
|
||||
try {
|
||||
await openExternalUrl(url);
|
||||
} catch {
|
||||
setError("Could not open GitHub in your browser. Try again.");
|
||||
}
|
||||
}
|
||||
|
||||
const pr = data?.pullRequest;
|
||||
if (!error && !pr && !data?.createUrl) return null;
|
||||
const ci = summarizeChecks(pr?.checks ?? []);
|
||||
const Icon =
|
||||
pr?.state === "MERGED"
|
||||
? GitMerge
|
||||
: pr?.state === "CLOSED"
|
||||
? GitPullRequestClosed
|
||||
: pr?.isDraft
|
||||
? GitPullRequestDraft
|
||||
: GitPullRequest;
|
||||
const mergeStatus = pr ? getMergeStatus(pr) : null;
|
||||
const statusColor = mergeStatusColors[mergeStatus?.tone ?? "neutral"];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-b border-border px-4 py-2 text-xs"
|
||||
aria-label="Pull request status"
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-1 flex items-start gap-2 text-muted-foreground">
|
||||
<output className="min-w-0 flex-1">{error}</output>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss pull request error"
|
||||
className="shrink-0 rounded p-1 hover:bg-muted"
|
||||
onClick={() => {
|
||||
errorDismissed.current = true;
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{data && (
|
||||
<>
|
||||
<Icon
|
||||
className={cn("size-4 shrink-0", statusColor)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{pr ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackPullRequestEvent("open_clicked", data);
|
||||
void open(pr.url);
|
||||
}}
|
||||
title={pr.title}
|
||||
className="shrink-0 font-medium hover:underline"
|
||||
aria-label={`Open pull request #${pr.number}: ${pr.title}`}
|
||||
>
|
||||
#{pr.number}
|
||||
</button>
|
||||
<span className={cn("shrink-0", statusColor)}>
|
||||
{mergeStatus?.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 font-medium hover:underline"
|
||||
title="Open GitHub’s comparison form for this branch. Push your commits before submitting."
|
||||
onClick={() => {
|
||||
if (data.createUrl) {
|
||||
trackPullRequestEvent("create_clicked", data);
|
||||
void open(data.createUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create PR <ExternalLink className="inline size-3" />
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-muted-foreground"
|
||||
title={`${data.repository} · ${data.branch}`}
|
||||
>
|
||||
{data.repository.split("/").pop()}{" "}
|
||||
<span className="ml-1">{data.branch}</span>
|
||||
</span>
|
||||
{pr && (
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 tabular-nums"
|
||||
title={`${pr.additions} additions, ${pr.deletions} deletions`}
|
||||
>
|
||||
<span className="text-green-500">
|
||||
+{pr.additions.toLocaleString()}
|
||||
</span>{" "}
|
||||
<span className="text-red-400">
|
||||
−{pr.deletions.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<Popover
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) trackPullRequestEvent("checks_expanded", data);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1"
|
||||
aria-label={
|
||||
ci === "none" ? "No CI checks" : checkLabels[ci]
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn("size-2 rounded-full", checkColors[ci])}
|
||||
/>
|
||||
{checkLabels[ci]}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-3">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
Checks for #{pr.number}
|
||||
</p>
|
||||
{!pr.checks.length && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No checks reported for this pull request.
|
||||
</p>
|
||||
)}
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{pr.checks.map((check, index) => (
|
||||
<li
|
||||
key={`${check.name}:${index}`}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full",
|
||||
checkColors[check.state],
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{check.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (check.url) {
|
||||
trackPullRequestEvent(
|
||||
"check_clicked",
|
||||
data,
|
||||
);
|
||||
void open(check.url);
|
||||
}
|
||||
}}
|
||||
className="text-left hover:underline"
|
||||
>
|
||||
{check.name}{" "}
|
||||
<ExternalLink className="inline size-3" />
|
||||
</button>
|
||||
) : (
|
||||
check.name
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{check.state}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
trackPullRequestEvent("refresh_clicked", data);
|
||||
refresh.current();
|
||||
}}
|
||||
aria-label="Refresh pull request status"
|
||||
title="Refresh pull request status"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn("size-3", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-gith
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { isFeatureEnabled, useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
@@ -322,6 +323,7 @@ function ConnectStep({
|
||||
}) {
|
||||
const { user, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const deviceUserCode = useOAuthUserCode(signingIn);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
const [clineApiKey, setClineApiKey] = useState("");
|
||||
const [clineKeySaving, setClineKeySaving] = useState(false);
|
||||
@@ -619,6 +621,14 @@ function ConnectStep({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!user && signingIn && deviceUserCode ? (
|
||||
<p className="mt-4 ml-12 text-sm text-muted-foreground max-[720px]:ml-0">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{signInError ? (
|
||||
<p
|
||||
className="mt-6 ml-12 text-xs text-destructive max-[720px]:ml-0"
|
||||
|
||||
@@ -43,6 +43,7 @@ function renderView({
|
||||
openThread = vi.fn(),
|
||||
loadAllSessions = vi.fn(async () => true),
|
||||
loadOlderSessions = vi.fn(),
|
||||
requestUsage = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
hasLoadedHistory = true,
|
||||
@@ -50,6 +51,7 @@ function renderView({
|
||||
openThread?: ReturnType<typeof vi.fn>;
|
||||
loadAllSessions?: ReturnType<typeof vi.fn>;
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
requestUsage?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
threads?: SessionThread[];
|
||||
hasLoadedHistory?: boolean;
|
||||
@@ -65,6 +67,7 @@ function renderView({
|
||||
openThread,
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
requestUsage,
|
||||
setThreadPinned: vi.fn(),
|
||||
sessionById: new Map(
|
||||
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
|
||||
@@ -76,6 +79,7 @@ function renderView({
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
openThread,
|
||||
requestUsage,
|
||||
render: () =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
@@ -308,6 +312,33 @@ describe("SessionsView pagination", () => {
|
||||
expect(container.textContent).toContain("11-20 of 25");
|
||||
});
|
||||
|
||||
it("asks the history hook for usage of the rows on the visible page", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(0, 10).map((item) => item.id),
|
||||
);
|
||||
|
||||
await clickNext();
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(10, 20).map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases its usage request when it unmounts", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(0, 10).map((item) => item.id),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<div />);
|
||||
});
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it("only asks the backend for older sessions at the last page", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
|
||||
@@ -241,6 +241,24 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
currentPage + 1 < pageCount ||
|
||||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
|
||||
|
||||
// Tokens and cost are not part of the discovery rows; the hook reads them
|
||||
// from each transcript on demand, so tell it which rows are on screen.
|
||||
// Paging (or a fresh batch of older sessions) changes the visible rows and
|
||||
// the new page fills in the same way.
|
||||
useEffect(() => {
|
||||
history.requestUsage(visibleThreads.map((thread) => thread.id));
|
||||
}, [history.requestUsage, visibleThreads]);
|
||||
// Leaving the view releases its page, so running sessions on it stop being
|
||||
// re-read while nobody is looking at them. Separate from the effect above
|
||||
// on purpose: a per-change cleanup would clear and re-set the same ids and
|
||||
// restart the hook's hydration each time a row filled in.
|
||||
useEffect(
|
||||
() => () => {
|
||||
history.requestUsage([]);
|
||||
},
|
||||
[history.requestUsage],
|
||||
);
|
||||
|
||||
// Snap back when a page disappears (filters changed, or "next" asked the
|
||||
// backend for older sessions and there were none left).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
@@ -181,6 +182,7 @@ export function AccountView() {
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
const deviceUserCode = useOAuthUserCode(accountActionPending === "sign-in");
|
||||
// Organization id being switched to, "" while switching to the personal
|
||||
// account, null when no switch is in flight.
|
||||
const [switchTargetId, setSwitchTargetId] = useState<string | null>(null);
|
||||
@@ -502,6 +504,14 @@ export function AccountView() {
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{accountActionPending === "sign-in" && deviceUserCode ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,10 @@ 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 {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
@@ -19,12 +22,12 @@ import { McpServersContent } from "./mcp-view";
|
||||
type CustomizeTab = "skills" | "mcp" | "plugins" | "rules" | "hooks" | "tools";
|
||||
|
||||
const CUSTOMIZE_TABS: { id: CustomizeTab; label: string }[] = [
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "rules", label: "Rules" },
|
||||
{ id: "hooks", label: "Hooks" },
|
||||
{ id: "tools", label: "Tools" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "rules", label: "Rules" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "hooks", label: "Hooks" },
|
||||
];
|
||||
|
||||
type TabCounts = Partial<Record<CustomizeTab, number>>;
|
||||
@@ -48,7 +51,7 @@ export function CustomizeView({
|
||||
}: {
|
||||
onOpenMarketplace?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<CustomizeTab>("skills");
|
||||
const [tab, setTab] = useState<CustomizeTab>("tools");
|
||||
const [counts, setCounts] = useState<TabCounts>({});
|
||||
|
||||
const refreshCounts = useCallback(async () => {
|
||||
@@ -75,6 +78,15 @@ export function CustomizeView({
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshCounts]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribe("settings.changed", () => {
|
||||
invalidateExtensionInventoryCache();
|
||||
void refreshCounts();
|
||||
}),
|
||||
[refreshCounts],
|
||||
);
|
||||
|
||||
const handleInventoryChanged = useCallback(() => {
|
||||
void refreshCounts();
|
||||
}, [refreshCounts]);
|
||||
@@ -95,7 +107,7 @@ export function CustomizeView({
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
description="Extend what Cline can do and change how it works. Manage what's installed, or browse the marketplace for more options."
|
||||
description="Extend what Cline can do and how it works. Explore the marketplace for more options."
|
||||
title="Customize"
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
|
||||
const { fetchMarketplaceCatalog, invoke } = vi.hoisted(() => ({
|
||||
fetchMarketplaceCatalog: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/marketplace", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/marketplace")>()),
|
||||
fetchMarketplaceCatalog,
|
||||
}));
|
||||
|
||||
const EMPTY_CATALOG = {
|
||||
version: 1,
|
||||
counts: { total: 0, plugins: 0, skills: 0, mcps: 0 },
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
const AGENT_PLUGIN = {
|
||||
id: "agent-plugin:/Users/test/.agents/plugins/example",
|
||||
name: "agent-plugins-example",
|
||||
path: "/Users/test/.agents/plugins/example",
|
||||
enabled: true,
|
||||
source: "agent-plugin",
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
contributions: {
|
||||
inspectionStatus: "available",
|
||||
capabilities: ["skills"],
|
||||
tools: [],
|
||||
skills: ["example-skill"],
|
||||
rules: [],
|
||||
hooks: [],
|
||||
commands: [],
|
||||
mcpServers: [],
|
||||
providers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const AGENT_PLUGIN_SKILL = {
|
||||
name: "example-skill",
|
||||
description: "A skill contributed by an Agent Plugin.",
|
||||
instructions: "",
|
||||
path: "/Users/test/.agents/plugins/example/skills/example-skill/SKILL.md",
|
||||
agentPlugin: true,
|
||||
pluginName: "agent-plugins-example",
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invalidateExtensionInventoryCache();
|
||||
fetchMarketplaceCatalog.mockReset();
|
||||
fetchMarketplaceCatalog.mockResolvedValue(EMPTY_CATALOG);
|
||||
invoke.mockReset();
|
||||
invoke.mockImplementation((command: string) => {
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return Promise.resolve({ installedKeys: [] });
|
||||
}
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return Promise.resolve({
|
||||
workspaceRoot: "/workspace",
|
||||
rules: [],
|
||||
workflows: [],
|
||||
skills: [AGENT_PLUGIN_SKILL],
|
||||
agents: [],
|
||||
plugins: [AGENT_PLUGIN],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
mcp: { servers: [] },
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
invalidateExtensionInventoryCache();
|
||||
});
|
||||
|
||||
describe("CustomizationSectionView Agent Plugin inventory", () => {
|
||||
it("shows Hub-managed Agent Plugins in the installed Plugins view", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
section="Plugins"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("agent-plugins-example");
|
||||
expect(container.textContent).toContain("Agent Plugin");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Agent Plugin skills in the installed Skills view", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
section="Skills"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("example-skill");
|
||||
expect(container.textContent).toContain("Agent Plugin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool state controls", () => {
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
])("sets duplicate built-in names explicitly (enabled=%s)", async (initialEnabled) => {
|
||||
let enabled = initialEnabled;
|
||||
const inventory = () => ({
|
||||
workspaceRoot: "/workspace",
|
||||
tools: [
|
||||
{
|
||||
id: "web_search",
|
||||
name: "web_search",
|
||||
headlessToolNames: ["web_search"],
|
||||
source: "builtin",
|
||||
enabled,
|
||||
},
|
||||
],
|
||||
});
|
||||
invoke.mockImplementation(async (command, args) => {
|
||||
if (command === "list_marketplace_installed_entries")
|
||||
return { installedKeys: [] };
|
||||
if (command === "list_user_instruction_configs") return inventory();
|
||||
if (command === "set_tool_disabled") {
|
||||
enabled = !args.disabled;
|
||||
return inventory();
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(<CustomizationSectionView section="Tools" />);
|
||||
});
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Toggle web_search"]')
|
||||
?.click();
|
||||
});
|
||||
expect(enabled).toBe(!initialEnabled);
|
||||
expect(invoke).toHaveBeenCalledWith("set_tool_disabled", {
|
||||
names: ["web_search", "web_search"],
|
||||
disabled: initialEnabled,
|
||||
});
|
||||
expect(
|
||||
container
|
||||
.querySelector('[aria-label="Toggle web_search"]')
|
||||
?.getAttribute("aria-checked"),
|
||||
).toBe(String(!initialEnabled));
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
@@ -18,12 +19,14 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
@@ -84,6 +87,9 @@ type SkillItem = {
|
||||
description?: string;
|
||||
instructions: string;
|
||||
path: string;
|
||||
enabled?: boolean;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type CommandItem = {
|
||||
@@ -92,8 +98,11 @@ type CommandItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
enabled?: boolean;
|
||||
path: string;
|
||||
scope: ItemScope;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type ItemScope = "Global" | "Project";
|
||||
@@ -104,9 +113,15 @@ type AgentItem = {
|
||||
};
|
||||
|
||||
type PluginItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
source?: string;
|
||||
toggleable?: boolean;
|
||||
agentPlugin?: boolean;
|
||||
description?: string;
|
||||
loadError?: string;
|
||||
contributions?: PluginContributions;
|
||||
};
|
||||
|
||||
@@ -133,6 +148,22 @@ type ToolItem = {
|
||||
headlessToolNames?: string[];
|
||||
};
|
||||
|
||||
function toolMatchesQuery(tool: ToolItem, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
const haystacks = [
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.pluginName,
|
||||
tool.path,
|
||||
...(tool.headlessToolNames ?? []),
|
||||
];
|
||||
return haystacks.some((value) =>
|
||||
value?.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
type HookItem = {
|
||||
fileName: string;
|
||||
hookEventName?: string;
|
||||
@@ -173,7 +204,7 @@ type McpServersResponse = {
|
||||
servers: McpServer[];
|
||||
};
|
||||
|
||||
type LocalUninstallType = "mcp" | "skill" | "workflow" | "plugin";
|
||||
type LocalUninstallType = "mcp" | "skill" | "workflow" | "plugin" | "rule";
|
||||
|
||||
type LocalUninstallTarget = {
|
||||
key: string;
|
||||
@@ -344,11 +375,6 @@ async function fetchUserInstructionLists(): Promise<UserInstructionListsResponse
|
||||
return normalizeInstructionListsResponse(response);
|
||||
}
|
||||
|
||||
function isUnsupportedDesktopCommand(error: unknown, command: string): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes(`unsupported desktop command: ${command}`);
|
||||
}
|
||||
|
||||
export function CustomizationSectionView({
|
||||
catalogPrimitive,
|
||||
chrome = "page",
|
||||
@@ -412,9 +438,13 @@ export function CustomizationSectionView({
|
||||
const [togglingToolIds, setTogglingToolIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [toolsSearchQuery, setToolsSearchQuery] = useState("");
|
||||
const [togglingPluginPaths, setTogglingPluginPaths] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [togglingSkillPaths, setTogglingSkillPaths] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [localUninstallingKeys, setLocalUninstallingKeys] = useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
@@ -586,31 +616,11 @@ export function CustomizationSectionView({
|
||||
if (names.length === 0) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
let response: UserInstructionListsResponse | undefined;
|
||||
try {
|
||||
response = await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
const response =
|
||||
await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
"set_tool_disabled",
|
||||
{
|
||||
names,
|
||||
disabled: tool.enabled,
|
||||
},
|
||||
{ names, disabled: tool.enabled },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUnsupportedDesktopCommand(error, "set_tool_disabled")) {
|
||||
throw error;
|
||||
}
|
||||
for (const name of names) {
|
||||
response = await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
"toggle_disabled_plugin_tool",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
"tool toggle did not return an updated extension list",
|
||||
);
|
||||
}
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -626,6 +636,50 @@ export function CustomizationSectionView({
|
||||
[applyResponse],
|
||||
);
|
||||
|
||||
const setAllToolsEnabled = useCallback(
|
||||
async (toolsList: ToolItem[], enabled: boolean) => {
|
||||
const targets = toolsList.filter((tool) => !enabled || !tool.enabled);
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const targetIds = targets.map((tool) => tool.id);
|
||||
setTogglingToolIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of targetIds) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const names = targets.flatMap((tool) =>
|
||||
[tool.name, ...(tool.headlessToolNames ?? [])].filter(Boolean),
|
||||
);
|
||||
if (names.length === 0) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
const response =
|
||||
await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
"set_tool_disabled",
|
||||
{ names, disabled: !enabled },
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setTogglingToolIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of targetIds) {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[applyResponse],
|
||||
);
|
||||
|
||||
const setPluginEnabled = useCallback(
|
||||
async (plugin: PluginItem) => {
|
||||
setTogglingPluginPaths((current) => new Set(current).add(plugin.path));
|
||||
@@ -654,6 +708,34 @@ export function CustomizationSectionView({
|
||||
[applyResponse],
|
||||
);
|
||||
|
||||
const setSkillEnabled = useCallback(
|
||||
async (skill: CommandItem) => {
|
||||
setTogglingSkillPaths((current) => new Set(current).add(skill.path));
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
"set_skill_disabled",
|
||||
{
|
||||
path: skill.path,
|
||||
disabled: skill.enabled !== false,
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setTogglingSkillPaths((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(skill.path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[applyResponse],
|
||||
);
|
||||
|
||||
const uninstallLocalPrimitive = useCallback(
|
||||
async (target: LocalUninstallTarget) => {
|
||||
if (localUninstallingKeys.has(target.key)) {
|
||||
@@ -772,8 +854,11 @@ export function CustomizationSectionView({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
enabled: skill.enabled,
|
||||
path: skill.path,
|
||||
scope: getPathScope(skill.path, workspaceRoot),
|
||||
agentPlugin: skill.agentPlugin,
|
||||
pluginName: skill.pluginName,
|
||||
}));
|
||||
return [...workflowItems, ...skillItems].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
@@ -825,9 +910,11 @@ export function CustomizationSectionView({
|
||||
for (const plugin of plugins) {
|
||||
const normalized = normalizePath(plugin.path);
|
||||
if (
|
||||
normalizedRoot &&
|
||||
normalized.startsWith(`${normalizedRoot}/`) &&
|
||||
normalized.includes("/.cline/plugins")
|
||||
plugin.source === "workspace-plugin" ||
|
||||
(normalizedRoot &&
|
||||
normalized.startsWith(`${normalizedRoot}/`) &&
|
||||
(normalized.includes("/.cline/plugins") ||
|
||||
normalized.includes("/.agents/plugins")))
|
||||
) {
|
||||
project.push(plugin);
|
||||
} else {
|
||||
@@ -838,11 +925,17 @@ export function CustomizationSectionView({
|
||||
}, [plugins, workspaceRoot]);
|
||||
|
||||
const builtinTools = useMemo(
|
||||
() => tools.filter((tool) => tool.source === "builtin"),
|
||||
() =>
|
||||
tools
|
||||
.filter((tool) => tool.source === "builtin")
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[tools],
|
||||
);
|
||||
const pluginTools = useMemo(
|
||||
() => tools.filter((tool) => tool.source !== "builtin"),
|
||||
() =>
|
||||
tools
|
||||
.filter((tool) => tool.source !== "builtin")
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[tools],
|
||||
);
|
||||
const pluginToolsByPluginKey = useMemo(() => {
|
||||
@@ -858,6 +951,33 @@ export function CustomizationSectionView({
|
||||
}
|
||||
return grouped;
|
||||
}, [pluginTools]);
|
||||
const normalizedToolsSearchQuery = toolsSearchQuery.trim().toLowerCase();
|
||||
const filteredBuiltinTools = useMemo(
|
||||
() =>
|
||||
builtinTools.filter((tool) =>
|
||||
toolMatchesQuery(tool, normalizedToolsSearchQuery),
|
||||
),
|
||||
[builtinTools, normalizedToolsSearchQuery],
|
||||
);
|
||||
const filteredPluginTools = useMemo(
|
||||
() =>
|
||||
pluginTools.filter((tool) =>
|
||||
toolMatchesQuery(tool, normalizedToolsSearchQuery),
|
||||
),
|
||||
[pluginTools, normalizedToolsSearchQuery],
|
||||
);
|
||||
const allBuiltinToolsEnabled = useMemo(
|
||||
() =>
|
||||
filteredBuiltinTools.length > 0 &&
|
||||
filteredBuiltinTools.every((tool) => tool.enabled),
|
||||
[filteredBuiltinTools],
|
||||
);
|
||||
const allPluginToolsEnabled = useMemo(
|
||||
() =>
|
||||
filteredPluginTools.length > 0 &&
|
||||
filteredPluginTools.every((tool) => tool.enabled),
|
||||
[filteredPluginTools],
|
||||
);
|
||||
|
||||
const scopedPlugins = useMemo(
|
||||
() => [
|
||||
@@ -872,6 +992,14 @@ export function CustomizationSectionView({
|
||||
],
|
||||
[globalPlugins, projectPlugins],
|
||||
);
|
||||
const clinePlugins = useMemo(
|
||||
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin !== true),
|
||||
[scopedPlugins],
|
||||
);
|
||||
const agentPlugins = useMemo(
|
||||
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin === true),
|
||||
[scopedPlugins],
|
||||
);
|
||||
|
||||
const scopedRules = useMemo(
|
||||
() => [
|
||||
@@ -928,7 +1056,11 @@ export function CustomizationSectionView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginMenu = (target: LocalUninstallTarget) => {
|
||||
const renderLocalItemMenu = (
|
||||
target: LocalUninstallTarget,
|
||||
options: { showDelete?: boolean } = {},
|
||||
) => {
|
||||
const { showDelete = true } = options;
|
||||
const uninstalling = localUninstallingKeys.has(target.key);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -953,14 +1085,16 @@ export function CustomizationSectionView({
|
||||
<Copy className="size-4" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={uninstalling}
|
||||
onClick={() => void uninstallLocalPrimitive(target)}
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
{showDelete ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={uninstalling}
|
||||
onClick={() => void uninstallLocalPrimitive(target)}
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -974,35 +1108,57 @@ export function CustomizationSectionView({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{item.type === "workflow" ? (
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{item.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={item.scope} />
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{item.type}
|
||||
</Badge>
|
||||
{item.agentPlugin === true ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Agent Plugin
|
||||
</Badge>
|
||||
) : null}
|
||||
{context?.matchedEntries?.length ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
</Badge>
|
||||
) : null}
|
||||
<Switch
|
||||
checked={item.enabled !== false}
|
||||
onCheckedChange={() => {
|
||||
void setSkillEnabled(item);
|
||||
}}
|
||||
disabled={
|
||||
item.type === "workflow" ||
|
||||
item.agentPlugin === true ||
|
||||
togglingSkillPaths.has(item.path)
|
||||
}
|
||||
title={
|
||||
item.type === "workflow"
|
||||
? "Toggling workflows isn't supported yet"
|
||||
: undefined
|
||||
}
|
||||
aria-label={`Toggle ${item.name}`}
|
||||
/>
|
||||
{item.agentPlugin !== true
|
||||
? renderLocalItemMenu({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{item.description?.trim() || previewText(item.instructions)}
|
||||
@@ -1057,32 +1213,47 @@ export function CustomizationSectionView({
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={scope} />
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{plugin.agentPlugin === true ? "Agent Plugin" : "Cline Plugin"}
|
||||
</Badge>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plugin.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
disabled={
|
||||
plugin.toggleable === false ||
|
||||
togglingPluginPaths.has(plugin.path)
|
||||
}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
{renderPluginMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
{plugin.agentPlugin !== true
|
||||
? renderLocalItemMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})
|
||||
: null}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{plugin.description?.trim() ? (
|
||||
<p className="mb-2 whitespace-pre-line text-xs text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
) : null}
|
||||
{plugin.loadError?.trim() ? (
|
||||
<p className="mb-2 whitespace-pre-line text-xs text-destructive">
|
||||
{plugin.loadError}
|
||||
</p>
|
||||
) : null}
|
||||
{plugin.contributions?.inspectionStatus === "disabled" ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Enable this plugin to inspect its dynamic contributions.
|
||||
@@ -1330,17 +1501,31 @@ export function CustomizationSectionView({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</span>
|
||||
<ScopeBadge scope={scope} />
|
||||
<Switch
|
||||
checked
|
||||
onCheckedChange={() => {}}
|
||||
disabled
|
||||
title="Toggling rules isn't supported yet"
|
||||
aria-label={`Toggle ${rule.name}`}
|
||||
/>
|
||||
{renderLocalItemMenu(
|
||||
{
|
||||
key: rule.path,
|
||||
type: "rule",
|
||||
id: rule.path,
|
||||
name: rule.name,
|
||||
path: rule.path,
|
||||
},
|
||||
{ showDelete: false },
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{previewText(rule.instructions)}
|
||||
</p>
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{rule.path}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{scopedRules.length === 0 && (
|
||||
@@ -1520,68 +1705,19 @@ export function CustomizationSectionView({
|
||||
{activeTab === "Plugins" && !catalogPrimitive && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Plugins discovered from workspace and global plugin directories.
|
||||
Cline and portable Agent Plugins discovered by the shared Hub.
|
||||
Changes apply when a session is rebuilt or started.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Global Plugins
|
||||
Cline Plugins ({clinePlugins.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{globalPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plugin.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{globalPlugins.length === 0 && (
|
||||
{clinePlugins.map((plugin) => renderPluginCard(plugin))}
|
||||
{clinePlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No global plugins found.
|
||||
No Cline Plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1589,63 +1725,13 @@ export function CustomizationSectionView({
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Project Plugins
|
||||
Agent Plugins ({agentPlugins.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{projectPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plugin.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{projectPlugins.length === 0 && (
|
||||
{agentPlugins.map((plugin) => renderPluginCard(plugin))}
|
||||
{agentPlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No project plugins found.
|
||||
No Agent Plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1655,17 +1741,57 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Tools" && (
|
||||
<div>
|
||||
<div className="relative mb-6 block">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search tools"
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setToolsSearchQuery(event.target.value)}
|
||||
placeholder="Search tools"
|
||||
value={toolsSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Builtin Tools
|
||||
BuiltIn Tools{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{filteredBuiltinTools.length}
|
||||
</span>
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{builtinTools.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={allBuiltinToolsEnabled}
|
||||
onCheckedChange={() => {
|
||||
void setAllToolsEnabled(
|
||||
filteredBuiltinTools,
|
||||
!allBuiltinToolsEnabled,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
filteredBuiltinTools.length === 0 ||
|
||||
filteredBuiltinTools.some((tool) =>
|
||||
togglingToolIds.has(tool.id),
|
||||
)
|
||||
}
|
||||
id="builtin-tools-toggle-all"
|
||||
aria-label={
|
||||
allBuiltinToolsEnabled
|
||||
? "Disable all builtin tools"
|
||||
: "Enable all builtin tools"
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="cursor-pointer"
|
||||
htmlFor="builtin-tools-toggle-all"
|
||||
>
|
||||
{allBuiltinToolsEnabled ? "Disable all" : "Enable all"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{builtinTools.map((tool) =>
|
||||
{filteredBuiltinTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
@@ -1678,9 +1804,6 @@ export function CustomizationSectionView({
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
@@ -1694,18 +1817,21 @@ export function CustomizationSectionView({
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{!!tool.headlessToolNames?.length && (
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.headlessToolNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{!!tool.headlessToolNames?.length &&
|
||||
tool.headlessToolNames?.length > 1 && (
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.headlessToolNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
)}
|
||||
{builtinTools.length === 0 && (
|
||||
{filteredBuiltinTools.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No builtin tools found.
|
||||
{builtinTools.length === 0
|
||||
? "No builtin tools found."
|
||||
: "No tools match your search."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1714,14 +1840,43 @@ export function CustomizationSectionView({
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Plugin Tools
|
||||
Plugin Tools{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{filteredPluginTools.length}
|
||||
</span>
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluginTools.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={allPluginToolsEnabled}
|
||||
onCheckedChange={() => {
|
||||
void setAllToolsEnabled(
|
||||
filteredPluginTools,
|
||||
!allPluginToolsEnabled,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
filteredPluginTools.length === 0 ||
|
||||
filteredPluginTools.some((tool) =>
|
||||
togglingToolIds.has(tool.id),
|
||||
)
|
||||
}
|
||||
id="plugin-tools-toggle-all"
|
||||
aria-label={
|
||||
allPluginToolsEnabled
|
||||
? "Disable all plugin tools"
|
||||
: "Enable all plugin tools"
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="cursor-pointer"
|
||||
htmlFor="plugin-tools-toggle-all"
|
||||
>
|
||||
{allPluginToolsEnabled ? "Disable all" : "Enable all"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pluginTools.map((tool) =>
|
||||
{filteredPluginTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
@@ -1736,15 +1891,13 @@ export function CustomizationSectionView({
|
||||
</h3>
|
||||
{tool.pluginName && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
variant="muted"
|
||||
className="shrink-0"
|
||||
title={tool.path || tool.pluginName}
|
||||
>
|
||||
{tool.pluginName}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
@@ -1758,18 +1911,15 @@ export function CustomizationSectionView({
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{tool.path && (
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
)}
|
||||
{pluginTools.length === 0 && (
|
||||
{filteredPluginTools.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No plugin tools found.
|
||||
{pluginTools.length === 0
|
||||
? "No plugin tools found."
|
||||
: "No tools match your search."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ImportContent } from "./import-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/import-sessions-dialog", () => ({
|
||||
ImportSessionsDialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="import-dialog">
|
||||
<button onClick={() => onOpenChange(false)} type="button">
|
||||
stub-close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function session(tool: string, alreadyImportedSessionId?: string) {
|
||||
return {
|
||||
tool,
|
||||
sourceId: `${tool}-${Math.random()}`,
|
||||
sourcePath: "/tmp/x",
|
||||
title: "t",
|
||||
cwd: "/tmp",
|
||||
startedAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
messageCount: 1,
|
||||
alreadyImportedSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invoke.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
await act(async () => {
|
||||
root.render(<ImportContent />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function click(label: string) {
|
||||
const button = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
);
|
||||
if (!button) throw new Error(`no button "${label}"`);
|
||||
act(() => button.click());
|
||||
}
|
||||
|
||||
describe("ImportContent", () => {
|
||||
it("summarizes each tool from the scan", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
installedTools: ["claude-code", "codex"],
|
||||
sessions: [
|
||||
session("claude-code"),
|
||||
session("claude-code", "existing"),
|
||||
session("claude-code"),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
expect.anything(),
|
||||
);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Claude Code");
|
||||
expect(text).toContain("3 sessions found · 1 already imported");
|
||||
expect(text).toContain("No sessions found");
|
||||
expect(text).toContain("Not detected on this machine");
|
||||
});
|
||||
|
||||
it("opens the import dialog and rescans when it closes", async () => {
|
||||
invoke.mockResolvedValue({ installedTools: [], sessions: [] });
|
||||
await render();
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
click("Import sessions");
|
||||
expect(container.querySelector("[data-testid=import-dialog]")).not.toBe(
|
||||
null,
|
||||
);
|
||||
|
||||
click("stub-close");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector("[data-testid=import-dialog]")).toBe(null);
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores a slow earlier scan that resolves after a rescan", async () => {
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
invoke
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
installedTools: ["codex"],
|
||||
sessions: [session("codex", "existing")],
|
||||
});
|
||||
await render();
|
||||
expect(container.textContent).toContain("Scanning…");
|
||||
|
||||
click("Import sessions");
|
||||
click("stub-close");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"1 session found · 1 already imported",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({ installedTools: ["codex"], sessions: [session("codex")] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"1 session found · 1 already imported",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a failed scan", async () => {
|
||||
invoke.mockRejectedValue(new Error("disk on fire"));
|
||||
await render();
|
||||
expect(container.textContent).toContain(
|
||||
"Couldn't scan for sessions: disk on fire",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Import } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
type ListImportableSessionsResponse,
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
SESSION_IMPORT_TOOL_ORDER,
|
||||
type SessionImportTool,
|
||||
} from "@/lib/session-import";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
function toolStatus(
|
||||
tool: SessionImportTool,
|
||||
scan: ListImportableSessionsResponse,
|
||||
): string {
|
||||
if (!scan.installedTools.includes(tool)) {
|
||||
return "Not detected on this machine";
|
||||
}
|
||||
const sessions = scan.sessions.filter((session) => session.tool === tool);
|
||||
if (sessions.length === 0) return "No sessions found";
|
||||
const imported = sessions.filter(
|
||||
(session) => session.alreadyImportedSessionId,
|
||||
).length;
|
||||
const found = `${sessions.length} session${sessions.length === 1 ? "" : "s"} found`;
|
||||
return imported > 0 ? `${found} · ${imported} already imported` : found;
|
||||
}
|
||||
|
||||
export function ImportContent() {
|
||||
const [scan, setScan] = useState<ListImportableSessionsResponse | null>(null);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// A slow initial scan can finish after the post-import rescan; only the
|
||||
// latest request may update the counts.
|
||||
const scanRequestRef = useRef(0);
|
||||
|
||||
const rescan = useCallback(async () => {
|
||||
const requestId = ++scanRequestRef.current;
|
||||
setScanError(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<ListImportableSessionsResponse>(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
if (scanRequestRef.current !== requestId) return;
|
||||
setScan({
|
||||
installedTools: response.installedTools ?? [],
|
||||
sessions: response.sessions ?? [],
|
||||
});
|
||||
} catch (error) {
|
||||
if (scanRequestRef.current !== requestId) return;
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void rescan();
|
||||
}, [rescan]);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button onClick={() => setDialogOpen(true)} type="button">
|
||||
<Import className="size-4" />
|
||||
Import sessions
|
||||
</Button>
|
||||
}
|
||||
description="Bring your conversation history from other coding tools into Cline. Imported sessions show up in your history and can be continued here."
|
||||
title="Import"
|
||||
/>
|
||||
<section className="max-w-2xl">
|
||||
{SESSION_IMPORT_TOOL_ORDER.map((tool, index) => (
|
||||
<div
|
||||
className={
|
||||
index === 0
|
||||
? "flex items-center justify-between gap-5 border-y py-4"
|
||||
: "flex items-center justify-between gap-5 border-b py-4"
|
||||
}
|
||||
key={tool}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{SESSION_IMPORT_TOOL_LABELS[tool]}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scan
|
||||
? toolStatus(tool, scan)
|
||||
: scanError
|
||||
? "Scan failed"
|
||||
: "Scanning…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{scanError ? (
|
||||
<p className="mt-4 text-sm text-destructive" role="alert">
|
||||
Couldn't scan for sessions: {scanError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
<ImportSessionsDialog
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
// Counts reflect whatever the dialog just imported.
|
||||
if (!open) void rescan();
|
||||
}}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
getProviderAuthKind,
|
||||
@@ -539,6 +540,7 @@ export function ProviderDetailContent({
|
||||
onDisconnect?: () => void;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const deviceUserCode = useOAuthUserCode(oauthLoginPending);
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
@@ -821,6 +823,14 @@ export function ProviderDetailContent({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{oauthLoginPending && deviceUserCode ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{apiKeyField ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
|
||||
@@ -78,6 +78,8 @@ import {
|
||||
loadProviderModelCatalog,
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import { preserveRoutineCron } from "@/lib/routine-schedule-cron";
|
||||
import { routineScheduleTimezone } from "@/lib/routine-schedule-timezone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CommandBadge,
|
||||
@@ -1021,7 +1023,7 @@ export function RoutineSchedulesContent({
|
||||
setCreateFormError("Choose a one-time date and time in the future.");
|
||||
return;
|
||||
}
|
||||
const cronPattern =
|
||||
const editedCronPattern =
|
||||
createForm.scheduleType === "daily"
|
||||
? buildCronPattern(
|
||||
WEEKDAY_OPTIONS.map((option) => option.value),
|
||||
@@ -1035,6 +1037,16 @@ export function RoutineSchedulesContent({
|
||||
createForm.scheduleMinute,
|
||||
)
|
||||
: undefined;
|
||||
const cronPattern =
|
||||
editingSchedule &&
|
||||
editingSchedule.cronPattern !== ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
? preserveRoutineCron(
|
||||
editingSchedule.cronPattern,
|
||||
parseCronPattern(editingSchedule.cronPattern),
|
||||
createForm,
|
||||
editedCronPattern,
|
||||
)
|
||||
: editedCronPattern;
|
||||
if (createForm.scheduleType === "weekly" && !cronPattern) {
|
||||
setCreateFormError("Select at least one weekday.");
|
||||
return;
|
||||
@@ -1077,6 +1089,10 @@ export function RoutineSchedulesContent({
|
||||
createForm.scheduleType === "once" ? "once" : "recurring",
|
||||
run_at: runAt,
|
||||
cron_pattern: cronPattern,
|
||||
timezone: routineScheduleTimezone(
|
||||
createForm.scheduleType,
|
||||
editingSchedule,
|
||||
),
|
||||
prompt,
|
||||
provider,
|
||||
model,
|
||||
@@ -1685,6 +1701,15 @@ export function RoutineSchedulesContent({
|
||||
|
||||
<div className="sm:col-span-2 space-y-3">
|
||||
<Label>Schedule</Label>
|
||||
{editingSchedule &&
|
||||
editingSchedule.cronPattern !==
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current cron expression:{" "}
|
||||
<code>{editingSchedule.cronPattern}</code>. Changing the
|
||||
timing controls replaces this expression.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border p-3">
|
||||
<div className="min-w-32 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-schedule-type">Frequency</Label>
|
||||
|
||||
@@ -11,6 +11,7 @@ const ALL_SETTINGS_SECTIONS = [
|
||||
"Voice",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Import",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
|
||||