mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac25347a4 | ||
|
|
5383014c3e | ||
|
|
8845c718a7 | ||
|
|
a2fca629b8 | ||
|
|
bfc8e62ea7 | ||
|
|
815de4442d | ||
|
|
bf4462e959 | ||
|
|
4eefc593ee | ||
|
|
4315517fca | ||
|
|
df6c57e97a | ||
|
|
f5f3a19f9e | ||
|
|
168510800b | ||
|
|
a593ca4a6b | ||
|
|
76bc102066 | ||
|
|
9328710dce | ||
|
|
fd4636edc9 | ||
|
|
71fc9681e2 | ||
|
|
3512ae5f65 | ||
|
|
05d3404bab | ||
|
|
5de79a75d0 | ||
|
|
ab3acd6a8a | ||
|
|
c322ab2bbc | ||
|
|
72b714b6be | ||
|
|
f0de3a2c20 | ||
|
|
4ae53292d3 | ||
|
|
3d0531238e | ||
|
|
b318c84f52 | ||
|
|
9e0af5c010 | ||
|
|
97b0700151 | ||
|
|
b46cf77ed1 | ||
|
|
c85384431d | ||
|
|
be59305d7a | ||
|
|
833be95cfb | ||
|
|
595f1dbf2e | ||
|
|
3501d4b0e2 | ||
|
|
1caf264754 | ||
|
|
4d28d82efa | ||
|
|
b427fae9f9 | ||
|
|
b9977a139f | ||
|
|
6d5a9793fc | ||
|
|
2ad2a41b56 | ||
|
|
9b1374059e | ||
|
|
8eb5f3d57f | ||
|
|
0852992f3b | ||
|
|
4ab091b959 | ||
|
|
7a6beb9f0d | ||
|
|
f5370ad4cf |
@@ -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.
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.61
|
||||
|
||||
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
|
||||
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
|
||||
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
|
||||
- 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
|
||||
- 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 binary while working in dev
|
||||
- Restoring a checkpoint now refuses to run when you have made commits 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 VS Code Rules tab writes them on WSL and headless installs
|
||||
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
|
||||
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
|
||||
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
|
||||
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
|
||||
- Fixed scheduled tasks disappearing after a hub restart
|
||||
- Fixed markdown flashing as it settled at the end of a streamed response
|
||||
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
|
||||
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
|
||||
- Refreshed the 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 across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
|
||||
|
||||
## 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
|
||||
|
||||
+7
-1
@@ -91,6 +91,12 @@ Cline CLI runs in a few different shapes depending on what you need:
|
||||
- Yolo: `cline --yolo "..."` skips approval prompts and exits when the turn finishes
|
||||
- Zen: `cline --zen "..."` fires the task to the background hub daemon and exits immediately (see below)
|
||||
|
||||
### Computer use (experimental)
|
||||
|
||||
Start qbt before the interactive CLI and set `CLINE_COMPUTER_USE_PORT` to its agent port. The computer-user helper requires a configured direct Anthropic provider. Optionally set `CLINE_COMPUTER_USE_BACKEND_COMMAND` to a shell command that starts qbt: this adds `computer_user_restart_backend` for recovery when qbt becomes unreachable, not automatic startup. Set these variables before launching the CLI and restart it after changes.
|
||||
|
||||
See the [computer-use setup and backend recovery command](../../sdk/packages/core/src/extensions/computer-use/README.md#backend-recovery-command) for a Windows example, shell rules, ports, and process ownership.
|
||||
|
||||
## Headless mode for CI/CD
|
||||
|
||||
Run Cline with zero interaction for scripting and automation. Pipe input, get JSON output, chain commands, integrate into CI/CD pipelines.
|
||||
@@ -215,7 +221,7 @@ cline connect --stop
|
||||
cline connect --stop telegram
|
||||
```
|
||||
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cwd <path>`, `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cd <path>` (also `/cwd <path>`), `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
|
||||
### Schedules
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.60",
|
||||
"version": "3.0.61",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The Telegram connector uses the shared connector command parser:
|
||||
- `/whereami` - show thread, cwd, tools, and yolo state
|
||||
- `/tools [on|off|toggle]` - allow or block repo/file/shell tools
|
||||
- `/yolo [on|off|toggle]` - auto-approve tool use
|
||||
- `/cwd <path>` - change working directory
|
||||
- `/cd <path>` or `/cwd <path>` - change working directory
|
||||
- `/schedule create/list/trigger/delete` - manage scheduled workflows
|
||||
- `/abort` - stop the current task
|
||||
- `/exit` - stop the connector
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
{ mode: startRequest.mode, cwd: startRequest.cwd },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
|
||||
@@ -576,6 +576,53 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("warns that computer use is unavailable when a prompt argument is used", async () => {
|
||||
const stdout = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
process.env.CLINE_COMPUTER_USE_PORT = "1234";
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(stdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"computer use is only available in interactive mode",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.CLINE_COMPUTER_USE_PORT;
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not warn about computer use in interactive mode", async () => {
|
||||
const stdout = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
process.env.CLINE_COMPUTER_USE_PORT = "1234";
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(stdout).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"computer use is only available in interactive mode",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.CLINE_COMPUTER_USE_PORT;
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a single bare positional prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
|
||||
+30
-9
@@ -32,6 +32,7 @@ import {
|
||||
normalizeAutoApproveArgs,
|
||||
resolveWorkspaceRoot,
|
||||
} from "./utils/helpers";
|
||||
import { createMutableUserInstructionConfigService } from "./utils/mutable-user-instruction-service";
|
||||
import {
|
||||
c,
|
||||
installStreamErrorGuards,
|
||||
@@ -915,15 +916,22 @@ export async function runCli(): Promise<void> {
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
includePluginSkills: true,
|
||||
cwd,
|
||||
},
|
||||
rules: { workspacePath: workspaceRoot },
|
||||
workflows: { workspacePath: workspaceRoot },
|
||||
});
|
||||
const createCliUserInstructionService = (location: {
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}) =>
|
||||
createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: location.workspaceRoot,
|
||||
includePluginSkills: true,
|
||||
cwd: location.cwd,
|
||||
},
|
||||
rules: { workspacePath: location.workspaceRoot },
|
||||
workflows: { workspacePath: location.workspaceRoot },
|
||||
});
|
||||
const userInstructionService = createMutableUserInstructionConfigService(
|
||||
createCliUserInstructionService({ cwd, workspaceRoot }),
|
||||
);
|
||||
await userInstructionService.start().catch(() => {});
|
||||
let userInstructionServiceDisposed = false;
|
||||
const stopUserInstructionService = () => {
|
||||
@@ -990,6 +998,16 @@ export async function runCli(): Promise<void> {
|
||||
(!process.stdin.isTTY && !args.interactive);
|
||||
const isInteractive = (args.interactive || !args.prompt) && !isHeadless;
|
||||
|
||||
// Computer-use is wired only into the interactive runtime, so any other
|
||||
// path yields a session with no `computer` tool. The model then reports
|
||||
// having no such tool, which reads like a backend fault rather than a
|
||||
// consequence of how cline was invoked.
|
||||
if (process.env.CLINE_COMPUTER_USE_PORT?.trim() && !isInteractive) {
|
||||
writeln(
|
||||
`${c.dim}[warn] CLINE_COMPUTER_USE_PORT is set, but computer use is only available in interactive mode, so the "computer" tool will not be registered for this run. Start cline without a prompt argument (and without --yolo/--zen/--output json) to use it.${c.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey && isOAuthProvider(provider) && !isHeadless && !isInteractive) {
|
||||
const oauthResult = await ensureOAuthProviderApiKey({
|
||||
providerId: provider,
|
||||
@@ -1206,6 +1224,9 @@ export async function runCli(): Promise<void> {
|
||||
await runInteractive(config, userInstructionService, resumeSessionId, {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
mutableUserInstructionService: userInstructionService,
|
||||
createUserInstructionService: createCliUserInstructionService,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
abortActiveRuntime,
|
||||
acquireAbortRejectionShield,
|
||||
cleanupActiveRuntime,
|
||||
clearAbortInProgress,
|
||||
isAbortInProgress,
|
||||
markAbortInProgress,
|
||||
setActiveRuntimeAbort,
|
||||
setActiveRuntimeCleanup,
|
||||
} from "./active-runtime";
|
||||
@@ -36,4 +40,26 @@ describe("active runtime hooks", () => {
|
||||
|
||||
expect(() => cleanupActiveRuntime()).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps abort rejection shielding active until overlapping aborts clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
markAbortInProgress();
|
||||
const releaseHelperAbort = acquireAbortRejectionShield();
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
|
||||
clearAbortInProgress();
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
|
||||
releaseHelperAbort();
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(isAbortInProgress()).toBe(false);
|
||||
} finally {
|
||||
clearAbortInProgress();
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
let activeRuntimeAbort: (() => void) | undefined;
|
||||
let activeRuntimeCleanup: (() => void) | undefined;
|
||||
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortInProgress = false;
|
||||
let abortScopeCount = 0;
|
||||
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
|
||||
let activeRuntimeAbortRelease: (() => void) | undefined;
|
||||
|
||||
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
|
||||
activeRuntimeAbort = abortFn;
|
||||
@@ -35,35 +36,61 @@ export function cleanupActiveRuntime(): void {
|
||||
// correctly (returns finishReason:"aborted"), but orphan rejections from
|
||||
// the streaming layer or hub capability teardown surface as
|
||||
// unhandledRejections and would otherwise crash the CLI.
|
||||
export function acquireAbortRejectionShield(): () => void {
|
||||
abortScopeCount += 1;
|
||||
if (abortScopeCount === 1) {
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
abortGraceTimer = undefined;
|
||||
}
|
||||
if (!savedRejectionListeners) {
|
||||
// Temporarily replace all unhandledRejection listeners with a
|
||||
// suppressing handler. AbortController.abort() causes orphan promise
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners(
|
||||
"unhandledRejection",
|
||||
) as Array<(...args: unknown[]) => void>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
releaseAbortRejectionShield();
|
||||
};
|
||||
}
|
||||
|
||||
export function markAbortInProgress(): void {
|
||||
if (abortInProgress) {
|
||||
return;
|
||||
}
|
||||
abortInProgress = true;
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
abortGraceTimer = undefined;
|
||||
}
|
||||
// Temporarily replace all unhandledRejection listeners with a
|
||||
// suppressing handler. AbortController.abort() causes orphan promise
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
});
|
||||
activeRuntimeAbortRelease ??= acquireAbortRejectionShield();
|
||||
}
|
||||
|
||||
export function clearAbortInProgress(): void {
|
||||
const release = activeRuntimeAbortRelease;
|
||||
activeRuntimeAbortRelease = undefined;
|
||||
release?.();
|
||||
}
|
||||
|
||||
function releaseAbortRejectionShield(): void {
|
||||
if (abortScopeCount === 0) {
|
||||
return;
|
||||
}
|
||||
abortScopeCount -= 1;
|
||||
if (abortScopeCount > 0) {
|
||||
return;
|
||||
}
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
}
|
||||
abortGraceTimer = setTimeout(() => {
|
||||
abortInProgress = false;
|
||||
abortGraceTimer = undefined;
|
||||
if (savedRejectionListeners) {
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
@@ -76,5 +103,5 @@ export function clearAbortInProgress(): void {
|
||||
}
|
||||
|
||||
export function isAbortInProgress(): boolean {
|
||||
return abortInProgress;
|
||||
return abortScopeCount > 0 || abortGraceTimer !== undefined;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,15 @@ function makeState(config: Config): ChatCommandState {
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(): InteractiveChatCommandRuntime {
|
||||
function makeRuntime(): InteractiveChatCommandRuntime & {
|
||||
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
forkCurrentSession: vi.fn(async () => undefined),
|
||||
getActiveSessionId: vi.fn(() => "session-1"),
|
||||
resetForNewSession: vi.fn(async () => {}),
|
||||
restartEmpty: vi.fn(async () => {}),
|
||||
changeWorkingDirectory: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -90,6 +94,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -116,6 +121,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -149,6 +155,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -164,6 +171,72 @@ describe("runInteractiveChatCommand", () => {
|
||||
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("changes the runtime working directory before reporting /cd success", async () => {
|
||||
const config = makeConfig();
|
||||
const state = makeState(config);
|
||||
const runtime = makeRuntime();
|
||||
const target = process.cwd();
|
||||
state.cwd = "/tmp";
|
||||
state.workspaceRoot = "/tmp";
|
||||
vi.mocked(runtime.changeWorkingDirectory).mockImplementation(
|
||||
async (next) => {
|
||||
Object.assign(state, next);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runInteractiveChatCommand({
|
||||
prompt: `/cd ${target}`,
|
||||
enabled: true,
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: state,
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
expect(runtime.changeWorkingDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: target }),
|
||||
);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(result).toMatchObject({
|
||||
handled: true,
|
||||
turnResult: { commandOutput: expect.stringContaining(`cwd=${target}`) },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run /cd when the submission is queued behind an active turn", async () => {
|
||||
const config = makeConfig();
|
||||
const state = makeState(config);
|
||||
const runtime = makeRuntime();
|
||||
const target = process.cwd();
|
||||
state.cwd = "/tmp";
|
||||
state.workspaceRoot = "/tmp";
|
||||
|
||||
await expect(
|
||||
runInteractiveChatCommand({
|
||||
prompt: `/cd ${target}`,
|
||||
enabled: true,
|
||||
delivery: "queue",
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: state,
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
|
||||
expect(runtime.changeWorkingDirectory).not.toHaveBeenCalled();
|
||||
expect(state).toMatchObject({ cwd: "/tmp", workspaceRoot: "/tmp" });
|
||||
});
|
||||
|
||||
it("returns plugin command submit prompts as model input", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
@@ -185,6 +258,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
onCommandOutput,
|
||||
});
|
||||
|
||||
@@ -39,12 +39,14 @@ function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
|
||||
export async function runInteractiveChatCommand(input: {
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
delivery?: "queue" | "steer";
|
||||
config: Config;
|
||||
host: ChatCommandHost;
|
||||
chatCommandState: ChatCommandState;
|
||||
autoApproveAllRef: AutoApproveRef;
|
||||
setInteractiveAutoApprove: (enabled: boolean) => void;
|
||||
sessionRuntime: InteractiveChatCommandRuntime;
|
||||
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
|
||||
stop: () => void;
|
||||
onCommandOutput?: (text: string) => void;
|
||||
}): Promise<InteractiveChatCommandResult> {
|
||||
@@ -74,10 +76,22 @@ export async function runInteractiveChatCommand(input: {
|
||||
autoApproveTools: input.autoApproveAllRef.current,
|
||||
}),
|
||||
setState: async (next) => {
|
||||
input.chatCommandState.enableTools = next.enableTools;
|
||||
input.chatCommandState.autoApproveTools = next.autoApproveTools;
|
||||
input.chatCommandState.cwd = next.cwd;
|
||||
input.chatCommandState.workspaceRoot = next.workspaceRoot;
|
||||
if (
|
||||
next.cwd !== input.chatCommandState.cwd ||
|
||||
next.workspaceRoot !== input.chatCommandState.workspaceRoot
|
||||
) {
|
||||
// Workspace resources and the replacement session change together at
|
||||
// an immediate submission boundary; deferred prompts cannot replay CLI
|
||||
// commands after the active turn finishes.
|
||||
if (input.delivery) {
|
||||
throw new Error(
|
||||
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
await input.changeWorkingDirectory(next);
|
||||
} else {
|
||||
Object.assign(input.chatCommandState, next);
|
||||
}
|
||||
input.setInteractiveAutoApprove(next.autoApproveTools);
|
||||
},
|
||||
reply: async (text) => {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
type AddressInfo,
|
||||
createServer,
|
||||
type Server,
|
||||
type Socket,
|
||||
} from "node:net";
|
||||
import type { AgentHooks, AgentResult, AgentToolContext } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
createInteractiveComputerUser,
|
||||
resolveHelperModelId,
|
||||
withHelperReasoningControls,
|
||||
} from "./computer-user";
|
||||
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const releaseAbortRejectionShieldMock = vi.hoisted(() => vi.fn());
|
||||
const acquireAbortRejectionShieldMock = vi.hoisted(() =>
|
||||
vi.fn(() => releaseAbortRejectionShieldMock),
|
||||
);
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
acquireAbortRejectionShield: acquireAbortRejectionShieldMock,
|
||||
}));
|
||||
|
||||
const toolContext: AgentToolContext = {
|
||||
agentId: "driver-agent",
|
||||
conversationId: "driver-conversation",
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Stub qbt backend answering get_display_info, which tool construction
|
||||
* always performs (the backend is the sole source of truth for display
|
||||
* dimensions). Tracks sockets so teardown can force-close the tool's
|
||||
* internal client connection.
|
||||
*/
|
||||
function startStubBackend(): Promise<{
|
||||
server: Server;
|
||||
port: number;
|
||||
destroyConnections: () => void;
|
||||
}> {
|
||||
const sockets = new Set<Socket>();
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((socket: Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
let buffer = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.trim().length > 0) {
|
||||
const request = JSON.parse(line) as { id: number };
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
display: { widthPx: 1920, heightPx: 1080 },
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve({
|
||||
server,
|
||||
port: address.port,
|
||||
destroyConnections: () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
cwd: "C:/work",
|
||||
workspaceRoot: "C:/work",
|
||||
} as Config;
|
||||
}
|
||||
|
||||
function makeSettings(settings: Record<string, unknown> | undefined) {
|
||||
return {
|
||||
getProviderSettings: () => settings as never,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult(overrides: Partial<AgentResult> = {}): AgentResult {
|
||||
return {
|
||||
text: "done",
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
...overrides,
|
||||
} as AgentResult;
|
||||
}
|
||||
|
||||
describe("createInteractiveComputerUser", () => {
|
||||
let server: Server | undefined;
|
||||
let destroyConnections: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
createCliCoreMock.mockReset();
|
||||
releaseAbortRejectionShieldMock.mockReset();
|
||||
acquireAbortRejectionShieldMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyConnections?.();
|
||||
destroyConnections = undefined;
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
it("returns undefined when computer use is not enabled by env", async () => {
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the Anthropic provider has no api key", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings(undefined),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes the driver tools when enabled and configured", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.driverTools.map((tool) => tool.name).sort()).toEqual([
|
||||
"computer_user_interrupt",
|
||||
"computer_user_message",
|
||||
"computer_user_restart",
|
||||
"computer_user_start",
|
||||
"computer_user_status",
|
||||
"computer_user_transcript",
|
||||
]);
|
||||
// The raw computer tool must not be among the driver's tools.
|
||||
expect(result?.driverTools.some((tool) => tool.name === "computer")).toBe(
|
||||
false,
|
||||
);
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("adds the backend restart tool only when a launch command is configured", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
CLINE_COMPUTER_USE_BACKEND_COMMAND: "echo start-the-backend",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(
|
||||
result?.driverTools
|
||||
.map((tool) => tool.name)
|
||||
.includes("computer_user_restart_backend"),
|
||||
).toBe(true);
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("keeps transcript session identities across helper replacement", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
const hooks: AgentHooks[] = [];
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start: vi.fn(async ({ config }: { config: { hooks: AgentHooks } }) => {
|
||||
hooks.push(config.hooks);
|
||||
return { sessionId: `helper-${hooks.length}` };
|
||||
}),
|
||||
send: vi.fn(async () => makeResult()),
|
||||
abort: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: { CLINE_COMPUTER_USE_PORT: String(started.port) },
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
if (!result) throw new Error("computer user was not configured");
|
||||
const tool = (name: string) => {
|
||||
const found = result.driverTools.find((tool) => tool.name === name);
|
||||
if (!found) throw new Error(`missing tool ${name}`);
|
||||
return found;
|
||||
};
|
||||
const recordMessage = (hook: AgentHooks, text: string) =>
|
||||
hook.onEvent?.({
|
||||
type: "message-added",
|
||||
snapshot: { agentId: "helper-agent" } as never,
|
||||
message: {
|
||||
id: text,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
createdAt: 0,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await tool("computer_user_start").execute({ task: "first" }, toolContext);
|
||||
await recordMessage(hooks[0], "first");
|
||||
await tool("computer_user_restart").execute({}, toolContext);
|
||||
await tool("computer_user_start").execute(
|
||||
{ task: "second" },
|
||||
toolContext,
|
||||
);
|
||||
await recordMessage(hooks[1], "second");
|
||||
await recordMessage(hooks[0], "late first");
|
||||
const transcript = await tool("computer_user_transcript").execute(
|
||||
{},
|
||||
toolContext,
|
||||
);
|
||||
expect(transcript).toMatchObject({
|
||||
entries: [
|
||||
{ sessionId: "helper-1", text: "first" },
|
||||
{ sessionId: "helper-2", text: "second" },
|
||||
{ sessionId: "helper-1", text: "late first" },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await result.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the helper with one moderate adaptive reasoning snapshot", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
const start = vi.fn(
|
||||
async (_input: {
|
||||
config: Record<string, unknown>;
|
||||
interactive: boolean;
|
||||
}) => ({ sessionId: "helper-session" }),
|
||||
);
|
||||
const send = vi.fn(() => new Promise(() => {}));
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start,
|
||||
send,
|
||||
abort: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const driverConfig = {
|
||||
...makeConfig(),
|
||||
thinking: true,
|
||||
reasoningEffort: "high" as const,
|
||||
};
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: driverConfig,
|
||||
providerSettingsManager: makeSettings({
|
||||
provider: "anthropic",
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-5",
|
||||
client: "openai",
|
||||
protocol: "openai-responses",
|
||||
routingProviderId: "openai-native",
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort: "low",
|
||||
budgetTokens: 8192,
|
||||
},
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const startTool = result?.driverTools.find(
|
||||
(tool) => tool.name === "computer_user_start",
|
||||
);
|
||||
|
||||
await startTool?.execute({ task: "inspect the desktop" }, toolContext);
|
||||
|
||||
expect(start).toHaveBeenCalledWith({
|
||||
interactive: true,
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
providerConfig: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
clientType: undefined,
|
||||
routingProviderId: undefined,
|
||||
thinkingBudgetTokens: undefined,
|
||||
knownModels: expect.objectContaining({
|
||||
"claude-sonnet-5": expect.objectContaining({
|
||||
reasoningOptions: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(start.mock.calls[0]?.[0]?.config).not.toHaveProperty(
|
||||
"thinkingBudgetTokens",
|
||||
);
|
||||
expect(driverConfig).toMatchObject({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("shields abort rejections until the helper run is quiescent", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
let resolveSend: ((result: AgentResult) => void) | undefined;
|
||||
const send = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentResult>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
const abort = vi.fn(async () => {});
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start: vi.fn(async () => ({ sessionId: "helper-session" })),
|
||||
send,
|
||||
abort,
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const byName = new Map(
|
||||
result?.driverTools.map((tool) => [tool.name, tool]) ?? [],
|
||||
);
|
||||
await byName
|
||||
.get("computer_user_start")
|
||||
?.execute({ task: "inspect the desktop" }, toolContext);
|
||||
|
||||
let stopped = false;
|
||||
const interruption = byName
|
||||
.get("computer_user_interrupt")
|
||||
?.execute({ reason: "no progress" }, toolContext) as Promise<unknown>;
|
||||
const observedInterruption = interruption.then((output) => {
|
||||
stopped = true;
|
||||
return output;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(abort).toHaveBeenCalledWith(
|
||||
"helper-session",
|
||||
expect.objectContaining({ message: "no progress" }),
|
||||
);
|
||||
});
|
||||
expect(acquireAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
|
||||
expect(releaseAbortRejectionShieldMock).not.toHaveBeenCalled();
|
||||
expect(stopped).toBe(false);
|
||||
|
||||
resolveSend?.(makeResult({ finishReason: "aborted" }));
|
||||
await expect(observedInterruption).resolves.toMatchObject({
|
||||
status: "stopped",
|
||||
});
|
||||
expect(releaseAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
|
||||
await result?.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withHelperReasoningControls", () => {
|
||||
it("declares adaptive effort controls for the helper model", () => {
|
||||
const result = withHelperReasoningControls(undefined, "claude-sonnet-5");
|
||||
expect(result["claude-sonnet-5"]).toEqual({
|
||||
id: "claude-sonnet-5",
|
||||
reasoningOptions: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves other catalog entries and the helper model's own facts", () => {
|
||||
const result = withHelperReasoningControls(
|
||||
{
|
||||
"claude-sonnet-5": {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
"claude-opus-4-7": { id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
},
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
expect(result["claude-sonnet-5"]).toMatchObject({
|
||||
name: "Claude Sonnet 5",
|
||||
contextWindow: 1000000,
|
||||
});
|
||||
expect(result["claude-opus-4-7"]).toEqual({
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHelperModelId", () => {
|
||||
it("prefers CLINE_COMPUTER_USER_MODEL over saved provider model", () => {
|
||||
expect(
|
||||
resolveHelperModelId({ model: "claude-sonnet-4-6" }, {
|
||||
CLINE_COMPUTER_USER_MODEL: "claude-opus-4-7",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("removes the redundant namespace for the direct Anthropic provider", () => {
|
||||
expect(
|
||||
resolveHelperModelId(undefined, {
|
||||
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("falls back to the Anthropic provider entry's saved model", () => {
|
||||
expect(
|
||||
resolveHelperModelId(
|
||||
{ model: "claude-haiku-4-5" },
|
||||
{} as NodeJS.ProcessEnv,
|
||||
),
|
||||
).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("defaults when neither env nor settings specify a model", () => {
|
||||
expect(resolveHelperModelId(undefined, {} as NodeJS.ProcessEnv)).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(
|
||||
resolveHelperModelId({ model: " " }, {
|
||||
CLINE_COMPUTER_USER_MODEL: " ",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
type AgentHooks,
|
||||
type ClineCore,
|
||||
COMPUTER_USER_SYSTEM_PROMPT,
|
||||
ComputerBackendRestart,
|
||||
ComputerTaskArtifactRecorder,
|
||||
ComputerUseClient,
|
||||
ComputerUserCoordinator,
|
||||
ComputerUserTranscriptLog,
|
||||
createComputerUserCollaborationTools,
|
||||
createComputerUserDriverTools,
|
||||
createComputerUseTool,
|
||||
createJournalEventSink,
|
||||
createTranscriptRecordingHooks,
|
||||
type ProviderSettingsManager,
|
||||
resolveComputerUseBackendCommandFromEnv,
|
||||
resolveComputerUseTargetFromEnv,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { AgentTool, ModelInfo, ModelReasoningOption } from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
import { createCliCore } from "../../session/session";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { acquireAbortRejectionShield } from "../active-runtime";
|
||||
|
||||
/**
|
||||
* CLI host integration for the asynchronous computer user.
|
||||
*
|
||||
* The driver session gets four `computer_user_*` tools; the helper runs as a
|
||||
* dedicated interactive ClineCore session on the Anthropic provider (the
|
||||
* computer-use beta header requires the direct provider — see qwanban's
|
||||
* README). Enabled by the same `CLINE_COMPUTER_USE_PORT` opt-in as the raw
|
||||
* `computer` tool; when the coordinator is active the driver deliberately
|
||||
* does NOT get the raw tool, so all GUI work flows through the helper.
|
||||
*
|
||||
* Helper consistency boundary: provider, credentials, reasoning, tool
|
||||
* inventory, and prompt are resolved here, once, when the runtime starts.
|
||||
* Changing them requires a new CLI session.
|
||||
*/
|
||||
|
||||
const HELPER_PROVIDER_ID = "anthropic";
|
||||
const HELPER_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
|
||||
const HELPER_MODEL_ENV_VAR = "CLINE_COMPUTER_USER_MODEL";
|
||||
const HELPER_REASONING = {
|
||||
thinking: true,
|
||||
reasoningEffort: "medium" as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning controls declared for the helper's model, in the models.dev
|
||||
* shape the Anthropic provider routing reads. The bundled model catalog
|
||||
* ships without `reasoningOptions`, which the routing treats as an
|
||||
* unlisted model with manual-only thinking and encodes as
|
||||
* `thinking.type.enabled`; current Claude models reject that shape and
|
||||
* require `thinking.type.adaptive` with an effort level. Declaring the
|
||||
* controls here keeps the helper on the adaptive wire shape regardless of
|
||||
* catalog state.
|
||||
*/
|
||||
const HELPER_MODEL_REASONING_OPTIONS: ModelReasoningOption[] = [
|
||||
{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] },
|
||||
];
|
||||
|
||||
function toDirectAnthropicModelId(modelId: string): string {
|
||||
const directProviderPrefix = `${HELPER_PROVIDER_ID}/`;
|
||||
return modelId.startsWith(directProviderPrefix)
|
||||
? modelId.slice(directProviderPrefix.length)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the provider config's model catalog with the helper's reasoning
|
||||
* controls declared for the helper model, preserving every other entry.
|
||||
*/
|
||||
export function withHelperReasoningControls(
|
||||
knownModels: Record<string, ModelInfo> | undefined,
|
||||
modelId: string,
|
||||
): Record<string, ModelInfo> {
|
||||
return {
|
||||
...knownModels,
|
||||
[modelId]: {
|
||||
...knownModels?.[modelId],
|
||||
id: modelId,
|
||||
reasoningOptions: HELPER_MODEL_REASONING_OPTIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the helper's Anthropic model id. The helper's model is chosen
|
||||
* independently of the driver's: CLINE_COMPUTER_USER_MODEL wins, then the
|
||||
* Anthropic provider entry's saved `model`, then the default. The provider
|
||||
* is always the direct `anthropic` provider — the computer-use beta header
|
||||
* is only sent on that wire target, so Anthropic models reached through
|
||||
* other providers (cline, openrouter, bedrock) would lack the extended
|
||||
* action set.
|
||||
*/
|
||||
export function resolveHelperModelId(
|
||||
helperSettings: { model?: unknown } | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
const fromEnv = env[HELPER_MODEL_ENV_VAR]?.trim();
|
||||
if (fromEnv) {
|
||||
return toDirectAnthropicModelId(fromEnv);
|
||||
}
|
||||
if (
|
||||
typeof helperSettings?.model === "string" &&
|
||||
helperSettings.model.trim()
|
||||
) {
|
||||
return toDirectAnthropicModelId(helperSettings.model.trim());
|
||||
}
|
||||
return HELPER_DEFAULT_MODEL_ID;
|
||||
}
|
||||
|
||||
export interface InteractiveComputerUser {
|
||||
driverTools: AgentTool[];
|
||||
/**
|
||||
* Hooks layer to merge into the driver session's config: records the
|
||||
* driver's transcript and run status to the backend journal alongside
|
||||
* the helper's, so the observatory can flip between both timelines.
|
||||
*/
|
||||
driverRecordingHooks: AgentHooks;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createInteractiveComputerUser(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<ProviderSettingsManager, "getProviderSettings">;
|
||||
/**
|
||||
* Injects a prompt into the driver's conversation. Must resolve the
|
||||
* driver session id at call time (session rebuilds change it), which
|
||||
* `sessionRuntime.sendCurrentTurn` does.
|
||||
*/
|
||||
notifyDriver: (prompt: string, delivery: "queue" | "steer") => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<InteractiveComputerUser | undefined> {
|
||||
// Check the local precondition (credentials) before dialing the backend:
|
||||
// tool construction queries the backend for display info and holds a
|
||||
// socket, which would be wasted if the helper cannot be configured.
|
||||
const helperSettings =
|
||||
input.providerSettingsManager.getProviderSettings(HELPER_PROVIDER_ID);
|
||||
const helperApiKey =
|
||||
typeof helperSettings?.apiKey === "string" ? helperSettings.apiKey : "";
|
||||
if (!helperApiKey) {
|
||||
// No silent fallback to the driver's credentials: the helper requires
|
||||
// the Anthropic provider's own configuration.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const target = resolveComputerUseTargetFromEnv(input.env ?? process.env);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// One backend client shared by the computer tool and the observability
|
||||
// publisher. The backend serves a single agent connection at a time, so
|
||||
// splitting these across two sockets would make one of them dead.
|
||||
//
|
||||
// No client-side action observer: the backend journals every computer
|
||||
// action (with its screenshot) as it executes it, so recording actions
|
||||
// here too would give the journal two producers for one event type.
|
||||
const computerClient = new ComputerUseClient(target);
|
||||
const recorder = new ComputerTaskArtifactRecorder(
|
||||
`task_${nanoid(10)}`,
|
||||
createJournalEventSink(computerClient),
|
||||
);
|
||||
|
||||
const computerTool = await createComputerUseTool({
|
||||
...target,
|
||||
client: computerClient,
|
||||
});
|
||||
|
||||
// In-process tail of the helper's transcript. The driver's
|
||||
// computer_user_transcript tool reads it, so peeking works even while
|
||||
// the backend is down; the tee shares the recording hooks' reduction, so
|
||||
// what the tool shows is identical to what the observatory journals.
|
||||
const transcriptLog = new ComputerUserTranscriptLog();
|
||||
const backendRestart = (() => {
|
||||
const command = resolveComputerUseBackendCommandFromEnv(
|
||||
input.env ?? process.env,
|
||||
);
|
||||
return command
|
||||
? new ComputerBackendRestart({
|
||||
...target,
|
||||
command,
|
||||
client: computerClient,
|
||||
})
|
||||
: undefined;
|
||||
})();
|
||||
|
||||
const helperModelId = resolveHelperModelId(
|
||||
helperSettings,
|
||||
input.env ?? process.env,
|
||||
);
|
||||
// Helper model and reasoning settings become effective together when this
|
||||
// session is created. Keep the provider config and session config derived
|
||||
// from this snapshot so saved manual thinking budgets cannot conflict with
|
||||
// adaptive thinking on current Claude models. The model's reasoning
|
||||
// controls are declared explicitly: the bundled catalog ships without
|
||||
// them, and without them the Anthropic routing falls back to the manual
|
||||
// thinking shape those models reject.
|
||||
const baseProviderConfig = toProviderConfig({
|
||||
...helperSettings,
|
||||
provider: HELPER_PROVIDER_ID,
|
||||
model: helperModelId,
|
||||
client: undefined,
|
||||
protocol: undefined,
|
||||
routingProviderId: undefined,
|
||||
reasoning: {
|
||||
enabled: HELPER_REASONING.thinking,
|
||||
effort: HELPER_REASONING.reasoningEffort,
|
||||
},
|
||||
});
|
||||
const helperProviderConfig = {
|
||||
...baseProviderConfig,
|
||||
clientType: undefined,
|
||||
routingProviderId: undefined,
|
||||
thinkingBudgetTokens: undefined,
|
||||
knownModels: withHelperReasoningControls(
|
||||
baseProviderConfig.knownModels,
|
||||
helperModelId,
|
||||
),
|
||||
};
|
||||
|
||||
// The helper config and the coordinator reference each other (the
|
||||
// collaboration tools call back into the coordinator). Break the cycle
|
||||
// with one shared extraTools array: the coordinator captures the config
|
||||
// object now; the tools are pushed into the same array below, before any
|
||||
// session can start.
|
||||
const helperExtraTools: AgentTool[] = [computerTool];
|
||||
const helperConfig = {
|
||||
providerId: helperProviderConfig.providerId,
|
||||
modelId: helperProviderConfig.modelId,
|
||||
apiKey: helperProviderConfig.apiKey,
|
||||
baseUrl: helperProviderConfig.baseUrl,
|
||||
headers: helperProviderConfig.headers,
|
||||
knownModels: helperProviderConfig.knownModels,
|
||||
providerConfig: helperProviderConfig,
|
||||
...HELPER_REASONING,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot?.trim() || input.config.cwd,
|
||||
mode: "act" as const,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
pluginPaths: [],
|
||||
systemPrompt: COMPUTER_USER_SYSTEM_PROMPT,
|
||||
extraTools: helperExtraTools,
|
||||
toolPolicies: {
|
||||
// Questions and completion go to the driver through the
|
||||
// collaboration tools, never to a human or generic completion.
|
||||
ask_question: { enabled: false },
|
||||
submit_and_exit: { enabled: false },
|
||||
},
|
||||
// The helper's terminal tools are ask_driver/finish_computer_task
|
||||
// (extraTools with completesRun). Require them explicitly: the
|
||||
// builder's inference only recognizes submit_and_exit, which is
|
||||
// disabled above, and a run that ends in free-form text would leave
|
||||
// the driver waiting with no report.
|
||||
completionPolicy: { requireCompletionTool: true },
|
||||
};
|
||||
|
||||
// Lazy: the helper ClineCore spawns only when the driver first delegates.
|
||||
// forceLocalBackend keeps the helper in this process, where the
|
||||
// computer-use backend's loopback socket is reachable — a hub daemon may
|
||||
// run on a different machine from the controlled display.
|
||||
let helperCorePromise: Promise<ClineCore> | undefined;
|
||||
let activeHelperSend: Promise<unknown> | undefined;
|
||||
const getHelperCore = () => {
|
||||
helperCorePromise ??= createCliCore({
|
||||
forceLocalBackend: true,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
logger: input.config.logger,
|
||||
}).catch((error) => {
|
||||
helperCorePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return helperCorePromise;
|
||||
};
|
||||
|
||||
const coordinator = new ComputerUserCoordinator({
|
||||
host: {
|
||||
start: async (startInput) => {
|
||||
// Each session owns its recording source, so late events from a
|
||||
// stopped helper cannot be relabelled as its replacement's work.
|
||||
const source = {
|
||||
kind: "computer_user" as const,
|
||||
sessionId: undefined as string | undefined,
|
||||
};
|
||||
const started = await (await getHelperCore()).start({
|
||||
config: {
|
||||
...startInput.config,
|
||||
hooks: createTranscriptRecordingHooks(recorder, source, (event) =>
|
||||
transcriptLog.append(event),
|
||||
),
|
||||
} as never,
|
||||
interactive: startInput.interactive,
|
||||
});
|
||||
source.sessionId = started.sessionId;
|
||||
return started;
|
||||
},
|
||||
send: async (sendInput) => {
|
||||
const send = (await getHelperCore()).send(sendInput);
|
||||
if (sendInput.delivery === "steer") {
|
||||
return await send;
|
||||
}
|
||||
activeHelperSend = send;
|
||||
try {
|
||||
return await send;
|
||||
} finally {
|
||||
if (activeHelperSend === send) {
|
||||
activeHelperSend = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
abort: async (sessionId, reason) => {
|
||||
const releaseAbortShield = acquireAbortRejectionShield();
|
||||
try {
|
||||
await (await getHelperCore()).abort(sessionId, reason);
|
||||
} catch (error) {
|
||||
releaseAbortShield();
|
||||
throw error;
|
||||
}
|
||||
const abortedSend = activeHelperSend;
|
||||
if (!abortedSend) {
|
||||
releaseAbortShield();
|
||||
return;
|
||||
}
|
||||
// The coordinator owns waiting for this run to settle. The adapter
|
||||
// only keeps expected provider cancellation rejections shielded for
|
||||
// the same interval, without making disposal wait on host teardown.
|
||||
void abortedSend.finally(releaseAbortShield).catch(() => {});
|
||||
},
|
||||
stop: async (sessionId) => (await getHelperCore()).stop(sessionId),
|
||||
},
|
||||
helperConfig,
|
||||
notifyDriver: ({ prompt, delivery }) =>
|
||||
input.notifyDriver(prompt, delivery),
|
||||
recorder,
|
||||
transcriptLog,
|
||||
});
|
||||
helperExtraTools.push(...createComputerUserCollaborationTools(coordinator));
|
||||
|
||||
return {
|
||||
driverTools: createComputerUserDriverTools(coordinator, {
|
||||
backendRestart,
|
||||
}),
|
||||
driverRecordingHooks: createTranscriptRecordingHooks(recorder, {
|
||||
kind: "driver",
|
||||
}),
|
||||
dispose: async () => {
|
||||
await coordinator.dispose().catch(() => {});
|
||||
if (helperCorePromise) {
|
||||
const core = await helperCorePromise.catch(() => undefined);
|
||||
await core?.dispose().catch(() => {});
|
||||
}
|
||||
// Push any queued journal publishes out before dropping the
|
||||
// backend connection.
|
||||
await recorder.flush().catch(() => {});
|
||||
computerClient.close();
|
||||
// Release a backend this process spawned; a backend someone else
|
||||
// owns is left running.
|
||||
await backendRestart?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -270,4 +270,37 @@ describe("applyInteractiveModeConfig", () => {
|
||||
expect(config.extraTools).toEqual([]);
|
||||
expect(config.systemPrompt).toBe("system prompt for act");
|
||||
});
|
||||
|
||||
it("keeps persistent extra tools across plan/act switches", async () => {
|
||||
const config = makeConfig();
|
||||
const computerUserTool = {
|
||||
...switchToActModeTool,
|
||||
name: "computer_user_start",
|
||||
};
|
||||
const persistentExtraTools = [computerUserTool];
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "act",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([computerUserTool]);
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,14 +115,30 @@ export {
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
/**
|
||||
* Builds the extraTools list for an interactive mode. The single derivation
|
||||
* used both at startup and on every mode switch, so mode-independent tools
|
||||
* (e.g. the computer-user tools) cannot be silently dropped by a switch.
|
||||
*/
|
||||
export function buildInteractiveExtraTools(input: {
|
||||
mode: InteractiveUiMode;
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
}): NonNullable<Config["extraTools"]> {
|
||||
return [
|
||||
...(input.mode === "plan" ? [input.switchToActModeTool] : []),
|
||||
...(input.persistentExtraTools ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
}): Promise<void> {
|
||||
input.config.mode = input.mode;
|
||||
input.config.extraTools =
|
||||
input.mode === "plan" ? [input.switchToActModeTool] : [];
|
||||
input.config.extraTools = buildInteractiveExtraTools(input);
|
||||
input.config.systemPrompt = await resolveSystemPrompt({
|
||||
cwd: input.config.cwd,
|
||||
providerId: input.config.providerId,
|
||||
|
||||
@@ -22,6 +22,7 @@ const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
const resolveSystemPromptMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
@@ -47,6 +48,10 @@ vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: resolveSystemPromptMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
@@ -233,10 +238,12 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
resolveSystemPromptMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
resolveSystemPromptMock.mockResolvedValue("rebuilt system prompt");
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
@@ -587,6 +594,107 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("restarts the active session with one working-directory snapshot", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
chatCommandState: state,
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
|
||||
expect(resolveSystemPromptMock).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/next-project",
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
providerId: "anthropic",
|
||||
mode: "act",
|
||||
});
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createRuntimeHooksMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the previous working-directory snapshot when restart fails", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
manager.start.mockRejectedValueOnce(new Error("replacement failed"));
|
||||
|
||||
await expect(
|
||||
runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/failed-project",
|
||||
workspaceRoot: "/tmp/failed-project",
|
||||
}),
|
||||
).rejects.toThrow("replacement failed");
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(3);
|
||||
expect(manager.start.mock.calls[2]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
type CoreSettingsListInput,
|
||||
type CoreSettingsMutationResult,
|
||||
type CoreSettingsSnapshot,
|
||||
type CoreSettingsToggleInput,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
mergeAgentHooks,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
@@ -27,6 +33,7 @@ import { setActiveCliSession } from "../../utils/output";
|
||||
import { loadInteractiveResumeMessages } from "../../utils/resume";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { markAbortInProgress } from "../active-runtime";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
@@ -93,6 +100,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
explicitSystemPrompt?: string;
|
||||
resumeSessionId?: string;
|
||||
chatCommandState: ChatCommandState;
|
||||
requestToolApproval: (
|
||||
@@ -102,6 +110,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
askQuestionRef: AskQuestionRef;
|
||||
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
/**
|
||||
* Mode-independent extra tools (e.g. the computer-user tools) that must
|
||||
* survive plan/act switches. Rebuilt into config.extraTools on every
|
||||
* mode change alongside the mode-dependent switch tool.
|
||||
*/
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
/**
|
||||
* Host-supplied hooks layer (e.g. computer-use transcript recording)
|
||||
* merged after the runtime's own hooks on every session build.
|
||||
*/
|
||||
extraAgentHooks?: AgentHooks;
|
||||
onAgentEvent: (event: AgentEvent) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
onPendingPrompts: (event: PendingPromptSnapshot) => void;
|
||||
@@ -126,6 +145,20 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
const createWorkspaceRuntimeHooks = (
|
||||
manager: CliCore,
|
||||
workspace: Pick<ChatCommandState, "cwd" | "workspaceRoot">,
|
||||
): RuntimeHooks =>
|
||||
createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: workspace.cwd,
|
||||
workspaceRoot: workspace.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
|
||||
const clearActiveSession = (): void => {
|
||||
activeSessionId = "";
|
||||
setActiveCliSession(undefined);
|
||||
@@ -175,15 +208,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
throw new Error("interactive runtime shutdown requested");
|
||||
}
|
||||
sessionManager = manager;
|
||||
runtimeHooks = createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
runtimeHooks = createWorkspaceRuntimeHooks(manager, input.chatCommandState);
|
||||
unsubscribeAgent = subscribeToAgentEvents(manager, input.onAgentEvent);
|
||||
unsubscribePendingPrompts = subscribeToPendingPromptEvents(manager, {
|
||||
onPendingPrompts: input.onPendingPrompts,
|
||||
@@ -197,7 +222,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const hooks = withInteractiveApprovalPolicyHook(
|
||||
runtimeHooks.hooks,
|
||||
mergeAgentHooks([runtimeHooks.hooks, input.extraAgentHooks]),
|
||||
input.resolveToolPolicy,
|
||||
);
|
||||
return buildInteractiveSessionConfig({
|
||||
@@ -299,6 +324,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;
|
||||
@@ -424,14 +462,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
options?: { preserveSessionId?: boolean; sessionId?: string },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
const reuseSessionId =
|
||||
options?.sessionId ??
|
||||
(options?.preserveSessionId ? activeSessionId || undefined : undefined);
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
@@ -494,6 +532,99 @@ export function createInteractiveSessionRuntime(input: {
|
||||
);
|
||||
};
|
||||
|
||||
const changeWorkingDirectory = async (
|
||||
next: ChatCommandState,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
throw new Error("interactive session manager is unavailable");
|
||||
}
|
||||
const sourceSessionId = activeSessionId;
|
||||
|
||||
const [{ messages, status }, compactionState, systemPrompt] =
|
||||
await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
resolveSystemPrompt({
|
||||
cwd: next.cwd,
|
||||
explicitSystemPrompt: input.explicitSystemPrompt,
|
||||
providerId: input.config.providerId,
|
||||
mode: input.config.mode,
|
||||
}),
|
||||
]);
|
||||
if (status !== "read" || activeSessionId !== sourceSessionId) {
|
||||
throw new Error("Working directory changed concurrently. Try /cd again.");
|
||||
}
|
||||
|
||||
const previousState = { ...input.chatCommandState };
|
||||
const previousSessionId = activeSessionId;
|
||||
const previousConfig = {
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
systemPrompt: input.config.systemPrompt,
|
||||
extensionContext: input.config.extensionContext,
|
||||
};
|
||||
const previousRuntimeHooks = runtimeHooks;
|
||||
const nextRuntimeHooks = createWorkspaceRuntimeHooks(manager, next);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
const initialCompactionState = projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// The directory becomes effective as one snapshot for the replacement
|
||||
// session. A concurrent ensureReady() waits on the restart barrier.
|
||||
Object.assign(input.chatCommandState, next);
|
||||
input.config.cwd = next.cwd;
|
||||
input.config.workspaceRoot = next.workspaceRoot;
|
||||
input.config.systemPrompt = systemPrompt;
|
||||
if (input.config.extensionContext?.workspace) {
|
||||
input.config.extensionContext = {
|
||||
...input.config.extensionContext,
|
||||
workspace: {
|
||||
...input.config.extensionContext.workspace,
|
||||
rootPath: next.workspaceRoot,
|
||||
cwd: next.cwd,
|
||||
workspaceName: basename(next.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
runtimeHooks = nextRuntimeHooks;
|
||||
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
preserveSessionId: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Object.assign(input.chatCommandState, previousState);
|
||||
input.config.cwd = previousConfig.cwd;
|
||||
input.config.workspaceRoot = previousConfig.workspaceRoot;
|
||||
input.config.systemPrompt = previousConfig.systemPrompt;
|
||||
input.config.extensionContext = previousConfig.extensionContext;
|
||||
runtimeHooks = previousRuntimeHooks;
|
||||
await nextRuntimeHooks.shutdown().catch(() => {});
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
sessionId: previousSessionId || undefined,
|
||||
});
|
||||
} catch (recoveryError) {
|
||||
throw new AggregateError(
|
||||
[error, recoveryError],
|
||||
"Working directory change failed, and the previous session could not be restored.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await previousRuntimeHooks?.shutdown().catch(() => {});
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
@@ -530,6 +661,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
config: input.config,
|
||||
mode,
|
||||
switchToActModeTool: input.switchToActModeTool,
|
||||
persistentExtraTools: input.persistentExtraTools,
|
||||
});
|
||||
await restartWithCurrentMessages();
|
||||
};
|
||||
@@ -883,6 +1015,8 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
return {
|
||||
ensureReady,
|
||||
listCoreSettings,
|
||||
toggleCoreSettings,
|
||||
sendCurrentTurn,
|
||||
updatePendingPrompt,
|
||||
getAccumulatedUsage,
|
||||
@@ -891,6 +1025,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
changeWorkingDirectory,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createChatCommandHost } from "../../utils/chat-commands";
|
||||
import { createMutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
|
||||
import type { WorkspaceChatCommandHostResult } from "../../utils/plugin-chat-commands";
|
||||
import { createInteractiveWorkspaceResources } from "./workspace-resources";
|
||||
|
||||
describe("interactive workspace resources", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function createWorkspace(commandName: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cli-workspace-resources-"));
|
||||
tempRoots.push(root);
|
||||
const workflows = join(root, "workflows");
|
||||
await mkdir(workflows, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflows, `${commandName}.md`),
|
||||
`---\nname: ${commandName}\n---\nRun ${commandName}.`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createInstructionService(cwd: string): UserInstructionConfigService {
|
||||
return createUserInstructionConfigService({
|
||||
skills: { directories: [] },
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [join(cwd, "workflows")] },
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginResult(
|
||||
commandName: string,
|
||||
shutdown = vi.fn(async () => {}),
|
||||
): WorkspaceChatCommandHostResult {
|
||||
return {
|
||||
host: createChatCommandHost().register("command", {
|
||||
names: [`/${commandName}`],
|
||||
run: async (_parsed, context) => {
|
||||
await context.reply(commandName);
|
||||
},
|
||||
}),
|
||||
pluginSlashCommands: [{ name: commandName }],
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
it("commits workflow expansion and plugin commands as one workspace snapshot", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const onCommandsChanged = vi.fn();
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: async ({ cwd }) =>
|
||||
createPluginResult(cwd === workspaceA ? "plugin-a" : "plugin-b"),
|
||||
onCommandsChanged,
|
||||
});
|
||||
|
||||
await resources.loadPluginSlashCommands();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
|
||||
expect.objectContaining({ name: "plugin-a" }),
|
||||
]);
|
||||
|
||||
const applySessionChange = vi.fn(async () => {});
|
||||
await resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
applySessionChange,
|
||||
);
|
||||
|
||||
expect(applySessionChange).toHaveBeenCalledOnce();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"/workflow-a",
|
||||
);
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
|
||||
"Run workflow-b.",
|
||||
);
|
||||
expect(onCommandsChanged).toHaveBeenLastCalledWith({
|
||||
workflowSlashCommands: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "workflow-b" }),
|
||||
]),
|
||||
pluginSlashCommands: [expect.objectContaining({ name: "plugin-b" })],
|
||||
});
|
||||
expect(
|
||||
onCommandsChanged.mock.calls
|
||||
.at(-1)?.[0]
|
||||
.workflowSlashCommands.map((command: { name: string }) => command.name),
|
||||
).not.toContain("workflow-a");
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("keeps the previous workspace active when the agent session transition fails", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const nextPluginShutdown = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: async () =>
|
||||
createPluginResult("plugin-b", nextPluginShutdown),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
async () => {
|
||||
throw new Error("session restart failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("session restart failed");
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
|
||||
"/workflow-b",
|
||||
);
|
||||
expect(nextPluginShutdown).toHaveBeenCalledOnce();
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("rejects incompatible instruction services before changing the agent session", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const incompatibleService = createInstructionService(workspaceB);
|
||||
incompatibleService.createSkillsExecutor = undefined;
|
||||
const applySessionChange = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: () => incompatibleService,
|
||||
createPluginCommands: async () => createPluginResult("plugin-b"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
applySessionChange,
|
||||
),
|
||||
).rejects.toThrow("incompatible skills capability");
|
||||
expect(applySessionChange).not.toHaveBeenCalled();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("does not let a stale plugin load replace a newer workspace", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
let resolveStaleLoad:
|
||||
| ((value: WorkspaceChatCommandHostResult) => void)
|
||||
| undefined;
|
||||
const staleLoad = new Promise<WorkspaceChatCommandHostResult>((resolve) => {
|
||||
resolveStaleLoad = resolve;
|
||||
});
|
||||
const staleShutdown = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: ({ cwd }) =>
|
||||
cwd === workspaceA
|
||||
? staleLoad
|
||||
: Promise.resolve(createPluginResult("plugin-b")),
|
||||
});
|
||||
|
||||
const loadingA = resources.loadPluginSlashCommands();
|
||||
const changing = resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
async () => {},
|
||||
);
|
||||
resolveStaleLoad?.(createPluginResult("plugin-a", staleShutdown));
|
||||
await Promise.all([loadingA, changing]);
|
||||
|
||||
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
|
||||
expect.objectContaining({ name: "plugin-b" }),
|
||||
]);
|
||||
expect(staleShutdown).toHaveBeenCalledOnce();
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { BasicLogger, UserInstructionConfigService } from "@cline/core";
|
||||
import type { InteractiveSlashCommand } from "../../tui/interactive-welcome";
|
||||
import { listInteractiveSlashCommands } from "../../tui/interactive-welcome";
|
||||
import {
|
||||
type ChatCommandHost,
|
||||
chatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { MutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
|
||||
import {
|
||||
createWorkspaceChatCommandHost,
|
||||
type WorkspaceChatCommandHostResult,
|
||||
} from "../../utils/plugin-chat-commands";
|
||||
|
||||
export interface InteractiveWorkspaceLocation {
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export interface InteractiveWorkspaceCommandSnapshot {
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginSlashCommands: InteractiveSlashCommand[];
|
||||
}
|
||||
|
||||
interface WorkspacePluginCommands {
|
||||
host: ChatCommandHost;
|
||||
commands: InteractiveSlashCommand[];
|
||||
shutdown?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function toPluginCommands(
|
||||
result: WorkspaceChatCommandHostResult,
|
||||
): WorkspacePluginCommands {
|
||||
return {
|
||||
host: result.host,
|
||||
commands: result.pluginSlashCommands.map((command) => ({
|
||||
name: command.name,
|
||||
instructions: "",
|
||||
description: command.description ?? "Plugin command",
|
||||
})),
|
||||
shutdown: result.shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveWorkspaceResources(input: {
|
||||
initialLocation: InteractiveWorkspaceLocation;
|
||||
userInstructionService: MutableUserInstructionConfigService;
|
||||
createUserInstructionService: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => UserInstructionConfigService;
|
||||
logger?: BasicLogger;
|
||||
createPluginCommands?: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => Promise<WorkspaceChatCommandHostResult>;
|
||||
onCommandsChanged?: (snapshot: InteractiveWorkspaceCommandSnapshot) => void;
|
||||
}) {
|
||||
let location = input.initialLocation;
|
||||
let pluginCommands: WorkspacePluginCommands = {
|
||||
host: chatCommandHost,
|
||||
commands: [],
|
||||
};
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let pluginCommandsLoaded = false;
|
||||
let pluginLoadPromise: Promise<InteractiveSlashCommand[]> | undefined;
|
||||
let workspaceChangePromise: Promise<void> | undefined;
|
||||
const createPluginCommands = async (next: InteractiveWorkspaceLocation) =>
|
||||
toPluginCommands(
|
||||
await (input.createPluginCommands
|
||||
? input.createPluginCommands(next)
|
||||
: createWorkspaceChatCommandHost({
|
||||
cwd: next.cwd,
|
||||
workspaceRoot: next.workspaceRoot,
|
||||
logger: input.logger,
|
||||
})),
|
||||
);
|
||||
const snapshot = (): InteractiveWorkspaceCommandSnapshot => ({
|
||||
workflowSlashCommands: listInteractiveSlashCommands(
|
||||
input.userInstructionService,
|
||||
),
|
||||
pluginSlashCommands: pluginCommands.commands,
|
||||
});
|
||||
|
||||
const loadPluginSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
if (disposed) {
|
||||
return [];
|
||||
}
|
||||
if (pluginCommandsLoaded) {
|
||||
return pluginCommands.commands;
|
||||
}
|
||||
if (pluginLoadPromise) {
|
||||
return await pluginLoadPromise;
|
||||
}
|
||||
const loadGeneration = generation;
|
||||
const loadLocation = location;
|
||||
const load = (async () => {
|
||||
const loaded = await createPluginCommands(loadLocation);
|
||||
if (disposed || generation !== loadGeneration) {
|
||||
await loaded.shutdown?.().catch(() => {});
|
||||
return pluginCommands.commands;
|
||||
}
|
||||
const previous = pluginCommands;
|
||||
pluginCommands = loaded;
|
||||
pluginCommandsLoaded = true;
|
||||
await previous.shutdown?.().catch(() => {});
|
||||
return loaded.commands;
|
||||
})();
|
||||
pluginLoadPromise = load;
|
||||
try {
|
||||
return await load;
|
||||
} finally {
|
||||
if (pluginLoadPromise === load) {
|
||||
pluginLoadPromise = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyWorkspaceChange = async (
|
||||
next: InteractiveWorkspaceLocation,
|
||||
applySessionChange: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
if (disposed) {
|
||||
throw new Error("interactive workspace resources are disposed");
|
||||
}
|
||||
generation += 1;
|
||||
const nextService = input.createUserInstructionService(next);
|
||||
let nextPluginCommands: WorkspacePluginCommands | undefined;
|
||||
try {
|
||||
await nextService.start();
|
||||
input.userInstructionService.assertCompatible(nextService);
|
||||
nextPluginCommands = await createPluginCommands(next);
|
||||
await applySessionChange();
|
||||
} catch (error) {
|
||||
try {
|
||||
nextService.stop();
|
||||
} catch {}
|
||||
await nextPluginCommands?.shutdown?.().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousService = input.userInstructionService.replace(nextService);
|
||||
const previousPluginCommands = pluginCommands;
|
||||
location = next;
|
||||
pluginCommands = nextPluginCommands;
|
||||
pluginCommandsLoaded = true;
|
||||
// The instruction delegate, plugin host, and TUI catalog become visible as
|
||||
// one workspace snapshot after the replacement agent session is live.
|
||||
try {
|
||||
input.onCommandsChanged?.(snapshot());
|
||||
} catch (error) {
|
||||
input.logger?.log("workspace command catalog notification failed", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
previousService.stop();
|
||||
} catch {}
|
||||
await previousPluginCommands.shutdown?.().catch(() => {});
|
||||
};
|
||||
|
||||
const changeWorkspace = (
|
||||
next: InteractiveWorkspaceLocation,
|
||||
applySessionChange: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
let change: Promise<void>;
|
||||
change = (async () => {
|
||||
await workspaceChangePromise?.catch(() => {});
|
||||
await applyWorkspaceChange(next, applySessionChange);
|
||||
})().finally(() => {
|
||||
if (workspaceChangePromise === change) {
|
||||
workspaceChangePromise = undefined;
|
||||
}
|
||||
});
|
||||
workspaceChangePromise = change;
|
||||
return change;
|
||||
};
|
||||
|
||||
const dispose = async (): Promise<void> => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
await workspaceChangePromise?.catch(() => {});
|
||||
await pluginLoadPromise?.catch(() => {});
|
||||
await pluginCommands.shutdown?.().catch(() => {});
|
||||
pluginCommands = { host: chatCommandHost, commands: [] };
|
||||
pluginCommandsLoaded = false;
|
||||
};
|
||||
|
||||
return {
|
||||
changeWorkspace,
|
||||
dispose,
|
||||
getChatCommandHost: () => pluginCommands.host,
|
||||
getCommandSnapshot: snapshot,
|
||||
arePluginCommandsLoaded: () => pluginCommandsLoaded,
|
||||
loadPluginSlashCommands,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,21 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userImages).toEqual([]);
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
|
||||
it("resolves relative file mentions from the configured working directory", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-cwd-"));
|
||||
const filePath = join(dir, "notes.md");
|
||||
writeFileSync(filePath, "# Notes\n");
|
||||
|
||||
const result = await buildUserInputMessage(
|
||||
"summarize @./notes.md",
|
||||
undefined,
|
||||
{ cwd: dir },
|
||||
);
|
||||
|
||||
expect(result.prompt).toBe("summarize [file: notes.md]");
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
|
||||
@@ -74,11 +74,11 @@ function extractFileMentions(
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveMentionPath(filePath: string): string {
|
||||
function resolveMentionPath(filePath: string, cwd: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return resolve(homedir(), filePath.slice(2));
|
||||
}
|
||||
return resolve(filePath);
|
||||
return resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,7 @@ export function shouldExpandSkillSlashCommands(mode?: string): boolean {
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
options?: { mode?: string },
|
||||
options?: { mode?: string; cwd?: string },
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
@@ -154,7 +154,10 @@ export async function buildUserInputMessage(
|
||||
|
||||
for (const mention of fileMentions) {
|
||||
try {
|
||||
const resolvedPath = resolveMentionPath(mention.path);
|
||||
const resolvedPath = resolveMentionPath(
|
||||
mention.path,
|
||||
options?.cwd ?? process.cwd(),
|
||||
);
|
||||
const stats = statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${resolvedPath}`);
|
||||
|
||||
@@ -279,6 +279,7 @@ export async function runAgent(
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createComputerUseToolFromEnv,
|
||||
getCurrentContextSize,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
@@ -23,7 +24,6 @@ import type {
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../tui/interactive-config";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
listInteractiveSlashCommands,
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
@@ -36,12 +36,12 @@ import {
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import type { MutableUserInstructionConfigService } from "../utils/mutable-user-instruction-service";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
writeln,
|
||||
} from "../utils/output";
|
||||
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./active-runtime";
|
||||
import { createInteractiveApprovalController } from "./interactive/approvals";
|
||||
import { runInteractiveChatCommand } from "./interactive/chat-command-runner";
|
||||
import { createInteractiveComputerUser } from "./interactive/computer-user";
|
||||
import { createInteractiveConfigDataLoader } from "./interactive/config-data";
|
||||
import {
|
||||
formatInteractiveExitSummary,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
buildInteractiveExtraTools,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
@@ -67,6 +69,11 @@ import {
|
||||
} from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import {
|
||||
createInteractiveWorkspaceResources,
|
||||
type InteractiveWorkspaceCommandSnapshot,
|
||||
type InteractiveWorkspaceLocation,
|
||||
} from "./interactive/workspace-resources";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { getUIEventEmitter } from "./session-events";
|
||||
|
||||
@@ -185,55 +192,53 @@ export async function runInteractive(
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
explicitSystemPrompt?: string;
|
||||
mutableUserInstructionService?: MutableUserInstructionConfigService;
|
||||
createUserInstructionService?: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => UserInstructionConfigService;
|
||||
},
|
||||
): Promise<void> {
|
||||
assertInteractivePreflight(config);
|
||||
|
||||
const initialRepoStatus = await readRepoStatus(config.cwd);
|
||||
const workflowSlashCommands = listInteractiveSlashCommands(
|
||||
userInstructionService,
|
||||
);
|
||||
let interactiveChatCommandHost = chatCommandHost;
|
||||
let pluginChatCommandHostLoaded = false;
|
||||
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
|
||||
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
|
||||
let pluginChatCommandHostPromise:
|
||||
| Promise<InteractiveSlashCommand[]>
|
||||
const mutableUserInstructionService = options?.mutableUserInstructionService;
|
||||
const createUserInstructionService = options?.createUserInstructionService;
|
||||
const activeUserInstructionService =
|
||||
mutableUserInstructionService ?? userInstructionService;
|
||||
if (
|
||||
(mutableUserInstructionService === undefined) !==
|
||||
(createUserInstructionService === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"interactive workspace resources require both the mutable instruction service and its factory",
|
||||
);
|
||||
}
|
||||
let workspaceCommandNotifier:
|
||||
| ((snapshot: InteractiveWorkspaceCommandSnapshot) => void)
|
||||
| undefined;
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
});
|
||||
const ensurePluginChatCommandHost = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
if (pluginChatCommandHostLoaded) {
|
||||
return pluginChatSlashCommands;
|
||||
}
|
||||
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
logger: config.logger,
|
||||
})
|
||||
.then(({ host, pluginSlashCommands, shutdown }) => {
|
||||
interactiveChatCommandHost = host;
|
||||
pluginChatCommandHostShutdown = shutdown;
|
||||
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
instructions: "",
|
||||
description: cmd.description ?? "Plugin command",
|
||||
}));
|
||||
return pluginChatSlashCommands;
|
||||
})
|
||||
.finally(() => {
|
||||
pluginChatCommandHostLoaded = true;
|
||||
pluginChatCommandHostPromise = undefined;
|
||||
});
|
||||
return await pluginChatCommandHostPromise;
|
||||
const workspaceResources =
|
||||
mutableUserInstructionService && createUserInstructionService
|
||||
? createInteractiveWorkspaceResources({
|
||||
initialLocation: {
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
},
|
||||
userInstructionService: mutableUserInstructionService,
|
||||
createUserInstructionService,
|
||||
logger: config.logger,
|
||||
onCommandsChanged: (snapshot) => workspaceCommandNotifier?.(snapshot),
|
||||
})
|
||||
: undefined;
|
||||
const initialCommandSnapshot = workspaceResources?.getCommandSnapshot() ?? {
|
||||
workflowSlashCommands: listInteractiveSlashCommands(
|
||||
activeUserInstructionService,
|
||||
),
|
||||
pluginSlashCommands: [],
|
||||
};
|
||||
const loadAdditionalSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => await ensurePluginChatCommandHost();
|
||||
const loadAdditionalSlashCommands = workspaceResources
|
||||
? workspaceResources.loadPluginSlashCommands
|
||||
: undefined;
|
||||
const shouldTryPluginChatCommands = (prompt: string): boolean => {
|
||||
return prompt.trimStart().startsWith("/");
|
||||
};
|
||||
@@ -262,7 +267,47 @@ export async function runInteractive(
|
||||
tuiModeChanged,
|
||||
});
|
||||
|
||||
config.extraTools = config.mode === "plan" ? [switchToActModeTool] : [];
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
|
||||
// Computer-use support, enabled when CLINE_COMPUTER_USE_PORT points at a
|
||||
// running backend. Preferred shape: the asynchronous computer user (a
|
||||
// dedicated Anthropic helper session behind computer_user_* tools). When
|
||||
// the Anthropic provider is not configured, fall back to giving the
|
||||
// driver the raw `computer` tool directly.
|
||||
//
|
||||
// notifyDriver closes over sessionRuntime (declared below) but only runs
|
||||
// after a driver turn has started, long after initialization. It resolves
|
||||
// the driver session id at call time, so session rebuilds are safe.
|
||||
const computerUser = await createInteractiveComputerUser({
|
||||
config,
|
||||
providerSettingsManager,
|
||||
notifyDriver: (prompt, delivery) => {
|
||||
void sessionRuntime
|
||||
.sendCurrentTurn({ prompt, delivery })
|
||||
.catch((error) => {
|
||||
logCliError(
|
||||
config.logger,
|
||||
"Computer-user driver notification failed",
|
||||
{
|
||||
error,
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const computerUseTool = computerUser
|
||||
? undefined
|
||||
: await createComputerUseToolFromEnv();
|
||||
const persistentExtraTools = [
|
||||
...(computerUser ? computerUser.driverTools : []),
|
||||
...(computerUseTool ? [computerUseTool] : []),
|
||||
];
|
||||
|
||||
config.extraTools = buildInteractiveExtraTools({
|
||||
mode: config.mode === "plan" ? "plan" : "act",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
|
||||
const uiEvents = getUIEventEmitter();
|
||||
const chatCommandState: ChatCommandState = {
|
||||
@@ -275,13 +320,13 @@ export async function runInteractive(
|
||||
autoApproveAllRef,
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager,
|
||||
userInstructionService,
|
||||
userInstructionService: activeUserInstructionService,
|
||||
explicitSystemPrompt: options?.explicitSystemPrompt,
|
||||
resumeSessionId,
|
||||
chatCommandState,
|
||||
requestToolApproval,
|
||||
@@ -289,6 +334,11 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
// Record the driver's transcript to the computer-use backend's
|
||||
// journal so the observatory can show it beside the computer user's
|
||||
// transcript and screenshots.
|
||||
extraAgentHooks: computerUser?.driverRecordingHooks,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
@@ -302,6 +352,26 @@ export async function runInteractive(
|
||||
uiEvents.emit("pending-prompt-submitted", event);
|
||||
},
|
||||
});
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService: activeUserInstructionService,
|
||||
loadCoreSettings: sessionRuntime.listCoreSettings,
|
||||
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
|
||||
});
|
||||
const changeInteractiveWorkingDirectory = async (
|
||||
next: ChatCommandState,
|
||||
): Promise<void> => {
|
||||
const applySessionChange = () =>
|
||||
sessionRuntime.changeWorkingDirectory(next);
|
||||
if (!workspaceResources) {
|
||||
await applySessionChange();
|
||||
return;
|
||||
}
|
||||
await workspaceResources.changeWorkspace(
|
||||
{ cwd: next.cwd, workspaceRoot: next.workspaceRoot },
|
||||
applySessionChange,
|
||||
);
|
||||
};
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
@@ -383,11 +453,8 @@ export async function runInteractive(
|
||||
try {
|
||||
exitSummary = await sessionRuntime.cleanup();
|
||||
} finally {
|
||||
await pluginChatCommandHostPromise?.catch(() => []);
|
||||
await pluginChatCommandHostShutdown?.().catch(() => {
|
||||
// Best effort cleanup for plugin command discovery sandbox.
|
||||
});
|
||||
pluginChatCommandHostShutdown = undefined;
|
||||
await computerUser?.dispose().catch(() => {});
|
||||
await workspaceResources?.dispose();
|
||||
setActiveRuntimeAbort(undefined);
|
||||
setActiveRuntimeCleanup(undefined);
|
||||
}
|
||||
@@ -521,7 +588,7 @@ export async function runInteractive(
|
||||
onInitialNoticeShown: options?.onInitialNoticeShown,
|
||||
loadDeferredInitialMessages,
|
||||
initialRepoStatus,
|
||||
workflowSlashCommands,
|
||||
workflowSlashCommands: initialCommandSnapshot.workflowSlashCommands,
|
||||
loadAdditionalSlashCommands,
|
||||
loadWelcomeLine: async () =>
|
||||
await resolveClineWelcomeLine({
|
||||
@@ -580,12 +647,14 @@ export async function runInteractive(
|
||||
let chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
delivery,
|
||||
config,
|
||||
host: interactiveChatCommandHost,
|
||||
host: workspaceResources?.getChatCommandHost() ?? chatCommandHost,
|
||||
chatCommandState,
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
changeWorkingDirectory: changeInteractiveWorkingDirectory,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
@@ -594,18 +663,21 @@ export async function runInteractive(
|
||||
}
|
||||
if (
|
||||
shouldTryPluginChatCommands(input) &&
|
||||
!pluginChatCommandHostLoaded
|
||||
workspaceResources &&
|
||||
!workspaceResources.arePluginCommandsLoaded()
|
||||
) {
|
||||
await ensurePluginChatCommandHost();
|
||||
await workspaceResources.loadPluginSlashCommands();
|
||||
chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
delivery,
|
||||
config,
|
||||
host: interactiveChatCommandHost,
|
||||
host: workspaceResources.getChatCommandHost(),
|
||||
chatCommandState,
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
changeWorkingDirectory: changeInteractiveWorkingDirectory,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
@@ -621,8 +693,9 @@ export async function runInteractive(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
} = await buildUserInputMessage(input, activeUserInstructionService, {
|
||||
mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
@@ -858,6 +931,12 @@ export async function runInteractive(
|
||||
setModeChangeNotifier: (fn) => {
|
||||
tuiModeChanged.current = fn;
|
||||
},
|
||||
setWorkspaceCommandNotifier: (fn) => {
|
||||
workspaceCommandNotifier = fn ?? undefined;
|
||||
if (fn && workspaceResources) {
|
||||
fn(workspaceResources.getCommandSnapshot());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function runZen(
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
cwd: config.cwd,
|
||||
});
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
|
||||
@@ -316,4 +316,26 @@ describe("slash command registry", () => {
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("account");
|
||||
});
|
||||
|
||||
it("exposes cd as a runtime command", () => {
|
||||
const registry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveSlashCommand(registry, "cd")).toMatchObject({
|
||||
source: "runtime",
|
||||
execution: "runtime",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
});
|
||||
expect(
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("cd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"cd",
|
||||
"model",
|
||||
"theme",
|
||||
"account",
|
||||
@@ -130,7 +131,7 @@ const SYSTEM_COMMAND_ORDER = [
|
||||
"history",
|
||||
"help",
|
||||
"quit",
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "team">;
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "cd" | "team">;
|
||||
|
||||
const SYSTEM_COMMAND_PRIORITY = new Map<string, number>(
|
||||
SYSTEM_COMMAND_ORDER.map((name, index) => [name, index]),
|
||||
|
||||
@@ -657,11 +657,18 @@ export function ChatEntryView(props: {
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*
|
||||
* streaming stays true even after the entry settles: flipping the
|
||||
* prop makes MarkdownRenderable rebuild every block from scratch
|
||||
* (updateBlocks(true) skips all reuse paths), so the finished
|
||||
* message flashes back to unhighlighted text while tree-sitter
|
||||
* re-highlights. opencode's TUI keeps streaming={true} for the
|
||||
* same reason. entry.streaming still drives the spinner glyph.
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -133,6 +133,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/theme",
|
||||
desc: "Change color theme",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-cd",
|
||||
key: "/cd <directory>",
|
||||
desc: "Change the working directory",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-mcp",
|
||||
|
||||
@@ -14,6 +14,27 @@ 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
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
|
||||
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
|
||||
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
|
||||
* in root.tsx, not this modal.
|
||||
*/
|
||||
export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
@@ -29,13 +38,12 @@ export function HubUpdateRequiredContent(
|
||||
<text fg="yellow">Cline Hub was updated</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
Another Cline installation restarted the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
|
||||
matches this CLI.
|
||||
Another Cline installation updated the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
|
||||
CLI cannot talk to.
|
||||
</text>
|
||||
<text selectable>
|
||||
Update and restart Cline so this CLI and the Hub run the same version
|
||||
again.
|
||||
Update and restart Cline to reconnect to the running Hub.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
@@ -49,3 +57,64 @@ export function HubUpdateRequiredContent(
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HubOutdatedDetails {
|
||||
hubCoreVersion?: string;
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when this CLI is the newer build and the shared Hub was left running
|
||||
* an older one because it is still serving other clients' sessions. Enter
|
||||
* replaces the Hub now (interrupting that work); Esc keeps it running.
|
||||
*/
|
||||
export function HubOutdatedContent(
|
||||
props: ChoiceContext<boolean> & HubOutdatedDetails,
|
||||
) {
|
||||
const {
|
||||
activeSessionCount,
|
||||
dialogId,
|
||||
dismiss,
|
||||
participantClientCount,
|
||||
resolve,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "update") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub update required</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
|
||||
{describeOutdatedHubSessions({
|
||||
activeSessionCount,
|
||||
participantClientCount,
|
||||
})}
|
||||
.
|
||||
</text>
|
||||
<text selectable>
|
||||
Updating stops that Hub and interrupts its sessions.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Update Now</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to update now, Esc to keep the Hub running
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
@@ -36,16 +40,16 @@ function toRuntimeToolInteraction(
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
function deniedToolResult(): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
pending.resolve(deniedToolResult());
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
@@ -111,9 +115,7 @@ export function useRuntimeDialogBridge(input: {
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
pending.resolve(approved ? { approved: true } : deniedToolResult());
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
|
||||
@@ -60,5 +60,11 @@ export function useSlashCommands(input: {
|
||||
[registry],
|
||||
);
|
||||
|
||||
return { registry, systemCommands, skillCommands, invokableSkillCommands };
|
||||
return {
|
||||
registry,
|
||||
systemCommands,
|
||||
skillCommands,
|
||||
invokableSkillCommands,
|
||||
setAdditionalSlashCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -123,6 +123,11 @@ export function listInteractiveSlashCommands(
|
||||
instructions: "",
|
||||
description: "Modify agent configuration",
|
||||
},
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
{
|
||||
name: "mcp",
|
||||
instructions: "",
|
||||
|
||||
+91
-10
@@ -2,6 +2,7 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
summarizeUsageFromMessages,
|
||||
upgradeManagedHub,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
@@ -40,7 +41,10 @@ import {
|
||||
buildCommandPaletteItems,
|
||||
findCommandPaletteShortcut,
|
||||
} from "./components/dialogs/command-palette-items";
|
||||
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
|
||||
import {
|
||||
HubOutdatedContent,
|
||||
HubUpdateRequiredContent,
|
||||
} from "./components/dialogs/hub-update-required";
|
||||
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
|
||||
import {
|
||||
SKILLS_MARKETPLACE_ACTION,
|
||||
@@ -136,12 +140,21 @@ function App(props: TuiProps) {
|
||||
systemCommands,
|
||||
skillCommands,
|
||||
invokableSkillCommands,
|
||||
setAdditionalSlashCommands,
|
||||
} = useSlashCommands({
|
||||
workflowSlashCommands,
|
||||
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
|
||||
canFork: canForkSession,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
props.setWorkspaceCommandNotifier((snapshot) => {
|
||||
setWorkflowSlashCommands(snapshot.workflowSlashCommands);
|
||||
setAdditionalSlashCommands(snapshot.pluginSlashCommands);
|
||||
});
|
||||
return () => props.setWorkspaceCommandNotifier(null);
|
||||
}, [props.setWorkspaceCommandNotifier, setAdditionalSlashCommands]);
|
||||
|
||||
const autocomplete = useAutocomplete({
|
||||
workspaceRoot,
|
||||
systemCommands,
|
||||
@@ -586,17 +599,85 @@ function App(props: TuiProps) {
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
// This CLI is already the newer build; the Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving. Left alone it
|
||||
// would stay behind for as long as those sessions run, so put the
|
||||
// choice to the user: replace it now (interrupting that work), or
|
||||
// keep it running and update later. This session itself is safe
|
||||
// either way - a CLI that could not attach to the outdated Hub is
|
||||
// running on the local backend.
|
||||
const details = {
|
||||
hubCoreVersion,
|
||||
activeSessionCount: hubBuildMismatch.activeSessionCount,
|
||||
participantClientCount: hubBuildMismatch.participantClientCount,
|
||||
};
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<HubOutdatedContent {...ctx} {...details} />
|
||||
),
|
||||
})
|
||||
.then(async (update) => {
|
||||
if (!update) {
|
||||
// choice() resolves undefined on Esc; it does not reject.
|
||||
showToast(
|
||||
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
|
||||
"info",
|
||||
);
|
||||
refocusTextareaRef.current();
|
||||
return;
|
||||
}
|
||||
showToast("Updating the Cline Hub…", "info");
|
||||
try {
|
||||
const result = await upgradeManagedHub({
|
||||
force: true,
|
||||
reason: "cline TUI hub update",
|
||||
});
|
||||
if (result.outcome === "still_busy") {
|
||||
showToast(
|
||||
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
result.outcome === "replaced" || result.outcome === "started"
|
||||
? "Cline Hub updated."
|
||||
: "Cline Hub is already up to date.",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
refocusTextareaRef.current();
|
||||
})
|
||||
.catch(() => {
|
||||
refocusTextareaRef.current();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hubBuildMismatch.reason === "build_mismatch") {
|
||||
// The Hub is newer but still speaks this CLI's protocol, so the
|
||||
// session keeps working and parity is advisable rather than urgent.
|
||||
// A modal mid-session is too heavy for advice; a toast (once per
|
||||
// observed Hub build, the watcher dedupes) says what changed and
|
||||
// how to catch up without stealing focus.
|
||||
showToast(
|
||||
`The shared Cline Hub was updated${
|
||||
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
|
||||
}. Run 'cline update' and restart when convenient.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// unsupported_protocol: this CLI cannot speak the running Hub's
|
||||
// protocol at all, so nothing hub-backed can work until it updates.
|
||||
// That is worth a blocking prompt.
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
|
||||
@@ -246,6 +246,14 @@ export interface TuiProps {
|
||||
handler: ((question: string, options: string[]) => Promise<string>) | null,
|
||||
) => void;
|
||||
setModeChangeNotifier: (handler: ((mode: AgentMode) => void) | null) => void;
|
||||
setWorkspaceCommandNotifier: (
|
||||
handler:
|
||||
| ((snapshot: {
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginSlashCommands: InteractiveSlashCommand[];
|
||||
}) => void)
|
||||
| null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type InlineStream = "text" | "reasoning" | undefined;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import { truncate } from "./helpers";
|
||||
import { c, getActiveCliSession, write } from "./output";
|
||||
|
||||
@@ -91,7 +95,7 @@ async function requestTerminalToolApproval(
|
||||
}
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createChatCommandHost,
|
||||
@@ -199,6 +202,65 @@ describe("chat commands", () => {
|
||||
expect(reply).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("changes directories with both /cd and the existing /cwd spelling", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-"));
|
||||
const target = join(root, "project with spaces");
|
||||
mkdirSync(target);
|
||||
|
||||
for (const command of [
|
||||
`/cd "project with spaces"`,
|
||||
`/cwd "project with spaces"`,
|
||||
]) {
|
||||
const state = {
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
};
|
||||
const setState = vi.fn(async (next) => Object.assign(state, next));
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand(command, {
|
||||
enabled: true,
|
||||
getState: () => state,
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(setState).toHaveBeenCalledOnce();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`cwd=${target}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the working directory unchanged when /cd is not a directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-invalid-"));
|
||||
writeFileSync(join(root, "file.txt"), "not a directory");
|
||||
const setState = vi.fn(async () => undefined);
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand("/cd file.txt", {
|
||||
enabled: true,
|
||||
getState: () => ({
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
}),
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
`invalid directory: ${join(root, "file.txt")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows usage for /team with no arguments", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "./helpers";
|
||||
|
||||
@@ -216,6 +217,22 @@ function tokenizeArgs(input: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resolveChatCommandDirectory(cwd: string, args: string[]): string {
|
||||
const rawPath = args.join(" ").trim();
|
||||
const unquotedPath =
|
||||
(rawPath.startsWith('"') && rawPath.endsWith('"')) ||
|
||||
(rawPath.startsWith("'") && rawPath.endsWith("'"))
|
||||
? rawPath.slice(1, -1)
|
||||
: rawPath;
|
||||
if (unquotedPath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (unquotedPath.startsWith("~/")) {
|
||||
return resolve(homedir(), unquotedPath.slice(2));
|
||||
}
|
||||
return resolve(cwd, unquotedPath);
|
||||
}
|
||||
|
||||
function parseFlagValues(tokens: string[]): {
|
||||
positionals: string[];
|
||||
flags: Record<string, string>;
|
||||
@@ -282,7 +299,7 @@ function formatHelp(state: ChatCommandState): string {
|
||||
"/whereami - show thread, cwd, tools, and yolo state",
|
||||
"/tools [on|off|toggle] - allow repo/file/shell tools",
|
||||
"/yolo [on|off|toggle] - auto-approve tool use",
|
||||
"/cwd <path> - change working directory",
|
||||
"/cd <path> (or /cwd <path>) - change working directory",
|
||||
"/schedule create/list/trigger/delete - manage scheduled workflows",
|
||||
"/abort - stop the current task",
|
||||
"/mute [target] - ignore this thread or target until /unmute",
|
||||
@@ -398,7 +415,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
},
|
||||
})
|
||||
.register("command", {
|
||||
names: ["/cwd"],
|
||||
names: ["/cd", "/cwd"],
|
||||
run: async ({ args, state }, context) => {
|
||||
const rawPath = args.join(" ").trim();
|
||||
if (!rawPath) {
|
||||
@@ -407,7 +424,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextCwd = resolve(state.cwd, rawPath);
|
||||
const nextCwd = resolveChatCommandDirectory(state.cwd, args);
|
||||
const fileStat = await stat(nextCwd).catch(() => undefined);
|
||||
if (!fileStat?.isDirectory()) {
|
||||
await context.reply(`invalid directory: ${nextCwd}`);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
|
||||
export interface MutableUserInstructionConfigService
|
||||
extends UserInstructionConfigService {
|
||||
assertCompatible(next: UserInstructionConfigService): void;
|
||||
replace(next: UserInstructionConfigService): UserInstructionConfigService;
|
||||
}
|
||||
|
||||
export function createMutableUserInstructionConfigService(
|
||||
initial: UserInstructionConfigService,
|
||||
): MutableUserInstructionConfigService {
|
||||
let current = initial;
|
||||
const hasSkillsExecutor = typeof initial.createSkillsExecutor === "function";
|
||||
const assertCompatible = (next: UserInstructionConfigService): void => {
|
||||
if (
|
||||
(typeof next.createSkillsExecutor === "function") !==
|
||||
hasSkillsExecutor
|
||||
) {
|
||||
throw new Error(
|
||||
"Replacement instruction service has incompatible skills capability",
|
||||
);
|
||||
}
|
||||
};
|
||||
const service: UserInstructionConfigService = {
|
||||
start: () => current.start(),
|
||||
stop: () => current.stop(),
|
||||
refreshType: (type) => current.refreshType(type),
|
||||
listRecords: (type) => current.listRecords(type),
|
||||
listRuntimeCommands: () => current.listRuntimeCommands(),
|
||||
resolveRuntimeSlashCommand: (input) =>
|
||||
current.resolveRuntimeSlashCommand(input),
|
||||
hasConfiguredSkills: (allowedSkillNames) =>
|
||||
current.hasConfiguredSkills(allowedSkillNames),
|
||||
createExtension: (options) => current.createExtension(options),
|
||||
};
|
||||
if (hasSkillsExecutor) {
|
||||
service.createSkillsExecutor = (allowedSkillNames) => {
|
||||
if (!current.createSkillsExecutor) {
|
||||
throw new Error(
|
||||
"Replacement instruction service has no skills executor",
|
||||
);
|
||||
}
|
||||
return current.createSkillsExecutor(allowedSkillNames);
|
||||
};
|
||||
}
|
||||
return {
|
||||
...service,
|
||||
assertCompatible,
|
||||
replace: (next) => {
|
||||
assertCompatible(next);
|
||||
const previous = current;
|
||||
current = next;
|
||||
return previous;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { GeneratedMedia } from "@cline/shared/browser";
|
||||
import {
|
||||
type GeneratedMedia,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared/browser";
|
||||
import { GeneratedMediaContent } from "@cline/ui";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -1182,7 +1185,7 @@ export default function Chat({
|
||||
type: "approval_response",
|
||||
approvalId,
|
||||
approved,
|
||||
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
|
||||
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
|
||||
});
|
||||
setStatus(approved ? "Approval sent." : "Rejection sent.");
|
||||
};
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 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 +27,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -462,6 +462,89 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("forks trimmed messages without restoring when the edited run has no checkpoint", async () => {
|
||||
const sourceSessionId = `source-imported-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "imported prompt" },
|
||||
{ role: "assistant" as const, content: "imported response" },
|
||||
{ role: "user" as const, content: "prompt to edit" },
|
||||
{ role: "assistant" as const, content: "response to replace" },
|
||||
];
|
||||
const expectedMessages = sourceMessages.slice(0, 2);
|
||||
const start = vi.fn(async () => ({ sessionId: "imported-fork" }));
|
||||
const restore = vi.fn(async () => {
|
||||
throw new Error("restore must not run without a checkpoint");
|
||||
});
|
||||
const readMessages = vi.fn(async () => expectedMessages);
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
messages: sourceMessages,
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
importedFrom: { tool: "codex", sourceId: "cdx-1" },
|
||||
},
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 2,
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
})) as { sessionId: string };
|
||||
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMessages: expectedMessages,
|
||||
sessionMetadata: expect.objectContaining({
|
||||
fork: expect.objectContaining({
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
beforeRunCount: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.sessionId).toBe("imported-fork");
|
||||
expect(ctx.liveSessions.has(sourceSessionId)).toBe(false);
|
||||
expect(ctx.liveSessions.get("imported-fork")?.messages).toEqual(
|
||||
expectedMessages,
|
||||
);
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
findCheckpointForRun,
|
||||
getCoreBuiltinToolCatalog,
|
||||
isSkillsToolAvailable,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
@@ -1299,8 +1301,18 @@ async function handleForkUnlocked(
|
||||
sessionMetadata: forkMetadata,
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
};
|
||||
// Sessions without a checkpoint at or before the edited run (imported
|
||||
// transcripts, checkpoints disabled) have no workspace state to roll back,
|
||||
// so fork the trimmed messages onto the current workspace instead of
|
||||
// failing the edit.
|
||||
const canRestoreWorkspace =
|
||||
forkBeforeRunCount !== undefined &&
|
||||
findCheckpointForRun(
|
||||
readSessionCheckpointHistory({ metadata: sourceMetadata }),
|
||||
forkBeforeRunCount,
|
||||
) !== undefined;
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined) {
|
||||
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext, SidecarWebSocketClient } from "./types";
|
||||
|
||||
const upgradeManagedHubMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
upgradeManagedHub: upgradeManagedHubMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createContext(): SidecarContext {
|
||||
return {
|
||||
workspaceRoot: "/workspace",
|
||||
wsClients: new Set(),
|
||||
hubBuildMismatch: {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
reason: "outdated_hub",
|
||||
expectedBuildId: "current-build",
|
||||
},
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
}
|
||||
|
||||
function connection(canApproveTools: boolean): SidecarWebSocketClient {
|
||||
return { data: { canApproveTools } } as unknown as SidecarWebSocketClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
upgradeManagedHubMock.mockReset();
|
||||
});
|
||||
|
||||
describe("hub_upgrade command", () => {
|
||||
it("rejects connections without the approval token, before touching the hub", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(false) }),
|
||||
).rejects.toThrow(/trusted desktop connection/);
|
||||
await expect(handleCommand(ctx, "hub_upgrade", {}, {})).rejects.toThrow(
|
||||
/trusted desktop connection/,
|
||||
);
|
||||
expect(upgradeManagedHubMock).not.toHaveBeenCalled();
|
||||
// The pending mismatch must survive a refused request.
|
||||
expect(ctx.hubBuildMismatch).not.toBeNull();
|
||||
});
|
||||
|
||||
it("forces the upgrade for the trusted webview connection and clears the mismatch", async () => {
|
||||
upgradeManagedHubMock.mockResolvedValue({
|
||||
outcome: "replaced",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
activeSessionCount: 2,
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
const result = await handleCommand(
|
||||
ctx,
|
||||
"hub_upgrade",
|
||||
{},
|
||||
{ connection: connection(true) },
|
||||
);
|
||||
|
||||
expect(upgradeManagedHubMock).toHaveBeenCalledWith({
|
||||
workspaceRoot: "/workspace",
|
||||
force: true,
|
||||
reason: "Cline Desktop hub update",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: "replaced",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
interruptedSessionCount: 2,
|
||||
});
|
||||
expect(ctx.hubBuildMismatch).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a newer running hub as an error instead of replacing it", async () => {
|
||||
upgradeManagedHubMock.mockResolvedValue({
|
||||
outcome: "hub_not_older",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(true) }),
|
||||
).rejects.toThrow(/newer than this app/);
|
||||
expect(ctx.hubBuildMismatch).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SESSION_IMPORT_TOOLS,
|
||||
type SessionImportRequest,
|
||||
SessionImportService,
|
||||
type SessionImportTool,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
saveVoiceInputSettings,
|
||||
@@ -45,6 +49,7 @@ import {
|
||||
transcribeConfiguredVoiceInput,
|
||||
updateLocalProvider,
|
||||
updateMcpSettingsFileSync,
|
||||
upgradeManagedHub,
|
||||
} from "@cline/core";
|
||||
import { resolveAudioTranscriptionRoute } from "@cline/llms";
|
||||
import {
|
||||
@@ -1031,6 +1036,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
|
||||
@@ -1050,9 +1083,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: [
|
||||
@@ -1383,6 +1422,45 @@ export async function handleCommand(
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Managed hub upgrade ───────────────────────────────────────────
|
||||
if (command === "hub_upgrade") {
|
||||
// Replacing the shared Hub interrupts other clients' sessions, so it
|
||||
// carries the same per-connection gate as the tool-approval commands:
|
||||
// only the webview connection dialed with the approval token may ask,
|
||||
// never an arbitrary local WebSocket client.
|
||||
if (!options?.connection?.data?.canApproveTools) {
|
||||
throw new Error("hub upgrade requires a trusted desktop connection");
|
||||
}
|
||||
// Only reached after the user accepted the blocking "Hub update
|
||||
// required" dialog, so force: the old Hub is replaced even though it
|
||||
// is still serving other clients' sessions. Drain-first semantics
|
||||
// still give in-flight turns the wait window to finish.
|
||||
const result = await upgradeManagedHub({
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
force: true,
|
||||
reason: "Cline Desktop hub update",
|
||||
});
|
||||
if (result.outcome === "hub_not_older") {
|
||||
throw new Error(
|
||||
"The running Cline Hub is newer than this app, so it was not replaced. Update Cline instead.",
|
||||
);
|
||||
}
|
||||
if (result.outcome === "still_busy") {
|
||||
throw new Error(
|
||||
"The running Cline Hub picked up new sessions before it could be replaced, so it was left running. Try again.",
|
||||
);
|
||||
}
|
||||
// The mismatch is resolved: a null broadcast closes the dialog in
|
||||
// every connected webview and stops the replay-on-connect.
|
||||
ctx.hubBuildMismatch = null;
|
||||
broadcastEvent(ctx, "hub_build_mismatch", null);
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
url: result.url ?? null,
|
||||
interruptedSessionCount: result.activeSessionCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool approvals (in-memory) ────────────────────────────────────
|
||||
if (command === "poll_tool_approvals") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
@@ -1514,6 +1592,57 @@ export async function handleCommand(
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
return (await getSessionFromSidecarManager(ctx, sessionId)) ?? null;
|
||||
}
|
||||
|
||||
// ── Session import from other coding tools ────────────────────────
|
||||
if (command === "list_importable_sessions") {
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const importer = new SessionImportService(backend);
|
||||
return {
|
||||
installedTools: importer.installedTools(),
|
||||
sessions: await importer.discover(),
|
||||
};
|
||||
}
|
||||
if (command === "import_sessions") {
|
||||
const rawSelections = Array.isArray(args?.selections)
|
||||
? args.selections
|
||||
: [];
|
||||
const requests: SessionImportRequest[] = [];
|
||||
for (const selection of rawSelections) {
|
||||
if (!selection || typeof selection !== "object") continue;
|
||||
const tool = String((selection as JsonRecord).tool ?? "").trim();
|
||||
const sourceId = String((selection as JsonRecord).sourceId ?? "").trim();
|
||||
if (!sourceId) continue;
|
||||
if (!(SESSION_IMPORT_TOOLS as readonly string[]).includes(tool)) {
|
||||
continue;
|
||||
}
|
||||
requests.push({ tool: tool as SessionImportTool, sourceId });
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
throw new Error("at least one { tool, sourceId } selection is required");
|
||||
}
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const importer = new SessionImportService(backend);
|
||||
// Opening a history session resumes on the row's provider/model, so the
|
||||
// UI passes what a new chat would run on; the source tool's own
|
||||
// provider/model stay in metadata.importedFrom.
|
||||
// Never let the source tool's provider become the resume target: when
|
||||
// the caller sends no selection, use the app default like other
|
||||
// server-started sessions do.
|
||||
const provider = asTrimmedString(args?.provider) ?? "cline";
|
||||
const model = asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID;
|
||||
const results = await importer.importMany(
|
||||
requests,
|
||||
(result, index) => {
|
||||
broadcastEvent(ctx, "session_import_progress", {
|
||||
index,
|
||||
total: requests.length,
|
||||
result,
|
||||
});
|
||||
},
|
||||
{ provider, model },
|
||||
);
|
||||
return { results };
|
||||
}
|
||||
if (command === "update_chat_session_title") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
@@ -1989,7 +2118,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") {
|
||||
|
||||
@@ -1193,6 +1193,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", () => {
|
||||
|
||||
@@ -811,6 +811,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) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
checkManagedHubBuildMismatch,
|
||||
createClineTelemetryServiceConfig,
|
||||
readGlobalSettings,
|
||||
setHomeDirIfUnset,
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
@@ -65,6 +68,20 @@ async function main() {
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
// Web search is opt-in elsewhere in Cline, but the desktop app defaults
|
||||
// it to on. Seed the shared setting only when the user has never set it,
|
||||
// so an explicit off (from any Cline app) stays off. Best-effort: an
|
||||
// unwritable settings file must not block startup over a default.
|
||||
try {
|
||||
if (readGlobalSettings().tools?.web_search === undefined) {
|
||||
setModelToolEnabledGlobally("web_search", true);
|
||||
}
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Failed to seed web search default", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
@@ -148,6 +165,34 @@ async function main() {
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
},
|
||||
});
|
||||
// The watcher's first check only runs after its interval, but a mismatch
|
||||
// that already exists at startup - an older Hub this app attached to
|
||||
// because it is still serving other clients' sessions - must prompt
|
||||
// before the user starts working, not half a minute in. Session-manager
|
||||
// init has already settled the hub state, so check once right away. The
|
||||
// broadcast reaches webviews that are already connected; the replay in
|
||||
// createWebSocketHandler covers ones that connect later. Skipped when
|
||||
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
|
||||
// hosts keep protocol-only compatibility and must not show update prompts.
|
||||
if (!process.env.CLINE_HUB_PORT?.trim()) {
|
||||
void checkManagedHubBuildMismatch()
|
||||
.then((mismatch) => {
|
||||
if (!mismatch || ctx.hubBuildMismatch) {
|
||||
return;
|
||||
}
|
||||
ctx.hubBuildMismatch = mismatch;
|
||||
observability.logger.log(
|
||||
"Managed hub build mismatch detected at startup",
|
||||
{
|
||||
hubBuildId: mismatch.hubBuildId,
|
||||
hubCoreVersion: mismatch.hubCoreVersion,
|
||||
reason: mismatch.reason,
|
||||
},
|
||||
);
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Cline uses the microphone to transcribe speech into chat input.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Cline uses speech recognition to turn your voice into chat input.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Voice input requires audio capture access when Hardened Runtime is enabled. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -755,6 +755,25 @@ fn restart_to_apply_update(
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Relaunch the current version of the app. Used after the sidecar replaces
|
||||
/// the shared Cline Hub under the running app (the "Cline Hub update
|
||||
/// required" flow): a fresh launch attaches everything to the new Hub instead
|
||||
/// of trying to migrate live connections. restart() never returns, so the
|
||||
/// run-loop Exit handler cannot stop the sidecar; do it explicitly first.
|
||||
#[tauri::command]
|
||||
fn relaunch_app(app: tauri::AppHandle, backend_state: State<'_, Arc<DesktopBackendState>>) {
|
||||
backend_state.stop();
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Quit the app. Used by the "Cline Hub update required" flow when the user
|
||||
/// chooses to keep the older running Hub (and its live sessions) and update
|
||||
/// later. The run-loop Exit handler stops the sidecar.
|
||||
#[tauri::command]
|
||||
fn quit_app(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Run one updater check/download/stage cycle immediately instead of waiting
|
||||
/// for the next background interval, and report the resulting status. Used by
|
||||
/// flows that need an update staged right now (e.g. the "Cline Hub was
|
||||
@@ -1212,7 +1231,9 @@ fn main() {
|
||||
set_app_icon,
|
||||
show_session_notification,
|
||||
drain_desktop_actions,
|
||||
set_tray_status
|
||||
set_tray_status,
|
||||
relaunch_app,
|
||||
quit_app
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri app")
|
||||
@@ -1237,6 +1258,16 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn macos_bundle_declares_voice_input_permissions() {
|
||||
let info_plist = include_str!("../Info.plist");
|
||||
assert!(info_plist.contains("<key>NSMicrophoneUsageDescription</key>"));
|
||||
assert!(info_plist.contains("<key>NSSpeechRecognitionUsageDescription</key>"));
|
||||
|
||||
let entitlements = include_str!("../entitlements.plist");
|
||||
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_actions_are_buffered_in_order_until_drained() {
|
||||
let state = DesktopActionState::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -258,6 +258,155 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(buttonWithText("Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("folds a schedule's runs into one collapsible row", async () => {
|
||||
const run = (index: number) => ({
|
||||
...makeThread("alpha", index),
|
||||
title: "Report today's date to the user.",
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily date report",
|
||||
scheduleRunNumber: index,
|
||||
});
|
||||
const openThread = vi.fn();
|
||||
const sessionHistory = makeSessionHistory(
|
||||
[run(2), makeThread("beta", 1), run(1)],
|
||||
vi.fn(),
|
||||
);
|
||||
(sessionHistory as { openThread: unknown }).openThread = openThread;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// One header per schedule, named after the schedule rather than the
|
||||
// prompt, with the run count; the runs themselves start collapsed.
|
||||
const header = sessionRow("Daily date report");
|
||||
expect(header.textContent).toContain("2 runs");
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(header.querySelector('[aria-label="Scheduled"]')).not.toBeNull();
|
||||
expect(sessionIsVisible("Report today's date to the user.")).toBe(false);
|
||||
expect(sessionIsVisible("Run 2")).toBe(false);
|
||||
// The Scheduled section counts schedules, not runs.
|
||||
expect(buttonWithText("Scheduled").textContent).toContain("1");
|
||||
expect(sessionIsVisible("beta session 1")).toBe(true);
|
||||
|
||||
await click(header);
|
||||
expect(header.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(sessionIsVisible("Run 2")).toBe(true);
|
||||
expect(sessionIsVisible("Run 1")).toBe(true);
|
||||
// Nested runs don't repeat the clock the header already shows.
|
||||
expect(
|
||||
sessionRow("Run 1").querySelector('[aria-label="Scheduled"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sessionRow("Run 2").compareDocumentPosition(sessionRow("Run 1")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
await click(sessionRow("Run 1"));
|
||||
expect(openThread).toHaveBeenCalledWith("alpha-1");
|
||||
|
||||
await click(header);
|
||||
expect(sessionIsVisible("Run 1")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands the schedule group that holds the active session", async () => {
|
||||
const run = (index: number) => ({
|
||||
...makeThread("alpha", index),
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily date report",
|
||||
scheduleRunNumber: index,
|
||||
});
|
||||
const sessionHistory = makeSessionHistory([run(2), run(1)], vi.fn());
|
||||
const render = async (activeSessionId: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeSessionId}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render("alpha-1");
|
||||
expect(sessionRow("Daily date report").getAttribute("aria-expanded")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(sessionIsVisible("Run 1")).toBe(true);
|
||||
|
||||
// The group can still be collapsed while it holds the active session,
|
||||
// and stays collapsed across re-renders.
|
||||
await click(sessionRow("Daily date report"));
|
||||
await render("alpha-1");
|
||||
expect(sessionIsVisible("Run 1")).toBe(false);
|
||||
|
||||
// Opening another run of the schedule (e.g. from the Schedules
|
||||
// settings page) reopens the collapsed group so the run is visible.
|
||||
await render("alpha-2");
|
||||
expect(sessionRow("Daily date report").getAttribute("aria-expanded")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(sessionIsVisible("Run 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("groups scheduled runs inside their project when sorted by project", async () => {
|
||||
const run = (index: number) => ({
|
||||
...makeThread("alpha", index),
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily date report",
|
||||
scheduleRunNumber: index,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory(
|
||||
[run(2), makeThread("alpha", 3), run(1)],
|
||||
vi.fn(),
|
||||
)}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
await switchToProjectSort();
|
||||
|
||||
const header = sessionRow("Daily date report");
|
||||
expect(header.textContent).toContain("2 runs");
|
||||
expect(sessionIsVisible("alpha session 3")).toBe(true);
|
||||
expect(sessionIsVisible("Run 2")).toBe(false);
|
||||
await click(header);
|
||||
expect(sessionIsVisible("Run 2")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to Pinned, Scheduled, and Tasks sections sorted by time", async () => {
|
||||
const pinned = { ...makeThread("alpha", 1), pinned: true };
|
||||
const scheduled = { ...makeThread("beta", 1), isScheduled: true };
|
||||
@@ -564,6 +713,19 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(
|
||||
getSessionOverviewItems(thread).some(([label]) => label === "Status"),
|
||||
).toBe(false);
|
||||
// Scheduled runs lead with the schedule they belong to and which run
|
||||
// this is; the row itself only says "Run N".
|
||||
expect(
|
||||
getSessionOverviewItems({
|
||||
...makeThread("cline", 6),
|
||||
isScheduled: true,
|
||||
scheduleName: "Daily date report",
|
||||
scheduleRunNumber: 6,
|
||||
}).slice(0, 2),
|
||||
).toEqual([
|
||||
["Schedule", "Daily date report"],
|
||||
["Run", "6"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the full first line of the session title", () => {
|
||||
|
||||
@@ -96,8 +96,13 @@ import {
|
||||
getSessionSources,
|
||||
} from "@/lib/session-history";
|
||||
import {
|
||||
groupScheduledThreads,
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
type SidebarListRow,
|
||||
type SidebarScheduleGroup,
|
||||
scheduleGroupKey,
|
||||
scheduleRunLabel,
|
||||
workspaceDisplayName,
|
||||
} from "@/lib/sidebar-session-organization";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -325,6 +330,12 @@ export function AgentSidebar({
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Explicit expand/collapse choices per schedule group. Groups without an
|
||||
// entry default to expanded only while they hold the active session, so a
|
||||
// run opened from elsewhere (e.g. the Schedules settings page) is visible.
|
||||
const [scheduleGroupExpanded, setScheduleGroupExpanded] = useState<
|
||||
Map<string, boolean>
|
||||
>(() => new Map());
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
@@ -537,6 +548,40 @@ export function AgentSidebar({
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Opening a session drops the stored choice for the group that holds it,
|
||||
// so a run opened elsewhere is visible even if its group was collapsed
|
||||
// earlier. Keyed on the session and group ids rather than the thread list
|
||||
// so a history refresh doesn't undo a collapse made while it is open.
|
||||
const activeScheduleGroupId = useMemo(() => {
|
||||
const thread = threads.find((t) => t.id === activeThread);
|
||||
return thread ? scheduleGroupKey(thread) : null;
|
||||
}, [activeThread, threads]);
|
||||
useEffect(() => {
|
||||
if (!activeThread || !activeScheduleGroupId) return;
|
||||
setScheduleGroupExpanded((current) => {
|
||||
if (!current.has(activeScheduleGroupId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(activeScheduleGroupId);
|
||||
return next;
|
||||
});
|
||||
}, [activeThread, activeScheduleGroupId]);
|
||||
const isScheduleGroupExpanded = useCallback(
|
||||
(group: SidebarScheduleGroup) =>
|
||||
scheduleGroupExpanded.get(group.id) ??
|
||||
group.threads.some((thread) => thread.id === activeThread),
|
||||
[activeThread, scheduleGroupExpanded],
|
||||
);
|
||||
const toggleScheduleGroup = useCallback(
|
||||
(group: SidebarScheduleGroup) => {
|
||||
const expanded = isScheduleGroupExpanded(group);
|
||||
setScheduleGroupExpanded((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(group.id, !expanded);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isScheduleGroupExpanded],
|
||||
);
|
||||
const toggleProject = useCallback((project: string) => {
|
||||
setCollapsedProjects((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -635,13 +680,15 @@ export function AgentSidebar({
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
const threadItem = (thread: Thread) => (
|
||||
const threadItem = (thread: Thread, options?: { nested?: boolean }) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
editing={editingSessionId === thread.id}
|
||||
hoverCardOpen={hoverCardThreadId === thread.id}
|
||||
isActive={activeThread === thread.id}
|
||||
key={thread.id}
|
||||
label={options?.nested ? scheduleRunLabel(thread) : undefined}
|
||||
nested={options?.nested}
|
||||
onHoverCardOpenChange={(open) =>
|
||||
setHoverCardThreadId((current) =>
|
||||
open ? thread.id : current === thread.id ? null : current,
|
||||
@@ -662,6 +709,30 @@ export function AgentSidebar({
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
);
|
||||
// Scheduled runs collapse into one row per schedule; everything else
|
||||
// renders as before.
|
||||
const listRow = (row: SidebarListRow) => {
|
||||
if (row.kind === "thread") {
|
||||
return threadItem(row.thread);
|
||||
}
|
||||
const expanded = isScheduleGroupExpanded(row);
|
||||
return (
|
||||
<ScheduleGroupRow
|
||||
active={row.threads.some((thread) => thread.id === activeThread)}
|
||||
expanded={expanded}
|
||||
group={row}
|
||||
key={row.id}
|
||||
onToggle={() => toggleScheduleGroup(row)}
|
||||
unread={row.threads.some((thread) => unreadSessionIds.has(thread.id))}
|
||||
>
|
||||
{row.threads.map((thread) => threadItem(thread, { nested: true }))}
|
||||
</ScheduleGroupRow>
|
||||
);
|
||||
};
|
||||
const scheduledRows = useMemo(
|
||||
() => groupScheduledThreads(scheduledThreads),
|
||||
[scheduledThreads],
|
||||
);
|
||||
const customizeSectionOpen =
|
||||
view === "settings" &&
|
||||
(CUSTOMIZATION_SECTIONS as readonly SettingsSection[]).includes(
|
||||
@@ -995,20 +1066,22 @@ export function AgentSidebar({
|
||||
label="Pinned"
|
||||
onToggle={() => toggleSection("pinned")}
|
||||
>
|
||||
{pinnedThreads.map(threadItem)}
|
||||
{pinnedThreads.map((thread) =>
|
||||
threadItem(thread),
|
||||
)}
|
||||
</CategorySection>
|
||||
) : null}
|
||||
{scheduledThreads.length > 0 ? (
|
||||
{scheduledRows.length > 0 ? (
|
||||
<CategorySection
|
||||
collapsed={collapsedSections.has("scheduled")}
|
||||
count={scheduledThreads.length}
|
||||
count={scheduledRows.length}
|
||||
label="Scheduled"
|
||||
onToggle={() => toggleSection("scheduled")}
|
||||
>
|
||||
{scheduledThreads
|
||||
{scheduledRows
|
||||
.slice(0, scheduledVisibleCount)
|
||||
.map(threadItem)}
|
||||
{scheduledThreads.length >
|
||||
.map(listRow)}
|
||||
{scheduledRows.length >
|
||||
scheduledVisibleCount ? (
|
||||
<Button
|
||||
className="px-2!"
|
||||
@@ -1037,19 +1110,24 @@ export function AgentSidebar({
|
||||
>
|
||||
{taskThreads
|
||||
.slice(0, showMoreCount)
|
||||
.map(threadItem)}
|
||||
.map((thread) => threadItem(thread))}
|
||||
{showTimeShowMore ? timeShowMoreButton : null}
|
||||
</CategorySection>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
taskThreads.slice(0, showMoreCount).map(threadItem)
|
||||
taskThreads
|
||||
.slice(0, showMoreCount)
|
||||
.map((thread) => threadItem(thread))
|
||||
)
|
||||
) : (
|
||||
projectGroups.map((project) => {
|
||||
const visibleCount =
|
||||
projectVisibleCounts[project.id] ??
|
||||
INITIAL_VISIBLE_THREAD_COUNT;
|
||||
const projectRows = groupScheduledThreads(
|
||||
project.threads,
|
||||
);
|
||||
return (
|
||||
<ProjectSection
|
||||
collapsed={collapsedProjects.has(project.id)}
|
||||
@@ -1057,10 +1135,8 @@ export function AgentSidebar({
|
||||
label={project.label}
|
||||
onToggle={() => toggleProject(project.id)}
|
||||
>
|
||||
{project.threads
|
||||
.slice(0, visibleCount)
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
{projectRows.slice(0, visibleCount).map(listRow)}
|
||||
{projectRows.length > visibleCount ? (
|
||||
<Button
|
||||
className="max-w-full pl-2!"
|
||||
onClick={() => showMoreForProject(project.id)}
|
||||
@@ -1312,12 +1388,83 @@ function ProjectSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleGroupRow({
|
||||
group,
|
||||
expanded,
|
||||
active,
|
||||
unread,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
group: SidebarScheduleGroup;
|
||||
expanded: boolean;
|
||||
/** One of the runs is the open session; the header shows it while collapsed. */
|
||||
active: boolean;
|
||||
unread: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const running = group.threads.some((thread) => thread.status === "running");
|
||||
const statusDotClass = running ? "bg-green-500" : unread ? "bg-blue-500" : "";
|
||||
const runCount = group.threads.length;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
"grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
|
||||
active && !expanded
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-surface-hover",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
title={group.label}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform",
|
||||
!expanded && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<Clock3
|
||||
aria-label="Scheduled"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{group.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
{statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
<span>
|
||||
{runCount} {runCount === 1 ? "run" : "runs"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="ml-4 flex min-w-0 flex-col gap-0.5 border-l border-sidebar-border/70 pl-1">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadItem({
|
||||
thread,
|
||||
editTitle,
|
||||
editing,
|
||||
hoverCardOpen,
|
||||
isActive,
|
||||
label,
|
||||
nested = false,
|
||||
onClick,
|
||||
onHoverCardOpenChange,
|
||||
onCancelRename,
|
||||
@@ -1335,6 +1482,10 @@ function ThreadItem({
|
||||
editing: boolean;
|
||||
hoverCardOpen: boolean;
|
||||
isActive: boolean;
|
||||
/** Row text when it should not be the session title (a run inside a schedule group). */
|
||||
label?: string;
|
||||
/** Rendered inside a schedule group: the group already shows the clock. */
|
||||
nested?: boolean;
|
||||
onClick: () => void;
|
||||
onHoverCardOpenChange: (open: boolean) => void;
|
||||
onCancelRename: () => void;
|
||||
@@ -1348,6 +1499,7 @@ function ThreadItem({
|
||||
unread: boolean;
|
||||
}) {
|
||||
const title = normalizeTitle(thread.title);
|
||||
const rowText = label ?? title;
|
||||
const overviewTitle = getSessionOverviewTitle(title);
|
||||
const pending = pendingAction !== null;
|
||||
const statusDotClass = pending
|
||||
@@ -1411,14 +1563,14 @@ function ThreadItem({
|
||||
type="button"
|
||||
>
|
||||
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{thread.isScheduled ? (
|
||||
{thread.isScheduled && !nested ? (
|
||||
<Clock3
|
||||
aria-label="Scheduled"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
{rowText}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -1508,6 +1660,8 @@ export function getSessionOverviewItems(
|
||||
// Updated time is already visible in the sidebar item.
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const items: Array<[string, string | null | undefined, string?]> = [
|
||||
["Schedule", thread.scheduleName],
|
||||
["Run", thread.scheduleRunNumber ? String(thread.scheduleRunNumber) : null],
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
|
||||
@@ -90,6 +90,15 @@ class FakeSpeechRecognition extends EventTarget {
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
emitError(error: string, message?: string): void {
|
||||
const event = new Event("error");
|
||||
Object.defineProperties(event, {
|
||||
error: { value: error },
|
||||
message: { value: message },
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -180,6 +189,28 @@ describe("SpeechInput", () => {
|
||||
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
|
||||
});
|
||||
|
||||
it("preserves browser speech-recognition error details", async () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<SpeechInput onError={onError} recordingMode="auto" />);
|
||||
});
|
||||
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Record speech"]',
|
||||
);
|
||||
await act(async () => button?.click());
|
||||
await act(async () => {
|
||||
FakeSpeechRecognition.instances[0]?.emitError("not-allowed");
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0]?.[0]).toEqual(
|
||||
new Error("Speech recognition failed: not-allowed"),
|
||||
);
|
||||
expect(button?.getAttribute("aria-label")).toBe("Record speech");
|
||||
});
|
||||
|
||||
it("records audio and forwards the provider transcript", async () => {
|
||||
FakeMediaRecorder.deferStopEvents = true;
|
||||
let resolveTranscript: (transcript: string) => void = () => {};
|
||||
|
||||
@@ -21,6 +21,11 @@ interface SpeechRecognitionEvent extends Event {
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
@@ -37,6 +42,20 @@ interface SpeechRecognitionAlternative {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function errorFromEvent(event: Event, fallbackMessage: string): Error {
|
||||
const eventError = (event as Event & { error?: unknown }).error;
|
||||
if (eventError instanceof Error) return eventError;
|
||||
|
||||
const eventMessage = (event as Event & { message?: unknown }).message;
|
||||
if (typeof eventMessage === "string" && eventMessage.trim()) {
|
||||
return new Error(eventMessage.trim());
|
||||
}
|
||||
if (typeof eventError === "string" && eventError.trim()) {
|
||||
return new Error(`${fallbackMessage}: ${eventError.trim()}`);
|
||||
}
|
||||
return new Error(fallbackMessage);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: new () => SpeechRecognition;
|
||||
@@ -191,7 +210,10 @@ export function SpeechInput({
|
||||
};
|
||||
const handleError = (event: Event) => {
|
||||
setIsListening(false);
|
||||
onErrorRef.current?.(event);
|
||||
const speechError = event as SpeechRecognitionErrorEvent;
|
||||
onErrorRef.current?.(
|
||||
errorFromEvent(speechError, "Speech recognition failed"),
|
||||
);
|
||||
};
|
||||
|
||||
recognition.addEventListener("start", handleStart);
|
||||
@@ -302,7 +324,7 @@ export function SpeechInput({
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
onErrorRef.current?.(event);
|
||||
onErrorRef.current?.(errorFromEvent(event, "Audio recording failed"));
|
||||
});
|
||||
recorder.addEventListener("stop", async () => {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
|
||||
@@ -14,14 +14,23 @@ import {
|
||||
import {
|
||||
checkForUpdateNow,
|
||||
restartToApplyUpdate,
|
||||
useAppUpdateStatus,
|
||||
} from "@/hooks/use-app-update";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { resolveHubUpdateRestartDecision } from "./hub-update-required-helpers";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
type HubBuildMismatchPayload = {
|
||||
hubBuildId?: string;
|
||||
hubCoreVersion?: string;
|
||||
reason?: string;
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
};
|
||||
|
||||
function mismatchKeyOf(payload: HubBuildMismatchPayload): string {
|
||||
@@ -30,38 +39,114 @@ function mismatchKeyOf(payload: HubBuildMismatchPayload): string {
|
||||
|
||||
type UpdatePhase = "idle" | "updating" | "restarting";
|
||||
|
||||
/** Generous deadline: drain wait + graceful retire + fresh daemon startup. */
|
||||
const HUB_UPGRADE_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Blocking prompt shown when the sidecar reports that another Cline
|
||||
* installation (for example an updated CLI) replaced the shared Cline Hub
|
||||
* with a different build. Accepting runs an updater check/download right
|
||||
* away and, once an update is staged, restarts into it so the app and the
|
||||
* Hub run the same version again. If nothing is staged (no release published
|
||||
* yet, or the check failed), the dialog explains why instead of restarting
|
||||
* into the same version and immediately re-prompting.
|
||||
* "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.
|
||||
*
|
||||
* Two directions, two dialogs:
|
||||
* - `build_mismatch` / `unsupported_protocol`: the Hub is newer than this
|
||||
* app. Accepting runs an updater check/download right away and, once an
|
||||
* update is staged, restarts into it so the app and the Hub run the same
|
||||
* version again. Dismissible - the app keeps working over the compatible
|
||||
* wire protocol.
|
||||
* - `outdated_hub`: this app is the newer build, and the running Hub was
|
||||
* left in place only because it is still serving other clients' sessions.
|
||||
* Not dismissible: the user chooses between updating the Hub now (which
|
||||
* interrupts those sessions, then relaunches the app onto the fresh Hub)
|
||||
* and quitting the app (the old Hub keeps running for its clients).
|
||||
*/
|
||||
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) => {
|
||||
// A null broadcast means the mismatch was resolved (the Hub was
|
||||
// upgraded, possibly from another window): close the dialog.
|
||||
if (payload === null) {
|
||||
setMismatch(null);
|
||||
setUpdateHint(null);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
|
||||
|
||||
// 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");
|
||||
@@ -79,20 +164,114 @@ export function HubUpdateRequiredDialog() {
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
// `outdated_hub` is purely informational: this app is already the newer
|
||||
// build, nothing is asked of the user, and the Hub is replaced on its own
|
||||
// once its sessions end. Interrupting with a modal to say "ignore me"
|
||||
// helps nobody, so that reason renders nothing.
|
||||
const handleUpgradeHub = useCallback(async () => {
|
||||
setPhase("updating");
|
||||
setUpdateHint(null);
|
||||
try {
|
||||
await desktopClient.invoke("hub_upgrade", undefined, {
|
||||
timeoutMs: HUB_UPGRADE_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
setUpdateHint(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Updating the Cline Hub failed. Try again, or run 'cline doctor fix' in a terminal.",
|
||||
);
|
||||
setPhase("idle");
|
||||
return;
|
||||
}
|
||||
setPhase("restarting");
|
||||
setMismatch(null);
|
||||
// Relaunch into a clean slate attached to the fresh Hub. In plain
|
||||
// web/dev mode there is no Tauri shell to relaunch; reloading the page
|
||||
// reconnects everything instead.
|
||||
try {
|
||||
await desktopClient.invoke("relaunch_app");
|
||||
} catch {
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleQuit = useCallback(async () => {
|
||||
try {
|
||||
await desktopClient.invoke("quit_app");
|
||||
} catch {
|
||||
// Plain web/dev mode: no Tauri shell to exit. Best-effort close;
|
||||
// browsers only honor this for script-opened windows.
|
||||
window.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// This app needs a newer Hub than the one running, and the running one was
|
||||
// deliberately left in place because it is serving sessions. Block until
|
||||
// the user picks a side: replace the Hub now, or quit and update later.
|
||||
if (mismatch?.reason === "outdated_hub") {
|
||||
return null;
|
||||
return (
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cline Hub update required</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cline needs a newer Cline Hub, but the running one is still
|
||||
serving {describeOutdatedHubSessions(mismatch)}.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
Update Now stops that Hub and interrupts its sessions. Quit Cline
|
||||
closes this app and leaves the Hub running, so you can update
|
||||
later.
|
||||
</AlertDialogDescription>
|
||||
{updateHint ? (
|
||||
<AlertDialogDescription>{updateHint}</AlertDialogDescription>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={phase !== "idle"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleQuit();
|
||||
}}
|
||||
>
|
||||
Quit Cline
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={phase !== "idle"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleUpgradeHub();
|
||||
}}
|
||||
>
|
||||
{phase === "restarting"
|
||||
? "Restarting…"
|
||||
: phase === "updating"
|
||||
? "Updating…"
|
||||
: updateHint
|
||||
? "Try again"
|
||||
: "Update Now"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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,5 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveHubUpdateRestartDecision } from "./hub-update-required-helpers";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
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("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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHubUpdateRestartDecision", () => {
|
||||
it("restarts only once an update is staged", () => {
|
||||
|
||||
@@ -4,6 +4,83 @@ export type HubUpdateRestartDecision =
|
||||
| { action: "restart" }
|
||||
| { action: "stay"; hint: string };
|
||||
|
||||
/**
|
||||
* 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 shouldShowHubMismatchDialog(
|
||||
reason: string | undefined,
|
||||
updateState: AppUpdateStatus["state"] | undefined,
|
||||
): boolean {
|
||||
if (reason === "unsupported_protocol" || reason === "outdated_hub") {
|
||||
return true;
|
||||
}
|
||||
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 previousDismissedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what "Update and restart" should do after an on-demand updater
|
||||
* check. Restart only when an update is actually staged - relaunching the
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
||||
import { basenamePath, formatRelativeTime } from "@/hooks/use-session-history";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
type ImportableSession,
|
||||
importSelectionKey,
|
||||
type ListImportableSessionsResponse,
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
SESSION_IMPORT_TOOL_ORDER,
|
||||
type SessionImportProgressEvent,
|
||||
type SessionImportResult,
|
||||
} from "@/lib/session-import";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImportPhase = "loading" | "pick" | "importing" | "done";
|
||||
|
||||
type ImportSessionsDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called after at least one session imported successfully. */
|
||||
onImported?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider/model a new chat would start with right now, resolved the same
|
||||
* way a new chat resolves it (remembered selection, then the built-in
|
||||
* default). Imported sessions are stamped with it so "continue this in
|
||||
* Cline" runs on what the user actually uses instead of the source tool's
|
||||
* provider. Reading model-selection storage directly is not enough: the
|
||||
* composer only records a model when the user picks one explicitly, so
|
||||
* anyone on the default model has no entry there.
|
||||
*/
|
||||
function resumeTarget(): { provider: string; model: string } {
|
||||
const { provider, model } = getInitialChatConfig();
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function matchesQuery(session: ImportableSession, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const haystack =
|
||||
`${session.title} ${session.cwd} ${session.preview ?? ""}`.toLowerCase();
|
||||
return query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
export function ImportSessionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: ImportSessionsDialogProps) {
|
||||
const [phase, setPhase] = useState<ImportPhase>("loading");
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<ImportableSession[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [collapsedTools, setCollapsedTools] = useState<Set<string>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [progress, setProgress] = useState<{ done: number; total: number }>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [results, setResults] = useState<SessionImportResult[]>([]);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
setPhase("loading");
|
||||
setScanError(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<ListImportableSessionsResponse>(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
// Scanning reads every source session file once; large
|
||||
// histories can take longer than the default RPC timeout.
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
setSessions(response.sessions ?? []);
|
||||
setPhase("pick");
|
||||
} catch (error) {
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
setPhase("pick");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fresh state on every open, then scan.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelected(new Set());
|
||||
setCollapsedTools(new Set());
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setProgress({ done: 0, total: 0 });
|
||||
void scan();
|
||||
}, [open, scan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return desktopClient.subscribe("session_import_progress", (payload) => {
|
||||
const event = payload as SessionImportProgressEvent | undefined;
|
||||
if (!event || typeof event.index !== "number") return;
|
||||
setProgress({ done: event.index + 1, total: event.total });
|
||||
if (event.result) {
|
||||
setResults((previous) => [...previous, event.result]);
|
||||
}
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
const visibleSessions = useMemo(
|
||||
() => sessions.filter((session) => matchesQuery(session, query.trim())),
|
||||
[sessions, query],
|
||||
);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
SESSION_IMPORT_TOOL_ORDER.map((tool) => ({
|
||||
tool,
|
||||
sessions: visibleSessions.filter((session) => session.tool === tool),
|
||||
})).filter((group) => group.sessions.length > 0),
|
||||
[visibleSessions],
|
||||
);
|
||||
|
||||
const selectableKeys = useCallback(
|
||||
(items: ImportableSession[]) =>
|
||||
items
|
||||
.filter((session) => !session.alreadyImportedSessionId)
|
||||
.map((session) => importSelectionKey(session.tool, session.sourceId)),
|
||||
[],
|
||||
);
|
||||
|
||||
const isFiltering = query.trim().length > 0;
|
||||
const visibleSelectableKeys = selectableKeys(visibleSessions);
|
||||
const selectedVisibleCount = visibleSelectableKeys.filter((key) =>
|
||||
selected.has(key),
|
||||
).length;
|
||||
const allVisibleSelected =
|
||||
visibleSelectableKeys.length > 0 &&
|
||||
selectedVisibleCount === visibleSelectableKeys.length;
|
||||
|
||||
const toggleAll = (items: ImportableSession[], checked: boolean) => {
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const key of selectableKeys(items)) {
|
||||
if (checked) next.add(key);
|
||||
else next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const startImport = async () => {
|
||||
const selections = sessions
|
||||
.filter((session) =>
|
||||
selected.has(importSelectionKey(session.tool, session.sourceId)),
|
||||
)
|
||||
.map((session) => ({ tool: session.tool, sourceId: session.sourceId }));
|
||||
if (selections.length === 0) return;
|
||||
setPhase("importing");
|
||||
setResults([]);
|
||||
setProgress({ done: 0, total: selections.length });
|
||||
try {
|
||||
const response = await desktopClient.invoke<{
|
||||
results: SessionImportResult[];
|
||||
}>(
|
||||
"import_sessions",
|
||||
{ selections, ...resumeTarget() },
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
// The progress events already streamed results; trust the final
|
||||
// response as the authoritative list.
|
||||
setResults(response.results ?? []);
|
||||
if ((response.results ?? []).some((result) => result.ok)) {
|
||||
onImported?.();
|
||||
}
|
||||
} catch (error) {
|
||||
setResults([
|
||||
{
|
||||
tool: "claude-code",
|
||||
sourceId: "",
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
]);
|
||||
}
|
||||
setPhase("done");
|
||||
};
|
||||
|
||||
const succeeded = results.filter((result) => result.ok);
|
||||
const failures = results.filter((result) => !result.ok);
|
||||
const importableCount = sessions.filter(
|
||||
(session) => !session.alreadyImportedSessionId,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(next) => {
|
||||
// Don't let a stray overlay click abandon a running import.
|
||||
if (!next && phase === "importing") return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{/* The single column must be minmax(0,1fr): with the default auto
|
||||
track, one unbreakable string in a session title widens the
|
||||
column's min-content beyond the fixed dialog width and
|
||||
overflow-hidden clips the header and search field. The explicit
|
||||
sm:max-w-none is needed because the primitive's sm:max-w-lg
|
||||
survives class merging across variants. */}
|
||||
<DialogContent className="grid h-[min(680px,calc(100dvh-2rem))] w-[min(620px,calc(100vw-2rem))] max-w-none grid-cols-[minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden sm:max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import sessions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bring your conversation history from other coding tools into Cline.
|
||||
Imported sessions appear in your history and can be continued here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === "loading" ? (
|
||||
<div className="flex min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<p className="text-sm">Scanning for sessions…</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "pick" ? (
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
{scanError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Couldn't scan for sessions: {scanError}
|
||||
</p>
|
||||
) : null}
|
||||
{sessions.length === 0 && !scanError ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
No sessions found
|
||||
</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Cline looks for local history from Claude Code, Codex, and
|
||||
opencode. Nothing importable turned up on this machine.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{sessions.length > 0 ? (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Filter sessions"
|
||||
className="h-8 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Filter by title or folder"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b pb-2">
|
||||
<Checkbox
|
||||
aria-label="Select all sessions"
|
||||
checked={
|
||||
allVisibleSelected
|
||||
? true
|
||||
: selectedVisibleCount > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={visibleSelectableKeys.length === 0}
|
||||
id="import-select-all"
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAll(visibleSessions, checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="cursor-pointer text-sm text-foreground"
|
||||
htmlFor="import-select-all"
|
||||
>
|
||||
Select all
|
||||
</label>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{selectedVisibleCount} of {visibleSelectableKeys.length}{" "}
|
||||
selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{groups.map((group) => {
|
||||
const keys = selectableKeys(group.sessions);
|
||||
const selectedInGroup = keys.filter((key) =>
|
||||
selected.has(key),
|
||||
).length;
|
||||
const allChecked =
|
||||
keys.length > 0 && selectedInGroup === keys.length;
|
||||
// A collapsed section would hide filter matches, so
|
||||
// filtering forces every section open.
|
||||
const collapsed =
|
||||
!isFiltering && collapsedTools.has(group.tool);
|
||||
return (
|
||||
<section className="mb-4" key={group.tool}>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-background py-1.5">
|
||||
<Checkbox
|
||||
aria-label={`Select all ${SESSION_IMPORT_TOOL_LABELS[group.tool]} sessions`}
|
||||
checked={
|
||||
allChecked
|
||||
? true
|
||||
: selectedInGroup > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={keys.length === 0}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAll(group.sessions, checked === true)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm py-0.5 text-left hover:text-foreground"
|
||||
onClick={() =>
|
||||
setCollapsedTools((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group.tool)) {
|
||||
next.delete(group.tool);
|
||||
} else {
|
||||
next.add(group.tool);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{SESSION_IMPORT_TOOL_LABELS[group.tool]}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.sessions.length}
|
||||
{selectedInGroup > 0
|
||||
? ` · ${selectedInGroup} selected`
|
||||
: ""}
|
||||
</span>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="ml-auto size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<ul hidden={collapsed}>
|
||||
{group.sessions.map((session) => {
|
||||
const key = importSelectionKey(
|
||||
session.tool,
|
||||
session.sourceId,
|
||||
);
|
||||
const alreadyImported = Boolean(
|
||||
session.alreadyImportedSessionId,
|
||||
);
|
||||
const checked = selected.has(key);
|
||||
const checkboxId = `import-session-${key}`;
|
||||
return (
|
||||
<li key={key}>
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2.5 rounded-md px-1.5 py-2 hover:bg-muted/40",
|
||||
alreadyImported &&
|
||||
"cursor-default opacity-60",
|
||||
)}
|
||||
htmlFor={checkboxId}
|
||||
>
|
||||
<Checkbox
|
||||
aria-label={`Import "${session.title}"`}
|
||||
checked={checked}
|
||||
className="mt-0.5"
|
||||
disabled={alreadyImported}
|
||||
id={checkboxId}
|
||||
onCheckedChange={(next) =>
|
||||
setSelected((previous) => {
|
||||
const nextSet = new Set(previous);
|
||||
if (next === true) nextSet.add(key);
|
||||
else nextSet.delete(key);
|
||||
return nextSet;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<span className="line-clamp-2 min-w-0 break-words text-sm text-foreground">
|
||||
{session.title}
|
||||
</span>
|
||||
{alreadyImported ? (
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
variant="secondary"
|
||||
>
|
||||
Imported
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="shrink-0">
|
||||
{formatRelativeTime(
|
||||
new Date(
|
||||
session.updatedAtMs,
|
||||
).toISOString(),
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="shrink-0">
|
||||
{session.messageCount} message
|
||||
{session.messageCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
{session.cwd ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{basenamePath(session.cwd)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No sessions match "{query.trim()}".
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "importing" ? (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground">Importing sessions…</span>
|
||||
<span className="text-muted-foreground">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
progress.total > 0
|
||||
? (progress.done / progress.total) * 100
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1 text-sm">
|
||||
{results.map((result) => (
|
||||
<li
|
||||
className="flex items-center gap-2"
|
||||
key={importSelectionKey(result.tool, result.sourceId)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{result.title ?? result.sourceId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "done" ? (
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<p className="text-sm text-foreground">
|
||||
{succeeded.length > 0
|
||||
? `Imported ${succeeded.length} session${succeeded.length === 1 ? "" : "s"}.`
|
||||
: "No sessions were imported."}
|
||||
{failures.length > 0
|
||||
? ` ${failures.length} failed.`
|
||||
: succeeded.length > 0
|
||||
? " They're in your history now."
|
||||
: ""}
|
||||
</p>
|
||||
{failures.length > 0 ? (
|
||||
<ul className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 text-sm">
|
||||
{failures.map((failure) => (
|
||||
<li
|
||||
className="flex items-start gap-2"
|
||||
key={importSelectionKey(failure.tool, failure.sourceId)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-foreground">
|
||||
{failure.title ?? failure.sourceId}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{failure.error}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
{phase === "pick" ? (
|
||||
<>
|
||||
<span className="mr-auto self-center text-xs text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${selected.size} selected`
|
||||
: importableCount > 0
|
||||
? `${importableCount} available`
|
||||
: ""}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => void startImport()}
|
||||
type="button"
|
||||
>
|
||||
Import{selected.size > 0 ? ` ${selected.size}` : ""}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{phase === "importing" ? (
|
||||
<Button disabled type="button">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Importing…
|
||||
</Button>
|
||||
) : null}
|
||||
{phase === "done" ? (
|
||||
<Button onClick={() => onOpenChange(false)} type="button">
|
||||
Done
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -348,6 +348,7 @@ function ChatInputBarImpl({
|
||||
onSteerPromptInQueue,
|
||||
onEditPromptInQueue,
|
||||
onRemovePromptInQueue,
|
||||
onOpenVoiceInputSettings,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -752,24 +753,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),
|
||||
|
||||
@@ -331,6 +331,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[] = [
|
||||
{
|
||||
|
||||
+218
@@ -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(
|
||||
[
|
||||
|
||||
+46
-11
@@ -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);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BadgeCheck,
|
||||
Github,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Scale,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
@@ -28,12 +26,11 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Marketplace explorer: a two-pane master/detail directory in the spirit of
|
||||
* an IDE extensions panel. The left rail lists every catalog entry grouped by
|
||||
* primitive maturity (Skills, then MCP, then plugins); the right pane is a
|
||||
* full detail page for the selected entry with the catalog's metadata
|
||||
* (author, license, verified state, tags, install command, env setup) and
|
||||
* links out to the entry's homepage and repository.
|
||||
* Marketplace explorer: a master/detail directory in the spirit of an IDE
|
||||
* extensions panel. Initially the catalog list fills the view, grouped by
|
||||
* primitive maturity (Skills, then MCP, then plugins); clicking an entry
|
||||
* opens a detail panel with the catalog's metadata (author, verified state,
|
||||
* tags, install command, env setup) and a link out to the entry's homepage.
|
||||
*/
|
||||
|
||||
/** Ordered most-mature first: skills > MCP servers > plugins. */
|
||||
@@ -368,10 +365,12 @@ function MetaCell({
|
||||
function DetailPane({
|
||||
directory,
|
||||
entry,
|
||||
onClose,
|
||||
onSelectTag,
|
||||
}: {
|
||||
directory: MarketplaceDirectory;
|
||||
entry: MarketplaceEntry;
|
||||
onClose: () => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
}) {
|
||||
const meta = TYPE_META[entry.type];
|
||||
@@ -379,6 +378,9 @@ function DetailPane({
|
||||
const state = directory.actionStates.get(key);
|
||||
const installed = directory.installedKeys.has(key);
|
||||
const busy = isBusy(state);
|
||||
// Homepage is usually the entry's docs/product page; the repo is the
|
||||
// fallback since many entries set both to the same GitHub URL anyway.
|
||||
const learnMoreUrl = entry.homepage ?? entry.repo;
|
||||
const requiredEnv =
|
||||
entry.install.env?.filter((env) => env.required !== false) ?? [];
|
||||
const optionalEnv =
|
||||
@@ -392,7 +394,7 @@ function DetailPane({
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full min-w-0 flex-1">
|
||||
<div className="mx-auto grid max-w-3xl gap-6 px-8 py-8 max-[900px]:px-5">
|
||||
<div className="grid max-w-3xl gap-6 px-8 py-8 max-[900px]:px-5">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -428,9 +430,9 @@ function DetailPane({
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabelFor(state, installed, directory.installedReady)}
|
||||
</Button>
|
||||
{entry.homepage ? (
|
||||
{learnMoreUrl ? (
|
||||
<Button
|
||||
onClick={() => void openExternalUrl(entry.homepage as string)}
|
||||
onClick={() => void openExternalUrl(learnMoreUrl)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -440,18 +442,6 @@ function DetailPane({
|
||||
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
{entry.repo ? (
|
||||
<Button
|
||||
onClick={() => void openExternalUrl(entry.repo as string)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Github className="size-4" />
|
||||
Repository
|
||||
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{message ? (
|
||||
<output
|
||||
@@ -466,9 +456,19 @@ function DetailPane({
|
||||
</output>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close details"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 rounded-xl border bg-card p-4 max-[720px]:grid-cols-2">
|
||||
<div className="grid grid-cols-2 gap-4 rounded-xl border bg-card p-4">
|
||||
{entry.author ? (
|
||||
<MetaCell
|
||||
icon={User}
|
||||
@@ -481,11 +481,6 @@ function DetailPane({
|
||||
value={entry.author.name}
|
||||
/>
|
||||
) : null}
|
||||
<MetaCell
|
||||
icon={Scale}
|
||||
label="License"
|
||||
value={entry.license ?? "Not specified"}
|
||||
/>
|
||||
<MetaCell icon={meta.icon} label="Type" value={meta.plural} />
|
||||
</div>
|
||||
|
||||
@@ -648,12 +643,15 @@ export function MarketplaceExplorerView() {
|
||||
[filteredEntries],
|
||||
);
|
||||
|
||||
const selectedEntry = useMemo(() => {
|
||||
const flat = groups.flatMap((group) => group.entries);
|
||||
return (
|
||||
flat.find((entry) => entryKey(entry) === selectedKey) ?? flat[0] ?? null
|
||||
);
|
||||
}, [groups, selectedKey]);
|
||||
// Resolved against the full catalog so an open panel stays open while the
|
||||
// list is filtered, rather than closing and reopening as filters change.
|
||||
const selectedEntry = useMemo(
|
||||
() =>
|
||||
directory.catalog?.entries.find(
|
||||
(entry) => entryKey(entry) === selectedKey,
|
||||
) ?? null,
|
||||
[directory.catalog?.entries, selectedKey],
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts = new Map<MarketplacePrimitiveType, number>();
|
||||
@@ -684,7 +682,14 @@ export function MarketplaceExplorerView() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0">
|
||||
<aside className="flex w-85 shrink-0 flex-col border-r max-[900px]:w-72">
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
selectedEntry
|
||||
? "w-85 shrink-0 border-r max-[900px]:w-72"
|
||||
: "min-w-0 flex-1",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-2.5 border-b p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -814,13 +819,10 @@ export function MarketplaceExplorerView() {
|
||||
<DetailPane
|
||||
directory={directory}
|
||||
entry={selectedEntry}
|
||||
onClose={() => setSelectedKey(null)}
|
||||
onSelectTag={setSelectedTag}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Select an entry to see details.
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Import,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ClineLogo } from "@/components/cline-logo";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -24,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 {
|
||||
@@ -39,13 +42,24 @@ import {
|
||||
invalidateProviderCatalogCache,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import {
|
||||
type ListImportableSessionsResponse,
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
SESSION_IMPORT_TOOL_ORDER,
|
||||
type SessionImportTool,
|
||||
} from "@/lib/session-import";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
export const GITHUB_ONBOARDING_FEATURE_FLAG = "code-onboarding-github";
|
||||
|
||||
export type OnboardingStep = "welcome" | "connect" | "github" | "done";
|
||||
export type OnboardingStep =
|
||||
| "welcome"
|
||||
| "connect"
|
||||
| "github"
|
||||
| "import"
|
||||
| "done";
|
||||
|
||||
type OnboardingConnection =
|
||||
| { kind: "cline" }
|
||||
@@ -309,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);
|
||||
@@ -606,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"
|
||||
@@ -820,6 +843,155 @@ function ConnectStep({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers to bring session history over from other coding tools. Scans once
|
||||
* on entry and silently advances when there is nothing to import, so only
|
||||
* people who actually have Claude Code / Codex / opencode history ever see
|
||||
* this step. After a successful import, onFinish closes onboarding directly
|
||||
* — a second "you're all set" screen right after the import confirmation
|
||||
* reads as a loop, not a finish.
|
||||
*/
|
||||
function ImportHistoryStep({
|
||||
onContinue,
|
||||
onFinish,
|
||||
}: {
|
||||
onContinue: () => void;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const [found, setFound] = useState<{
|
||||
count: number;
|
||||
tools: SessionImportTool[];
|
||||
} | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [imported, setImported] = useState(false);
|
||||
|
||||
// The parent recreates onContinue every render, and importing itself
|
||||
// re-renders the app shell (history refresh). Keep the callback in a ref
|
||||
// so the scan effect runs exactly once per step entry: a re-scan after
|
||||
// importing would see zero remaining sessions and auto-advance out from
|
||||
// under the user's own import confirmation.
|
||||
const skipRef = useRef(onContinue);
|
||||
useEffect(() => {
|
||||
skipRef.current = onContinue;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<ListImportableSessionsResponse>(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
if (cancelled) return;
|
||||
const sessions = (response.sessions ?? []).filter(
|
||||
(session) => !session.alreadyImportedSessionId,
|
||||
);
|
||||
if (sessions.length === 0) {
|
||||
skipRef.current();
|
||||
return;
|
||||
}
|
||||
const tools = SESSION_IMPORT_TOOL_ORDER.filter((tool) =>
|
||||
sessions.some((session) => session.tool === tool),
|
||||
);
|
||||
setFound({ count: sessions.length, tools });
|
||||
} catch {
|
||||
// Onboarding must never dead-end on a scan failure.
|
||||
if (!cancelled) skipRef.current();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!found) {
|
||||
return (
|
||||
<OnboardingContent surface="transparent">
|
||||
<div className="flex flex-col items-center py-10 text-center">
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<p className="mt-4 text-md text-muted-foreground">
|
||||
Checking for session history from other tools…
|
||||
</p>
|
||||
<Button
|
||||
className="mt-8"
|
||||
onClick={onContinue}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingContent>
|
||||
);
|
||||
}
|
||||
|
||||
const toolList = found.tools
|
||||
.map((tool) => SESSION_IMPORT_TOOL_LABELS[tool])
|
||||
.join(found.tools.length === 2 ? " and " : ", ");
|
||||
|
||||
return (
|
||||
<OnboardingContent surface="transparent">
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<Import aria-hidden="true" className="size-10 text-primary" />
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-foreground">
|
||||
Bring your history with you
|
||||
</h1>
|
||||
<p className="mt-3 text-md text-muted-foreground">
|
||||
{imported
|
||||
? "Your sessions are in Cline's history now. You can import more anytime from the Sessions page."
|
||||
: `Cline found ${found.count} session${found.count === 1 ? "" : "s"} from ${toolList} on this machine. Import them to keep your past conversations — and continue them here.`}
|
||||
</p>
|
||||
{imported ? (
|
||||
<Button
|
||||
className="mt-8 w-full max-w-64"
|
||||
onClick={onFinish}
|
||||
size="lg"
|
||||
tone="accent"
|
||||
type="button"
|
||||
variant="fill"
|
||||
>
|
||||
Start building
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="mt-8 w-full max-w-64"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
size="lg"
|
||||
tone="accent"
|
||||
type="button"
|
||||
variant="fill"
|
||||
>
|
||||
Choose sessions to import
|
||||
</Button>
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={onContinue}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ImportSessionsDialog
|
||||
onImported={() => setImported(true)}
|
||||
onOpenChange={setDialogOpen}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingContent>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep({
|
||||
connection,
|
||||
onFinish,
|
||||
@@ -900,15 +1072,20 @@ export function OnboardingView({
|
||||
setStep(
|
||||
nextConnection.kind === "cline" && githubStepEnabled
|
||||
? "github"
|
||||
: "done",
|
||||
: "import",
|
||||
);
|
||||
}}
|
||||
onSkip={onComplete}
|
||||
/>
|
||||
) : step === "github" ? (
|
||||
<OnboardingContent surface="panel">
|
||||
<GitHubConnectStep onContinue={() => setStep("done")} />
|
||||
<GitHubConnectStep onContinue={() => setStep("import")} />
|
||||
</OnboardingContent>
|
||||
) : step === "import" ? (
|
||||
<ImportHistoryStep
|
||||
onContinue={() => setStep("done")}
|
||||
onFinish={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<DoneStep connection={connection} onFinish={onComplete} />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
Import,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -155,6 +157,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
|
||||
null,
|
||||
);
|
||||
@@ -333,6 +336,18 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Import sessions from other tools"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
size="sm"
|
||||
title="Import sessions from Claude Code, Codex, or opencode"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Import className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -783,6 +798,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ImportSessionsDialog
|
||||
onImported={() => void history.refreshSessions()}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
open={importDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,8 @@ type SkillItem = {
|
||||
description?: string;
|
||||
instructions: string;
|
||||
path: string;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type CommandItem = {
|
||||
@@ -94,6 +96,8 @@ type CommandItem = {
|
||||
instructions: string;
|
||||
path: string;
|
||||
scope: ItemScope;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type ItemScope = "Global" | "Project";
|
||||
@@ -104,9 +108,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;
|
||||
};
|
||||
|
||||
@@ -774,6 +784,8 @@ export function CustomizationSectionView({
|
||||
instructions: skill.instructions,
|
||||
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 +837,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 {
|
||||
@@ -872,6 +886,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(
|
||||
() => [
|
||||
@@ -976,15 +998,17 @@ export function CustomizationSectionView({
|
||||
key={key}
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
{item.agentPlugin !== true ? (
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
{item.type === "workflow" ? (
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
@@ -998,6 +1022,11 @@ export function CustomizationSectionView({
|
||||
<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
|
||||
@@ -1057,6 +1086,9 @@ 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
|
||||
@@ -1071,18 +1103,33 @@ export function CustomizationSectionView({
|
||||
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
|
||||
? renderPluginMenu({
|
||||
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.
|
||||
@@ -1520,68 +1567,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 +1587,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { providerOffersModelTool } from "@cline/llms/browser";
|
||||
import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { Import, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -665,6 +666,7 @@ function GeneralSettingsContent({
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [accent, setAccent] = useState<HubAccent>(() => {
|
||||
if (typeof window === "undefined") return "violet";
|
||||
return readStoredHubAccent();
|
||||
@@ -1124,6 +1126,31 @@ function GeneralSettingsContent({
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Import sessions
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bring your conversation history from Claude Code, Codex, or
|
||||
opencode into Cline.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Import className="size-3" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
<ImportSessionsDialog
|
||||
onOpenChange={setImportDialogOpen}
|
||||
open={importDialogOpen}
|
||||
/>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
/**
|
||||
* Device sign-in confirmation code pushed by the sidecar while a provider
|
||||
* OAuth login is pending, so the user can match it against the code shown in
|
||||
* their browser. Cleared whenever the pending flow ends.
|
||||
*/
|
||||
export function useOAuthUserCode(pending: boolean): string | null {
|
||||
const [userCode, setUserCode] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
setUserCode(null);
|
||||
return;
|
||||
}
|
||||
return desktopClient.subscribe("provider_oauth_user_code", (payload) => {
|
||||
const code = (payload as { userCode?: unknown } | null)?.userCode;
|
||||
if (typeof code === "string" && code) {
|
||||
setUserCode(code);
|
||||
}
|
||||
});
|
||||
}, [pending]);
|
||||
return userCode;
|
||||
}
|
||||
@@ -132,8 +132,12 @@ describe("useSessionHistory session mapping", () => {
|
||||
}
|
||||
if (command === "list_routine_schedules") {
|
||||
return {
|
||||
schedules: [{ scheduleId: "sched_daily", name: "Daily report" }],
|
||||
activeExecutions: [{ sessionId: "cron-active" }],
|
||||
lastExecutions: [{ sessionId: "cron-session" }, {}],
|
||||
lastExecutions: [
|
||||
{ sessionId: "cron-session", scheduleId: "sched_daily" },
|
||||
{},
|
||||
],
|
||||
};
|
||||
}
|
||||
return [];
|
||||
@@ -161,13 +165,56 @@ describe("useSessionHistory session mapping", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The executions list also supplies the schedule identity the session
|
||||
// record itself lacks, so the sidebar can group it with its siblings.
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "cron-session"),
|
||||
).toMatchObject({ isScheduled: true });
|
||||
).toMatchObject({
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily report",
|
||||
});
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "regular-session"),
|
||||
).toMatchObject({ isScheduled: false });
|
||||
});
|
||||
|
||||
it("maps the runner's schedule provenance onto sidebar threads", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([
|
||||
{
|
||||
...sessionRow("run-session"),
|
||||
source: "core",
|
||||
metadata: {
|
||||
sessionHistoryOrigin: {
|
||||
mode: "automation",
|
||||
trigger: "hub-schedule",
|
||||
},
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily report",
|
||||
scheduleExecutionId: "crun_1",
|
||||
scheduleRunNumber: 4,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "run-session"),
|
||||
).toMatchObject({
|
||||
isScheduled: true,
|
||||
startedAt: "2026-07-20T10:00:00.000Z",
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily report",
|
||||
scheduleRunNumber: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory initial load", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getSessionMetadataGitBranch,
|
||||
getSessionMetadataIsScheduled,
|
||||
getSessionMetadataPinned,
|
||||
getSessionMetadataSchedule,
|
||||
getSessionMetadataTitle,
|
||||
getSessionSource,
|
||||
PINNED_METADATA_KEY,
|
||||
@@ -39,8 +40,20 @@ export interface SessionThread {
|
||||
status: SessionHistoryStatus;
|
||||
pinned?: boolean;
|
||||
isScheduled: boolean;
|
||||
/** Raw start timestamp; the sidebar labels un-numbered scheduled runs with it. */
|
||||
startedAt?: string;
|
||||
/** Schedule provenance for scheduled runs (metadata or executions list). */
|
||||
scheduleId?: string;
|
||||
scheduleName?: string;
|
||||
scheduleRunNumber?: number;
|
||||
}
|
||||
|
||||
/** What the schedule executions list knows about a session it started. */
|
||||
type ScheduledSessionLink = {
|
||||
scheduleId?: string;
|
||||
scheduleName?: string;
|
||||
};
|
||||
|
||||
type SessionHookEvent = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
@@ -251,6 +264,7 @@ function inferStatusFromMessages(
|
||||
|
||||
function toThread(session: SessionHistoryItem): SessionThread {
|
||||
const workspacePath = (session.workspaceRoot || session.cwd).trim();
|
||||
const schedule = getSessionMetadataSchedule(session.metadata);
|
||||
return {
|
||||
id: session.sessionId,
|
||||
title: toTitle(session),
|
||||
@@ -264,6 +278,10 @@ function toThread(session: SessionHistoryItem): SessionThread {
|
||||
status: normalizeDiscoveredStatus(session.status, session.prompt),
|
||||
pinned: getSessionMetadataPinned(session.metadata),
|
||||
isScheduled: getSessionMetadataIsScheduled(session.metadata),
|
||||
startedAt: session.startedAt?.trim() || undefined,
|
||||
scheduleId: schedule.scheduleId,
|
||||
scheduleName: schedule.scheduleName,
|
||||
scheduleRunNumber: schedule.runNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,6 +362,17 @@ function summarizeUsageFromMessages(messages: SessionMessage[]): {
|
||||
return { inputTokens, outputTokens, totalCostUsd };
|
||||
}
|
||||
|
||||
function areScheduleInfosEqual(
|
||||
a: ReturnType<typeof getSessionMetadataSchedule>,
|
||||
b: ReturnType<typeof getSessionMetadataSchedule>,
|
||||
): boolean {
|
||||
return (
|
||||
a.scheduleId === b.scheduleId &&
|
||||
a.scheduleName === b.scheduleName &&
|
||||
a.runNumber === b.runNumber
|
||||
);
|
||||
}
|
||||
|
||||
function areSessionsEquivalent(
|
||||
current: SessionHistoryItem[],
|
||||
next: SessionHistoryItem[],
|
||||
@@ -369,6 +398,10 @@ function areSessionsEquivalent(
|
||||
getSessionMetadataTitle(b.metadata) ||
|
||||
getSessionMetadataPinned(a.metadata) !==
|
||||
getSessionMetadataPinned(b.metadata) ||
|
||||
!areScheduleInfosEqual(
|
||||
getSessionMetadataSchedule(a.metadata),
|
||||
getSessionMetadataSchedule(b.metadata),
|
||||
) ||
|
||||
a.workspaceRoot !== b.workspaceRoot ||
|
||||
a.cwd !== b.cwd ||
|
||||
a.provider !== b.provider ||
|
||||
@@ -405,7 +438,11 @@ function areThreadsEquivalent(
|
||||
a.totalCostUsd !== b.totalCostUsd ||
|
||||
a.status !== b.status ||
|
||||
a.pinned !== b.pinned ||
|
||||
a.isScheduled !== b.isScheduled
|
||||
a.isScheduled !== b.isScheduled ||
|
||||
a.startedAt !== b.startedAt ||
|
||||
a.scheduleId !== b.scheduleId ||
|
||||
a.scheduleName !== b.scheduleName ||
|
||||
a.scheduleRunNumber !== b.scheduleRunNumber
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -504,14 +541,16 @@ export function useSessionHistory({
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Session ids that schedule executions report as their own. Scheduled runs
|
||||
// executed by the local hub do not reliably carry the "hub-schedule"
|
||||
// origin trigger in their session metadata (the runtime that claims the
|
||||
// run doesn't always stamp provenance), so the metadata check alone would
|
||||
// miss them; the executions list is the authoritative link.
|
||||
const [scheduledSessionIds, setScheduledSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Sessions that schedule executions report as their own, keyed by session
|
||||
// id. Scheduled runs executed by the local hub do not reliably carry the
|
||||
// "hub-schedule" origin trigger in their session metadata (the runtime
|
||||
// that claims the run doesn't always stamp provenance), so the metadata
|
||||
// check alone would miss them; the executions list is the authoritative
|
||||
// link. It also supplies the schedule id/name for sessions recorded
|
||||
// before the runner stamped those into metadata.
|
||||
const [scheduledSessionLinks, setScheduledSessionLinks] = useState<
|
||||
Map<string, ScheduledSessionLink>
|
||||
>(() => new Map());
|
||||
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
|
||||
// Limit of the most recent refresh that actually returned sessions. Failed
|
||||
// attempts roll back to this rather than to a caller-local snapshot, which
|
||||
@@ -563,17 +602,36 @@ export function useSessionHistory({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const collectScheduledSessionIds = async () => {
|
||||
const collectScheduledSessionLinks = async () => {
|
||||
const response = await desktopClient
|
||||
.invoke<{
|
||||
activeExecutions?: Array<{ sessionId?: unknown }>;
|
||||
lastExecutions?: Array<{ sessionId?: unknown }>;
|
||||
schedules?: Array<{ scheduleId?: unknown; name?: unknown }>;
|
||||
activeExecutions?: Array<{
|
||||
sessionId?: unknown;
|
||||
scheduleId?: unknown;
|
||||
}>;
|
||||
lastExecutions?: Array<{
|
||||
sessionId?: unknown;
|
||||
scheduleId?: unknown;
|
||||
}>;
|
||||
}>("list_routine_schedules")
|
||||
.catch(() => null);
|
||||
if (cancelled || !response) {
|
||||
return;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const scheduleNames = new Map<string, string>();
|
||||
for (const schedule of response.schedules ?? []) {
|
||||
const scheduleId =
|
||||
typeof schedule?.scheduleId === "string"
|
||||
? schedule.scheduleId.trim()
|
||||
: "";
|
||||
const name =
|
||||
typeof schedule?.name === "string" ? schedule.name.trim() : "";
|
||||
if (scheduleId && name) {
|
||||
scheduleNames.set(scheduleId, name);
|
||||
}
|
||||
}
|
||||
const links = new Map<string, ScheduledSessionLink>();
|
||||
for (const execution of [
|
||||
...(response.activeExecutions ?? []),
|
||||
...(response.lastExecutions ?? []),
|
||||
@@ -582,23 +640,43 @@ export function useSessionHistory({
|
||||
typeof execution?.sessionId === "string"
|
||||
? execution.sessionId.trim()
|
||||
: "";
|
||||
if (sessionId) {
|
||||
ids.add(sessionId);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
const scheduleId =
|
||||
typeof execution?.scheduleId === "string"
|
||||
? execution.scheduleId.trim()
|
||||
: "";
|
||||
links.set(sessionId, {
|
||||
...(scheduleId ? { scheduleId } : {}),
|
||||
...(scheduleId && scheduleNames.has(scheduleId)
|
||||
? { scheduleName: scheduleNames.get(scheduleId) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
setScheduledSessionIds((current) => {
|
||||
setScheduledSessionLinks((current) => {
|
||||
// Merge instead of replace: the executions list is a rolling
|
||||
// window, so ids that fell out of it are still scheduled runs.
|
||||
const next = new Set(current);
|
||||
for (const id of ids) {
|
||||
next.add(id);
|
||||
let changed = false;
|
||||
const next = new Map(current);
|
||||
for (const [sessionId, link] of links) {
|
||||
const existing = next.get(sessionId);
|
||||
if (
|
||||
existing &&
|
||||
existing.scheduleId === link.scheduleId &&
|
||||
existing.scheduleName === link.scheduleName
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
next.set(sessionId, link);
|
||||
changed = true;
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
return changed ? next : current;
|
||||
});
|
||||
};
|
||||
void collectScheduledSessionIds();
|
||||
void collectScheduledSessionLinks();
|
||||
const interval = window.setInterval(
|
||||
() => void collectScheduledSessionIds(),
|
||||
() => void collectScheduledSessionLinks(),
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
return () => {
|
||||
@@ -1041,6 +1119,20 @@ export function useSessionHistory({
|
||||
}
|
||||
},
|
||||
);
|
||||
const unsubscribeTransportImport = desktopClient.subscribe(
|
||||
"session_import_progress",
|
||||
(payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
// Imported sessions land directly in the store; refresh so they
|
||||
// appear in history no matter where the import was started from.
|
||||
const result = (payload as { result?: { ok?: boolean } }).result;
|
||||
if (result?.ok) {
|
||||
scheduleRefresh(HISTORY_EVENT_REFRESH_DELAY_MS, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
const unsubscribeTransportChatEvent = desktopClient.subscribe(
|
||||
"chat_event",
|
||||
(payload) => {
|
||||
@@ -1079,6 +1171,7 @@ export function useSessionHistory({
|
||||
unsubscribeTransportDelete();
|
||||
unsubscribeTransportStatus();
|
||||
unsubscribeTransportEnded();
|
||||
unsubscribeTransportImport();
|
||||
unsubscribeTransportChatEvent();
|
||||
};
|
||||
}, [activeSessionId, scheduleRefresh]);
|
||||
@@ -1484,15 +1577,33 @@ export function useSessionHistory({
|
||||
);
|
||||
|
||||
const threadsWithScheduled = useMemo(() => {
|
||||
if (scheduledSessionIds.size === 0) {
|
||||
if (scheduledSessionLinks.size === 0) {
|
||||
return threads;
|
||||
}
|
||||
return threads.map((thread) =>
|
||||
!thread.isScheduled && scheduledSessionIds.has(thread.id)
|
||||
? { ...thread, isScheduled: true }
|
||||
: thread,
|
||||
);
|
||||
}, [scheduledSessionIds, threads]);
|
||||
return threads.map((thread) => {
|
||||
const link = scheduledSessionLinks.get(thread.id);
|
||||
if (!link) {
|
||||
return thread;
|
||||
}
|
||||
// Metadata stamped by the runner wins; the executions list only
|
||||
// fills in what the session record itself doesn't carry.
|
||||
const scheduleId = thread.scheduleId ?? link.scheduleId;
|
||||
const scheduleName = thread.scheduleName ?? link.scheduleName;
|
||||
if (
|
||||
thread.isScheduled &&
|
||||
scheduleId === thread.scheduleId &&
|
||||
scheduleName === thread.scheduleName
|
||||
) {
|
||||
return thread;
|
||||
}
|
||||
return {
|
||||
...thread,
|
||||
isScheduled: true,
|
||||
...(scheduleId ? { scheduleId } : {}),
|
||||
...(scheduleName ? { scheduleName } : {}),
|
||||
};
|
||||
});
|
||||
}, [scheduledSessionLinks, threads]);
|
||||
|
||||
return {
|
||||
getSessionByThreadId,
|
||||
|
||||
@@ -238,6 +238,8 @@ const NATIVE_COMMANDS = new Set([
|
||||
"show_session_notification",
|
||||
"drain_desktop_actions",
|
||||
"set_tray_status",
|
||||
"relaunch_app",
|
||||
"quit_app",
|
||||
]);
|
||||
|
||||
class DesktopClient {
|
||||
|
||||
@@ -21,9 +21,24 @@ export type SessionMetadata = {
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
};
|
||||
/**
|
||||
* Provenance the cron runner stamps onto sessions it starts (see
|
||||
* `buildRunSessionMetadata` in @cline/core). The sidebar groups a
|
||||
* schedule's runs by `scheduleId` and labels each with `scheduleRunNumber`.
|
||||
*/
|
||||
scheduleId?: string;
|
||||
scheduleName?: string;
|
||||
scheduleExecutionId?: string;
|
||||
scheduleRunNumber?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface SessionScheduleInfo {
|
||||
scheduleId?: string;
|
||||
scheduleName?: string;
|
||||
runNumber?: number;
|
||||
}
|
||||
|
||||
export const PINNED_METADATA_KEY = "pinned";
|
||||
|
||||
export interface SessionHistoryItem {
|
||||
@@ -121,6 +136,27 @@ export function getSessionMetadataIsScheduled(
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionMetadataSchedule(
|
||||
metadata?: SessionMetadata,
|
||||
): SessionScheduleInfo {
|
||||
const scheduleId =
|
||||
typeof metadata?.scheduleId === "string" ? metadata.scheduleId.trim() : "";
|
||||
const scheduleName =
|
||||
typeof metadata?.scheduleName === "string"
|
||||
? metadata.scheduleName.trim()
|
||||
: "";
|
||||
const runNumber = metadata?.scheduleRunNumber;
|
||||
return {
|
||||
...(scheduleId ? { scheduleId } : {}),
|
||||
...(scheduleName ? { scheduleName } : {}),
|
||||
...(typeof runNumber === "number" &&
|
||||
Number.isInteger(runNumber) &&
|
||||
runNumber > 0
|
||||
? { runNumber }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSessionMetadataGitBranch(
|
||||
metadata?: SessionMetadata,
|
||||
): string {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Wire types for the sidecar's session-import commands
|
||||
* (list_importable_sessions / import_sessions). Mirrors
|
||||
* sdk/packages/core/src/services/session-import/types.ts — kept as a local
|
||||
* copy so the client bundle never imports node-only core code.
|
||||
*/
|
||||
|
||||
export type SessionImportTool = "claude-code" | "codex" | "opencode";
|
||||
|
||||
export const SESSION_IMPORT_TOOL_ORDER: SessionImportTool[] = [
|
||||
"claude-code",
|
||||
"codex",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
export const SESSION_IMPORT_TOOL_LABELS: Record<SessionImportTool, string> = {
|
||||
"claude-code": "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "opencode",
|
||||
};
|
||||
|
||||
export interface ImportableSession {
|
||||
tool: SessionImportTool;
|
||||
sourceId: string;
|
||||
sourcePath: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
startedAtMs: number;
|
||||
updatedAtMs: number;
|
||||
messageCount: number;
|
||||
preview?: string;
|
||||
alreadyImportedSessionId?: string;
|
||||
}
|
||||
|
||||
export interface ListImportableSessionsResponse {
|
||||
installedTools: SessionImportTool[];
|
||||
sessions: ImportableSession[];
|
||||
}
|
||||
|
||||
export interface SessionImportResult {
|
||||
tool: SessionImportTool;
|
||||
sourceId: string;
|
||||
ok: boolean;
|
||||
sessionId?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
/** The source was already imported; sessionId is the existing session. */
|
||||
alreadyImported?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionImportProgressEvent {
|
||||
index: number;
|
||||
total: number;
|
||||
result: SessionImportResult;
|
||||
}
|
||||
|
||||
export function importSelectionKey(tool: string, sourceId: string): string {
|
||||
return `${tool}:${sourceId}`;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import {
|
||||
groupScheduledThreads,
|
||||
groupThreadsByProject,
|
||||
scheduleRunLabel,
|
||||
workspaceDisplayName,
|
||||
} from "./sidebar-session-organization";
|
||||
|
||||
@@ -57,4 +59,96 @@ describe("sidebar session organization", () => {
|
||||
"Chat",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds scheduled runs into one group per schedule at the newest run's position", () => {
|
||||
const scheduled = (id: string, runNumber: number) =>
|
||||
thread(id, "/work/acme/repo", {
|
||||
title: "Report today's date",
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily date report",
|
||||
scheduleRunNumber: runNumber,
|
||||
});
|
||||
const rows = groupScheduledThreads([
|
||||
thread("task-1", "/work/acme/repo"),
|
||||
scheduled("run-3", 3),
|
||||
thread("task-2", "/work/acme/repo"),
|
||||
scheduled("run-2", 2),
|
||||
thread("other-1", "/work/acme/repo", {
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_other",
|
||||
scheduleName: "Other schedule",
|
||||
}),
|
||||
scheduled("run-1", 1),
|
||||
]);
|
||||
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
row.kind === "thread"
|
||||
? row.thread.id
|
||||
: `${row.label}[${row.threads.map((t) => t.id).join(",")}]`,
|
||||
),
|
||||
).toEqual([
|
||||
"task-1",
|
||||
"Daily date report[run-3,run-2,run-1]",
|
||||
"task-2",
|
||||
"Other schedule[other-1]",
|
||||
]);
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "schedule",
|
||||
id: "schedule:sched_daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("groups un-stamped scheduled runs by their shared title", () => {
|
||||
const legacy = (id: string) =>
|
||||
thread(id, "/work/acme/repo", {
|
||||
title: "Report today's date to the user.",
|
||||
isScheduled: true,
|
||||
});
|
||||
const rows = groupScheduledThreads([
|
||||
legacy("legacy-2"),
|
||||
thread("named-1", "/work/acme/repo", {
|
||||
title: "Report today's date to the user.",
|
||||
isScheduled: true,
|
||||
scheduleId: "sched_daily",
|
||||
scheduleName: "Daily date report",
|
||||
}),
|
||||
legacy("legacy-1"),
|
||||
]);
|
||||
|
||||
// Runs with a schedule id never merge into the title-keyed group.
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
row.kind === "schedule"
|
||||
? `${row.id}:${row.threads.length}`
|
||||
: row.thread.id,
|
||||
),
|
||||
).toEqual([
|
||||
"title:report today's date to the user.:2",
|
||||
"schedule:sched_daily:1",
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
label: "Report today's date to the user.",
|
||||
});
|
||||
});
|
||||
|
||||
it("labels runs by number and falls back to the start time", () => {
|
||||
expect(
|
||||
scheduleRunLabel(
|
||||
thread("a", "/ws", { isScheduled: true, scheduleRunNumber: 7 }),
|
||||
),
|
||||
).toBe("Run 7");
|
||||
const dated = scheduleRunLabel(
|
||||
thread("b", "/ws", {
|
||||
isScheduled: true,
|
||||
startedAt: "2026-08-31T19:31:40.834Z",
|
||||
}),
|
||||
);
|
||||
expect(dated).toMatch(/Aug 31/);
|
||||
expect(dated).not.toBe("Run");
|
||||
expect(scheduleRunLabel(thread("c", "/ws", { isScheduled: true }))).toBe(
|
||||
"Run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
parseTimestamp,
|
||||
type SessionThread,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
// One page of sidebar rows. Large enough to fill the sidebar on a tall
|
||||
@@ -70,3 +74,87 @@ export function groupThreadsByProject(
|
||||
threads: group.threads,
|
||||
}));
|
||||
}
|
||||
|
||||
export type SidebarScheduleGroup = {
|
||||
kind: "schedule";
|
||||
/** Stable key: the schedule id when known, otherwise the shared title. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Runs in the order they were given (newest first in the sidebar). */
|
||||
threads: SessionThread[];
|
||||
};
|
||||
|
||||
export type SidebarListRow =
|
||||
| { kind: "thread"; thread: SessionThread }
|
||||
| SidebarScheduleGroup;
|
||||
|
||||
/**
|
||||
* Key that decides which schedule group a thread joins. Runs stamped with a
|
||||
* schedule id (or linked to one through the executions list) group by that
|
||||
* id; older runs without one group by their shared title, since a schedule's
|
||||
* sessions all start from the same prompt. Non-scheduled threads return null.
|
||||
*/
|
||||
export function scheduleGroupKey(thread: SessionThread): string | null {
|
||||
if (!thread.isScheduled) return null;
|
||||
const scheduleId = thread.scheduleId?.trim();
|
||||
if (scheduleId) return `schedule:${scheduleId}`;
|
||||
const title = normalizeTitle(thread.title).trim().toLowerCase();
|
||||
return title ? `title:${title}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds every scheduled thread into one row per schedule, keeping each group
|
||||
* at the position of its first (newest) run so the list still reads in
|
||||
* recency order. Non-scheduled threads pass through as plain rows.
|
||||
*/
|
||||
export function groupScheduledThreads(
|
||||
threads: readonly SessionThread[],
|
||||
): SidebarListRow[] {
|
||||
const rows: SidebarListRow[] = [];
|
||||
const groups = new Map<string, SidebarScheduleGroup>();
|
||||
for (const thread of threads) {
|
||||
const key = scheduleGroupKey(thread);
|
||||
if (!key) {
|
||||
rows.push({ kind: "thread", thread });
|
||||
continue;
|
||||
}
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.threads.push(thread);
|
||||
if (!existing.label) existing.label = scheduleGroupLabel(thread);
|
||||
continue;
|
||||
}
|
||||
const group: SidebarScheduleGroup = {
|
||||
kind: "schedule",
|
||||
id: key,
|
||||
label: scheduleGroupLabel(thread),
|
||||
threads: [thread],
|
||||
};
|
||||
groups.set(key, group);
|
||||
rows.push(group);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function scheduleGroupLabel(thread: SessionThread): string {
|
||||
return thread.scheduleName?.trim() || normalizeTitle(thread.title).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for one run inside a schedule group. Runs the runner numbered show
|
||||
* "Run N"; older runs fall back to when they started, which is the next
|
||||
* best way to tell them apart.
|
||||
*/
|
||||
export function scheduleRunLabel(thread: SessionThread): string {
|
||||
if (thread.scheduleRunNumber) return `Run ${thread.scheduleRunNumber}`;
|
||||
const started = parseTimestamp(thread.startedAt);
|
||||
if (Number.isFinite(started)) {
|
||||
return new Date(started).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return "Run";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "4.1.16",
|
||||
"version": "4.1.17",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -126,6 +126,8 @@ async function generateVscodeProtobusServers(protobusServices) {
|
||||
const imports = [];
|
||||
const servers = [];
|
||||
const serviceMap = [];
|
||||
const decoders = [];
|
||||
const decoderMap = [];
|
||||
const streamingMethods = [];
|
||||
for (const [serviceName, def] of Object.entries(protobusServices)) {
|
||||
const domain = getDomainName(serviceName);
|
||||
@@ -134,23 +136,34 @@ async function generateVscodeProtobusServers(protobusServices) {
|
||||
servers.push(
|
||||
`const ${serviceName}Handlers: serviceTypes.${serviceName}Handlers = {`,
|
||||
);
|
||||
decoders.push(
|
||||
`const ${serviceName}RequestDecoders: Record<string, (json: unknown) => unknown> = {`,
|
||||
);
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
imports.push(
|
||||
`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`,
|
||||
);
|
||||
servers.push(` ${rpcName}: ${rpcName},`);
|
||||
decoders.push(
|
||||
` ${rpcName}: ${getFqn(rpc.requestType.type.name)}.fromJSON,`,
|
||||
);
|
||||
if (rpc.responseStream) {
|
||||
streamingMethods.push(` "cline.${serviceName}.${rpcName}",`);
|
||||
}
|
||||
}
|
||||
servers.push(`} \n`);
|
||||
decoders.push(`} \n`);
|
||||
serviceMap.push(` "cline.${serviceName}": ${serviceName}Handlers,`);
|
||||
decoderMap.push(
|
||||
` "cline.${serviceName}": ${serviceName}RequestDecoders,`,
|
||||
);
|
||||
imports.push("");
|
||||
}
|
||||
|
||||
// Create output file
|
||||
const output = `// GENERATED CODE -- DO NOT EDIT!
|
||||
// Generated by ${SCRIPT_NAME}
|
||||
import * as proto from "@shared/proto/index"
|
||||
import * as serviceTypes from "@generated/hosts/vscode/protobus-service-types"
|
||||
|
||||
${imports.join("\n")}
|
||||
@@ -159,6 +172,18 @@ export const serviceHandlers: Record<string, any> = {
|
||||
${serviceMap.join("\n")}
|
||||
}
|
||||
|
||||
${decoders.join("\n")}
|
||||
/**
|
||||
* Per-method request decoders (proto3 JSON -> ts-proto message) for hosts whose
|
||||
* transport delivers protobus requests as JSON. Proto3 JSON spells enums as
|
||||
* string names and omits default-valued fields (empty repeated fields included),
|
||||
* while the handlers assume ts-proto message shapes: numeric enums, repeated
|
||||
* fields always present. fromJSON restores those invariants.
|
||||
*/
|
||||
export const serviceRequestDecoders: Record<string, Record<string, (json: unknown) => unknown>> = {
|
||||
${decoderMap.join("\n")}
|
||||
}
|
||||
|
||||
/** Fully-qualified response-streaming methods, derived from the proto descriptors. */
|
||||
export const responseStreamingMethods: ReadonlySet<string> = new Set([
|
||||
${streamingMethods.join("\n")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { TOOL_REJECTION_SUFFIX, USER_REJECTED_TOOL_REASON } from "@cline/shared"
|
||||
import * as diff from "diff"
|
||||
import * as path from "path"
|
||||
import { Mode } from "@/shared/storage/types"
|
||||
@@ -20,7 +21,8 @@ export const formatResponse = {
|
||||
condense: () =>
|
||||
`The user has accepted the condensed conversation summary you generated. This summary covers important details of the historical conversation with the user which has been truncated.\n<explicit_instructions type="condense_response">It's crucial that you respond by ONLY asking the user what you should work on next. You should NOT take any initiative or make any assumptions about continuing with work. For example you should NOT suggest file changes or attempt to read any files.\nWhen asking the user what you should work on next, you can reference information in the summary which was just generated. However, you should NOT reference information outside of what's contained in the summary for this response. Keep this response CONCISE.</explicit_instructions>`,
|
||||
|
||||
toolDenied: () => `The user denied this operation.`,
|
||||
// Not routed through the agent runtime, so the guidance suffix is included here.
|
||||
toolDenied: () => `${USER_REJECTED_TOOL_REASON} -- ${TOOL_REJECTION_SUFFIX}`,
|
||||
|
||||
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { USER_REJECTED_TOOL_REASON } from "@cline/shared"
|
||||
import { isEditTool } from "./sdk-tool-policies"
|
||||
|
||||
export const DEFAULT_TOOL_APPROVAL_DENIAL_REASON = "User denied the tool execution"
|
||||
export const DEFAULT_TOOL_APPROVAL_DENIAL_REASON = USER_REJECTED_TOOL_REASON
|
||||
export const USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON = "Tool execution was cancelled because the user sent a follow-up message."
|
||||
export const EDIT_TOOL_APPROVAL_DENIAL_REASON =
|
||||
"The user denied this edit. The file was NOT modified and still contains its original content."
|
||||
@@ -48,7 +49,7 @@ export function isKnownToolApprovalDenial(value: unknown): boolean {
|
||||
|
||||
return (
|
||||
message.includes(USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON) ||
|
||||
message.includes(DEFAULT_TOOL_APPROVAL_DENIAL_REASON) ||
|
||||
message.includes(USER_REJECTED_TOOL_REASON) ||
|
||||
message.includes(EDIT_TOOL_APPROVAL_DENIAL_REASON)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,7 +238,12 @@ export class BannerService {
|
||||
StateManager.get().setGlobalState("dismissedBanners", [...dismissed, { bannerId, dismissedAt: Date.now() }])
|
||||
|
||||
await this.sendBannerEvent(bannerId, "dismiss")
|
||||
this.clearCache()
|
||||
// Only refetch when a cached remote banner was dismissed. Dismissing a
|
||||
// hardcoded/webview-local banner id (e.g. the ClinePass promos) should
|
||||
// not wipe the cached remote carousel banners.
|
||||
if (this.cachedBanners.some((banner) => banner.id === bannerId)) {
|
||||
this.clearCache()
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("[BannerService] Error dismissing banner", error)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user