mirror of
https://github.com/cline/cline.git
synced 2026-09-14 11:29:25 +08:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491b30b806 | ||
|
|
8b046d04f9 | ||
|
|
8a6c6f8afe | ||
|
|
4f5f238407 | ||
|
|
8f69880ac4 | ||
|
|
a0c341e93c | ||
|
|
f91af30401 | ||
|
|
83b2588c9c | ||
|
|
6e09e81a79 | ||
|
|
dc43a57fd7 | ||
|
|
833cc891b5 | ||
|
|
a2fb1d15ca | ||
|
|
8e7a55498b | ||
|
|
0cfc901589 | ||
|
|
a5c3181b78 | ||
|
|
8cb60caf0c | ||
|
|
c03e452315 | ||
|
|
83ff8fd2f5 | ||
|
|
397a6a3341 | ||
|
|
c9b75155ea | ||
|
|
09ee902639 | ||
|
|
2b7b01328a | ||
|
|
be8b984d10 | ||
|
|
c870116d1d | ||
|
|
4f836ae7d0 | ||
|
|
6cb653a362 | ||
|
|
5077fe8697 | ||
|
|
2266fe8cf4 | ||
|
|
21cb8d2525 | ||
|
|
68ad354b52 | ||
|
|
e098a8ed0d | ||
|
|
1de61b178a | ||
|
|
e7ed29109b | ||
|
|
9316de6bb5 | ||
|
|
fb58e340a2 | ||
|
|
db6d18a98a | ||
|
|
2ea460fa46 | ||
|
|
7d366ce7d4 |
@@ -15,6 +15,8 @@ body:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
- Desktop App
|
||||
- Cloud Platform
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -62,13 +64,15 @@ body:
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
label: Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
- Desktop App: paste the app version from the Settings view.
|
||||
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
|
||||
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -14,9 +14,12 @@ name: ext-vscode-publish-nightly
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
# Manual dispatch only. The nightly cron was removed deliberately: the
|
||||
# PublishNightly environment gained required reviewers, and an unattended
|
||||
# cron run would just sit `waiting` on that approval, hold this workflow's
|
||||
# concurrency group, and silently cancel every later scheduled run behind it
|
||||
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
|
||||
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
@@ -74,8 +77,9 @@ jobs:
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
# NOTE: the || fallback is retained so this stays correct if a
|
||||
# non-dispatch trigger is ever added back (inputs are empty strings
|
||||
# on e.g. `schedule` events, where the declared default does not apply).
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.15]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
|
||||
|
||||
## [4.1.14]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
|
||||
|
||||
## [4.1.13]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
|
||||
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
|
||||
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
|
||||
|
||||
## [4.1.12]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
|
||||
|
||||
## [4.1.11]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.58
|
||||
|
||||
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
|
||||
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
|
||||
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.57
|
||||
|
||||
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
|
||||
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
|
||||
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
|
||||
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
|
||||
## 3.0.56
|
||||
|
||||
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.56",
|
||||
"version": "3.0.58",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -3,16 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockLocalHubHasNoActiveSessions,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockRequestHubDrain,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockLocalHubHasNoActiveSessions: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockRequestHubDrain: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -27,8 +31,10 @@ const {
|
||||
vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
requestHubDrain: mockRequestHubDrain,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
@@ -95,6 +101,147 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand() {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
return {
|
||||
cmd,
|
||||
output,
|
||||
errors,
|
||||
exitCode: () => exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
it("sends an un-drain request with drain --off", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain", "--off"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain --off",
|
||||
{ off: true },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: false,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("drains without the off flag by default", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain",
|
||||
{ off: false },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
|
||||
// The drain was never lifted manually: the drained hub was replaced.
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
upgraded: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
|
||||
const { cmd, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(errors[0]).toContain("still serving sessions");
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const { cmd } = createCommand();
|
||||
cmd.configureOutput({ writeErr: () => {} });
|
||||
for (const sub of cmd.commands) {
|
||||
sub.configureOutput({ writeErr: () => {} });
|
||||
}
|
||||
await expect(
|
||||
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
|
||||
).rejects.toThrow("--wait requires a non-negative number of seconds.");
|
||||
expect(mockRequestHubDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
localHubHasNoActiveSessions,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
requestHubDrain,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -54,6 +56,16 @@ function resolveCliHubOwnerContext() {
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError(
|
||||
"--wait requires a non-negative number of seconds.",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -150,5 +162,115 @@ export function createHubCommand(
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("drain")
|
||||
.description("Refuse new mutating work while accepted runs finish")
|
||||
.option("--reason <text>", "Why the hub is draining")
|
||||
.option("--off", "Lift the drain and accept new mutating work again")
|
||||
.action(
|
||||
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
io.writeErr("No hub is running.");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const draining = cmdOptions.off !== true;
|
||||
const ok = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
cmdOptions.reason ??
|
||||
(draining ? "cline hub drain" : "cline hub drain --off"),
|
||||
{ off: !draining },
|
||||
);
|
||||
if (!ok) {
|
||||
io.writeErr(
|
||||
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify({ draining, url: discovery.url }));
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("upgrade")
|
||||
.description(
|
||||
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
|
||||
)
|
||||
.option(
|
||||
"--wait <seconds>",
|
||||
"How long to wait for the hub to go idle",
|
||||
parseWaitSeconds,
|
||||
120,
|
||||
)
|
||||
.action(
|
||||
action(async (cmdOptions: { wait: number }) => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
const drained = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade",
|
||||
).catch(() => false);
|
||||
// An aborted upgrade must hand the hub back: leaving it
|
||||
// draining refuses all new mutating work until a restart.
|
||||
const undrain = async (): Promise<void> => {
|
||||
if (!drained) {
|
||||
return;
|
||||
}
|
||||
await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
).catch(() => false);
|
||||
};
|
||||
try {
|
||||
const deadline = Date.now() + cmdOptions.wait * 1_000;
|
||||
let idle = false;
|
||||
// Check at least once so --wait 0 still observes an idle hub.
|
||||
for (;;) {
|
||||
idle = await localHubHasNoActiveSessions(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
).catch(() => true);
|
||||
if (idle || Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
if (!idle) {
|
||||
await undrain();
|
||||
io.writeErr(
|
||||
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
await stopHubServer(opts.cwd);
|
||||
} catch (error) {
|
||||
await undrain();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(JSON.stringify({ upgraded: true, url }));
|
||||
}),
|
||||
);
|
||||
|
||||
return hub;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -77,8 +76,5 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -18,20 +18,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.17
|
||||
|
||||
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
|
||||
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
|
||||
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
|
||||
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
|
||||
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
|
||||
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
|
||||
- Added suggested schedule templates to the Schedule page
|
||||
- Add Provider opens a dialog instead of swapping out the page
|
||||
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
|
||||
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
|
||||
- Fixed the provider list being unscrollable while a provider detail panel was open
|
||||
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
|
||||
- Fixed Uninstall buttons collapsing to a broken square next to Install
|
||||
- Fixed unreadable selected text inside input fields
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing the app on files containing a single enormous line
|
||||
- The hub's event log can no longer grow until it fills your disk
|
||||
|
||||
## 0.0.16
|
||||
|
||||
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
- The app now honors server-side feature flags, refreshing them when your account changes
|
||||
|
||||
## 0.0.15
|
||||
|
||||
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -33,10 +33,10 @@
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -80,6 +80,7 @@
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SidecarContext } from "./types";
|
||||
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
@@ -50,6 +52,7 @@ beforeEach(() => {
|
||||
clineAccountServiceCtorMock.mockReset();
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -140,3 +143,162 @@ describe("cline_account command auth states", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
|
||||
* cover the mid-session transitions that would otherwise keep evaluating flags
|
||||
* against a stale account (or the device).
|
||||
*/
|
||||
describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
async function currentFlagsUserId(): Promise<string | undefined> {
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
return getDesktopFeatureFlagsContext().userId ?? undefined;
|
||||
}
|
||||
|
||||
async function runOperation(ctx: SidecarContext, operation: string) {
|
||||
const { handleCommand } = await import("./commands");
|
||||
return handleCommand(ctx, "cline_account", {
|
||||
action: "clineAccount",
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { resetDesktopFeatureFlagsForTesting } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accountId: "stale-acct" },
|
||||
});
|
||||
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("adopts the identity from the refetch that follows a switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-2");
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// Signed out: no token resolves.
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// What the Sign Out button actually sends: a settings write that blanks
|
||||
// the auth block. No account command is involved.
|
||||
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "cline",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "anthropic",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "anthropic",
|
||||
api_key: "sk-test",
|
||||
});
|
||||
|
||||
// Saving an unrelated provider must not disturb the Cline identity.
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("falls back to the device distinct ID after logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
const deviceId = getDesktopFeatureFlagsContext().distinctId;
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
// Not left on the previous account's ID.
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,10 @@ import {
|
||||
resolveSidecarAskQuestion,
|
||||
sendEventToClient,
|
||||
} from "./context";
|
||||
import {
|
||||
identifyDesktopFeatureFlagsAccount,
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -294,6 +298,33 @@ function removePathIfExists(
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function syncFeatureFlagsAccountFromResult(
|
||||
ctx: SidecarContext,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
): void {
|
||||
if (operation === "fetchMe") {
|
||||
const user = result as { id?: string; email?: string } | undefined;
|
||||
if (user?.id) {
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: user.id, email: user.email },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFeatureFlagsAccountFromSettings(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): void {
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
@@ -1403,7 +1434,7 @@ export async function handleCommand(
|
||||
const result = await backend.updateSession({ sessionId, metadata: merged });
|
||||
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
||||
// Annotating a session is not session activity. updateSession stamps
|
||||
// updated_at, which clients sort and label rows by, so a favorite would
|
||||
// updated_at, which clients sort and label rows by, so a pin would
|
||||
// otherwise make an old session look like it just ran.
|
||||
if (existing?.updatedAt) {
|
||||
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
||||
@@ -1548,6 +1579,14 @@ export async function handleCommand(
|
||||
// would be captured as error telemetry and shown raw to the user.
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
// Backstop for credentials that go away without a settings write —
|
||||
// an expired or server-revoked token. Explicit sign-out is handled
|
||||
// at its source in `save_provider_settings`; this catches the rest
|
||||
// so a stale account never keeps serving its rollout cohort.
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
@@ -1556,10 +1595,12 @@ export async function handleCommand(
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
const result = await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
syncFeatureFlagsAccountFromResult(ctx, operation, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Provider management ────────────────────────────────────────────
|
||||
@@ -1718,13 +1759,21 @@ export async function handleCommand(
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
return saveLocalProviderSettings(manager, {
|
||||
const saved = saveLocalProviderSettings(manager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
providerId: String(args?.provider ?? ""),
|
||||
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
|
||||
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
||||
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
|
||||
});
|
||||
// Sign-out is a `save_provider_settings` that blanks the cline auth block
|
||||
// (see signOut in webview settings/account-view.tsx), so this is the
|
||||
// authoritative signal — it fires the moment credentials are cleared
|
||||
// rather than waiting for the next account fetch.
|
||||
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
|
||||
syncFeatureFlagsAccountFromSettings(ctx, manager);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
if (command === "add_provider") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -1827,6 +1876,18 @@ export async function handleCommand(
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Feature flags ──────────────────────────────────────────────────
|
||||
// Flags are evaluated here, not in the webview: the sidecar already has
|
||||
// the PostHog key inlined at build time and evaluates against the same
|
||||
// distinct ID it reports telemetry with. The client just reads the
|
||||
// resolved values.
|
||||
if (command === "get_feature_flags") {
|
||||
return await refreshDesktopFeatureFlags({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import {
|
||||
disposeDesktopFeatureFlagsService,
|
||||
getDesktopFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
@@ -627,6 +631,10 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
// Shuts down the PostHog client the feature flags service owns, flushing
|
||||
// any pending $feature_flag_called events.
|
||||
cleanup.push(disposeDesktopFeatureFlagsService());
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -1021,6 +1029,10 @@ export async function initializeSessionManager(
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
featureFlags: getDesktopFeatureFlagsService({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
}),
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildClinePostHogClient: vi.fn(() => ({ kind: "posthog-client" })),
|
||||
PostHogFeatureFlagsProvider: vi.fn(function PostHogFeatureFlagsProvider(
|
||||
this: Record<string, unknown>,
|
||||
options: unknown,
|
||||
) {
|
||||
this.kind = "posthog";
|
||||
this.options = options;
|
||||
}),
|
||||
NoOpFeatureFlagsProvider: vi.fn(function NoOpFeatureFlagsProvider(
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.kind = "noop";
|
||||
}),
|
||||
resolveCoreDistinctId: vi.fn(() => "machine-distinct-id"),
|
||||
poll: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setContext: vi.fn(),
|
||||
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
// Two known flags keep the snapshot assertions meaningful even as the
|
||||
// real registry changes.
|
||||
FEATURE_FLAGS: ["ext-cline-pass", "ext-demo-flag"],
|
||||
NoOpFeatureFlagsProvider: mocks.NoOpFeatureFlagsProvider,
|
||||
resolveCoreDistinctId: mocks.resolveCoreDistinctId,
|
||||
FeatureFlagsService: class {
|
||||
options: Record<string, unknown>;
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
}
|
||||
poll = mocks.poll;
|
||||
dispose = mocks.dispose;
|
||||
setContext = mocks.setContext;
|
||||
getFlagPayload = mocks.getFlagPayload;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core/services/feature-flags/posthog", () => ({
|
||||
buildClinePostHogClient: mocks.buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider: mocks.PostHogFeatureFlagsProvider,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildFeatureFlagsSnapshot,
|
||||
disposeDesktopFeatureFlagsService,
|
||||
getDesktopFeatureFlagsContext,
|
||||
getDesktopFeatureFlagsService,
|
||||
refreshDesktopFeatureFlags,
|
||||
resetDesktopFeatureFlagsForTesting,
|
||||
setDesktopFeatureFlagsAccountContext,
|
||||
} from "./feature-flags";
|
||||
|
||||
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const originalIsTest = process.env.IS_TEST;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
delete process.env.IS_TEST;
|
||||
delete process.env.E2E_TEST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
} else {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = originalApiKey;
|
||||
}
|
||||
if (originalIsTest === undefined) {
|
||||
delete process.env.IS_TEST;
|
||||
} else {
|
||||
process.env.IS_TEST = originalIsTest;
|
||||
}
|
||||
});
|
||||
|
||||
describe("getDesktopFeatureFlagsService", () => {
|
||||
it("uses PostHog when the build-time key is inlined", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildClinePostHogClient).toHaveBeenCalledWith("phc_key");
|
||||
expect(mocks.NoOpFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the no-op provider when no key was inlined", () => {
|
||||
delete process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never calls PostHog under IS_TEST even with a key present", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
process.env.IS_TEST = "true";
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns one shared instance so the core and the webview agree", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
expect(getDesktopFeatureFlagsService()).toBe(
|
||||
getDesktopFeatureFlagsService(),
|
||||
);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flags context", () => {
|
||||
it("defaults to the machine distinct ID under the cline-code client name", () => {
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.clientName).toBe("cline-code");
|
||||
expect(context.distinctId).toBe("machine-distinct-id");
|
||||
});
|
||||
|
||||
it("switches to the account ID once signed in, and pushes it to the service", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
setDesktopFeatureFlagsAccountContext({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
});
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.distinctId).toBe("acct-1");
|
||||
expect(context.userId).toBe("acct-1");
|
||||
expect(mocks.setContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the device identity when the account ID is blank", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: " " });
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe(
|
||||
"machine-distinct-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the account identity on sign-out and falls back to the device", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
|
||||
expect(getDesktopFeatureFlagsContext().userId).toBe("acct-1");
|
||||
|
||||
expect(setDesktopFeatureFlagsAccountContext({})).toBe(true);
|
||||
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.userId).toBeUndefined();
|
||||
// Must not be left on the signed-out account's ID.
|
||||
expect(context.distinctId).toBe("machine-distinct-id");
|
||||
});
|
||||
|
||||
it("reports no change when the same account is re-confirmed", () => {
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(true);
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no change when signed out twice", () => {
|
||||
expect(setDesktopFeatureFlagsAccountContext({})).toBe(false);
|
||||
});
|
||||
|
||||
it("re-points at the new account when switching accounts", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-2" })).toBe(true);
|
||||
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.userId).toBe("acct-2");
|
||||
expect(context.distinctId).toBe("acct-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureFlagsSnapshot", () => {
|
||||
it("resolves every known flag so the client needs no defaults", () => {
|
||||
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
|
||||
flag === "ext-cline-pass" ? true : undefined,
|
||||
);
|
||||
const snapshot = buildFeatureFlagsSnapshot(
|
||||
getDesktopFeatureFlagsService() as never,
|
||||
);
|
||||
expect(snapshot.flags).toEqual({
|
||||
"ext-cline-pass": true,
|
||||
// Unreturned flags resolve to false rather than being absent.
|
||||
"ext-demo-flag": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes non-boolean payloads through untouched", () => {
|
||||
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
|
||||
flag === "ext-cline-pass" ? { variant: "b", limit: 3 } : false,
|
||||
);
|
||||
const snapshot = buildFeatureFlagsSnapshot(
|
||||
getDesktopFeatureFlagsService() as never,
|
||||
);
|
||||
expect(snapshot.flags["ext-cline-pass"]).toEqual({
|
||||
variant: "b",
|
||||
limit: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshDesktopFeatureFlags", () => {
|
||||
it("polls before returning the snapshot", async () => {
|
||||
mocks.getFlagPayload.mockReturnValue(true);
|
||||
const snapshot = await refreshDesktopFeatureFlags();
|
||||
expect(mocks.poll).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot.flags["ext-cline-pass"]).toBe(true);
|
||||
});
|
||||
|
||||
it("still returns cached values when the poll fails", async () => {
|
||||
mocks.poll.mockRejectedValueOnce(new Error("offline"));
|
||||
mocks.getFlagPayload.mockReturnValue(false);
|
||||
const logger = { error: vi.fn(), log: vi.fn(), debug: vi.fn() };
|
||||
|
||||
const snapshot = await refreshDesktopFeatureFlags({ logger });
|
||||
|
||||
expect(snapshot.flags["ext-cline-pass"]).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeDesktopFeatureFlagsService", () => {
|
||||
it("disposes the live service and clears it", async () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
await disposeDesktopFeatureFlagsService();
|
||||
expect(mocks.dispose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A later call builds a fresh service rather than reusing a disposed one.
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing was created", async () => {
|
||||
await expect(disposeDesktopFeatureFlagsService()).resolves.toBeUndefined();
|
||||
expect(mocks.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
FEATURE_FLAGS,
|
||||
type FeatureFlagPayload,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let desktopFeatureFlagsContext: FeatureFlagsContext = {
|
||||
clientName: "cline-code",
|
||||
};
|
||||
let desktopFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
function resolveDesktopFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.cline-code.json");
|
||||
}
|
||||
|
||||
function ensureDesktopDistinctId(): string {
|
||||
const distinctId = desktopFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
desktopFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getDesktopFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureDesktopDistinctId();
|
||||
return { ...desktopFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getDesktopFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!desktopFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
desktopFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getDesktopFeatureFlagsContext(),
|
||||
cacheFilePath: resolveDesktopFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return desktopFeatureFlagsService;
|
||||
}
|
||||
|
||||
export async function disposeDesktopFeatureFlagsService(): Promise<void> {
|
||||
if (!desktopFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = desktopFeatureFlagsService;
|
||||
desktopFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setDesktopFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): boolean {
|
||||
const accountId = account.id?.trim();
|
||||
const previousUserId = desktopFeatureFlagsContext.userId ?? undefined;
|
||||
if (previousUserId === (accountId || undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
desktopFeatureFlagsContext = {
|
||||
...desktopFeatureFlagsContext,
|
||||
distinctId: accountId,
|
||||
userId: accountId,
|
||||
};
|
||||
} else {
|
||||
// Drop both identifiers; ensureDesktopDistinctId re-resolves the device
|
||||
// ID on the next read rather than leaving the old account's ID behind.
|
||||
const {
|
||||
distinctId: _distinctId,
|
||||
userId: _userId,
|
||||
...rest
|
||||
} = desktopFeatureFlagsContext;
|
||||
desktopFeatureFlagsContext = rest;
|
||||
}
|
||||
|
||||
desktopFeatureFlagsService?.setContext(getDesktopFeatureFlagsContext());
|
||||
return true;
|
||||
}
|
||||
|
||||
export type FeatureFlagsSnapshot = {
|
||||
flags: Record<string, FeatureFlagPayload>;
|
||||
};
|
||||
|
||||
export function buildFeatureFlagsSnapshot(
|
||||
service: FeatureFlagsService,
|
||||
): FeatureFlagsSnapshot {
|
||||
const flags: Record<string, FeatureFlagPayload> = {};
|
||||
for (const flag of FEATURE_FLAGS) {
|
||||
flags[flag] = service.getFlagPayload(flag) ?? false;
|
||||
}
|
||||
return { flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh flags from PostHog, then hand back the resolved snapshot.
|
||||
*
|
||||
* Polling is cheap to call repeatedly.
|
||||
*/
|
||||
export async function refreshDesktopFeatureFlags(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): Promise<FeatureFlagsSnapshot> {
|
||||
const service = getDesktopFeatureFlagsService(options);
|
||||
try {
|
||||
await service.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error refreshing desktop feature flags", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
return buildFeatureFlagsSnapshot(service);
|
||||
}
|
||||
|
||||
export async function identifyDesktopFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
options?: { logger?: BasicLogger; telemetry?: ITelemetryService },
|
||||
): Promise<void> {
|
||||
if (
|
||||
!setDesktopFeatureFlagsAccountContext(account) ||
|
||||
!desktopFeatureFlagsService
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await desktopFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error polling desktop feature flags", { error });
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDesktopFeatureFlagsForTesting(): void {
|
||||
desktopFeatureFlagsService = undefined;
|
||||
desktopFeatureFlagsContext = { clientName: "cline-code" };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
type DesktopLoggerAdapter,
|
||||
@@ -45,6 +46,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
});
|
||||
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.17",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
markOnboardingCompleted,
|
||||
ONBOARDING_RESET_EVENT,
|
||||
} from "@/lib/onboarding";
|
||||
import { requestPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
@@ -220,6 +221,7 @@ export default function Home() {
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
|
||||
requestPromptInputFocus();
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
@@ -289,6 +291,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
navigateWith({ view: "chat" });
|
||||
requestPromptInputFocus();
|
||||
}, [activeThread, handleNewThread, navigateWith]);
|
||||
const handleViewChange = useCallback(
|
||||
(nextView: DesktopAppView) => {
|
||||
@@ -296,6 +299,13 @@ export default function Home() {
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
// The sidebar's New row reads as selected while the fresh, not-yet-started
|
||||
// task page is showing; once the task starts the session row takes over.
|
||||
const newTaskActive =
|
||||
view === "chat" &&
|
||||
activeThread !== undefined &&
|
||||
!activeThread.hasStarted &&
|
||||
!activeThread.historySession;
|
||||
const handleSettingsSectionChange = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
navigateWith({ settingsSection: section, view: "settings" });
|
||||
@@ -428,21 +438,15 @@ export default function Home() {
|
||||
>
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
newTaskActive={newTaskActive}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
workspaceRoot={
|
||||
activeThread?.historySession?.workspaceRoot ||
|
||||
activeThread?.historySession?.cwd ||
|
||||
historyWorkspacePaths[0]
|
||||
}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
/>
|
||||
@@ -481,7 +485,7 @@ export default function Home() {
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onOpenVoiceInputSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
handleSettingsSectionChange("Voice")
|
||||
}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// Covers the shipped state of the Agenda feature: the sidebar has no Agenda
|
||||
// UI at all, and with AGENDA_UI_ENABLED false (the real flag value) the
|
||||
// welcome quick actions stay hidden and no agenda commands are issued. The
|
||||
// feature-flag mock in welcome-chat.test.tsx forces the flag on to keep
|
||||
// exercising the dormant welcome-screen UI.
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { UseSessionHistoryResult } from "@/hooks/use-session-history";
|
||||
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
listAgendaTasks: vi.fn(),
|
||||
getAgendaAutomationPolicy: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
desktopMocks.invoke.mockRejectedValue(new Error("not available in test"));
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeSessionHistory(): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
hasLoadedHistory: true,
|
||||
isLoadingMore: false,
|
||||
loadAllSessions: vi.fn(async () => true),
|
||||
loadOlderSessions: vi.fn(),
|
||||
loadMoreSessions: vi.fn(),
|
||||
mayHaveMoreSessions: false,
|
||||
openThread: vi.fn(),
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
threads: [],
|
||||
unreadSessionIds: new Set<string>(),
|
||||
} as unknown as UseSessionHistoryResult;
|
||||
}
|
||||
|
||||
describe("Agenda UI hidden by default", () => {
|
||||
it("renders the sidebar without any Agenda UI and issues no agenda commands", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory()}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Show Agenda"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Search sessions"]'),
|
||||
).not.toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
expect(desktopMocks.getAgendaAutomationPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the welcome screen without agenda quick actions or agenda fetches", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
listWorkspaces: vi.fn(async () => ["/projects/project-1"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -18,13 +17,6 @@ import type {
|
||||
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
createAgendaTask: vi.fn(),
|
||||
listAgendaTasks: vi.fn(),
|
||||
approveAgendaTask: vi.fn(),
|
||||
cancelAgendaTask: vi.fn(),
|
||||
runAgendaTask: vi.fn(),
|
||||
getAgendaAutomationPolicy: vi.fn(),
|
||||
setAgendaAutomationPolicy: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
}));
|
||||
@@ -55,13 +47,15 @@ function makeSessionHistory(
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
hasLoadedHistory?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
} = {},
|
||||
): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
hasLoadedHistory: options.hasLoadedHistory ?? true,
|
||||
isLoadingMore: false,
|
||||
isLoadingMore: options.isLoadingMore ?? false,
|
||||
loadAllSessions: vi.fn(async () => true),
|
||||
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions: options.mayHaveMoreSessions ?? false,
|
||||
@@ -122,6 +116,14 @@ function sessionIsVisible(title: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function sessionRow(title: string): HTMLButtonElement {
|
||||
const row = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(button) => button.querySelector("span")?.textContent === title,
|
||||
);
|
||||
expect(row).toBeDefined();
|
||||
return row as HTMLButtonElement;
|
||||
}
|
||||
|
||||
const signedInUser = {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
@@ -145,23 +147,6 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
invoke.mockReset();
|
||||
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
|
||||
desktopMocks.createAgendaTask.mockReset();
|
||||
desktopMocks.listAgendaTasks.mockReset();
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([]);
|
||||
desktopMocks.approveAgendaTask.mockReset();
|
||||
desktopMocks.cancelAgendaTask.mockReset();
|
||||
desktopMocks.runAgendaTask.mockReset();
|
||||
desktopMocks.getAgendaAutomationPolicy.mockReset();
|
||||
desktopMocks.getAgendaAutomationPolicy.mockResolvedValue({
|
||||
scopeKey: "global",
|
||||
mode: "manual",
|
||||
applyToAgentCreated: true,
|
||||
maxConcurrentRuns: 1,
|
||||
maxChainDepth: 3,
|
||||
maxStartsPerHour: 20,
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
});
|
||||
desktopMocks.setAgendaAutomationPolicy.mockReset();
|
||||
desktopMocks.subscribe.mockReset();
|
||||
desktopMocks.subscribe.mockImplementation(() => () => undefined);
|
||||
desktopMocks.subscribeTransportState.mockReset();
|
||||
@@ -179,6 +164,17 @@ beforeEach(() => {
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
// cmdk (the search dialog) observes its list size; jsdom has no
|
||||
// ResizeObserver implementation.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -191,258 +187,18 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("shows an unread dot when a new Todo item arrives and clears it on open", async () => {
|
||||
const eventHandlers = new Map<string, () => void>();
|
||||
desktopMocks.subscribe.mockImplementation(
|
||||
(eventName: string, handler: () => void) => {
|
||||
eventHandlers.set(eventName, handler);
|
||||
return () => eventHandlers.delete(eventName);
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(desktopMocks.listAgendaTasks).toHaveBeenCalled(),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).toBeNull();
|
||||
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([makeAgendaTask()]);
|
||||
await act(async () => {
|
||||
eventHandlers.get("task.created")?.();
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="new-todo-indicator"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows pending Agenda work and requires approval before run", async () => {
|
||||
const task = makeAgendaTask();
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([task]);
|
||||
desktopMocks.approveAgendaTask.mockResolvedValue({
|
||||
...task,
|
||||
status: "approved",
|
||||
revision: 2,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
workspaceRoot="/projects/current"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Review PR checks");
|
||||
expect(container.textContent).toContain("cline");
|
||||
expect(container.textContent).not.toContain("P1 · pending approval");
|
||||
expect(desktopMocks.listAgendaTasks).toHaveBeenCalledWith({
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
workspaceRoot: "/projects/current",
|
||||
limit: 200,
|
||||
});
|
||||
const approve = container.querySelector(
|
||||
'[aria-label="Approve Review PR checks"]',
|
||||
);
|
||||
expect(approve).not.toBeNull();
|
||||
expect(approve?.className).toContain("text-emerald-500!");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Cancel Review PR checks"]')
|
||||
?.className,
|
||||
).toContain("text-destructive!");
|
||||
expect(approve?.closest(".group")?.className).toContain("max-w-full");
|
||||
expect(
|
||||
buttonWithText("Review PR checks").querySelector(".truncate"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Run Review PR checks"]'),
|
||||
).toBeNull();
|
||||
|
||||
await click(buttonWithText("Review PR checks"));
|
||||
expect(desktopMocks.approveAgendaTask).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain(
|
||||
"Review CI and report failures.",
|
||||
);
|
||||
expect(buttonWithText("Reject", document)).toBeDefined();
|
||||
await click(buttonWithText("Approve", document));
|
||||
expect(desktopMocks.approveAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses each displayed Agenda revision when running or cancelling", async () => {
|
||||
const runnable = makeAgendaTask({
|
||||
taskId: "task-run",
|
||||
title: "Run task",
|
||||
status: "approved",
|
||||
revision: 4,
|
||||
});
|
||||
const cancellable = makeAgendaTask({
|
||||
taskId: "task-cancel",
|
||||
title: "Cancel task",
|
||||
status: "approved",
|
||||
revision: 9,
|
||||
});
|
||||
desktopMocks.listAgendaTasks.mockResolvedValue([runnable, cancellable]);
|
||||
desktopMocks.runAgendaTask.mockResolvedValue({
|
||||
task: { ...runnable, status: "in_progress" },
|
||||
});
|
||||
desktopMocks.cancelAgendaTask.mockResolvedValue({
|
||||
...cancellable,
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Run Run task"]') as Element,
|
||||
);
|
||||
expect(desktopMocks.runAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-run",
|
||||
expectedRevision: 4,
|
||||
});
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Cancel Cancel task"]') as Element,
|
||||
);
|
||||
expect(desktopMocks.cancelAgendaTask).toHaveBeenCalledWith({
|
||||
taskId: "task-cancel",
|
||||
expectedRevision: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a workspace task with the selected priority, expiry, and model", async () => {
|
||||
const created = makeAgendaTask({
|
||||
taskId: "task-created",
|
||||
title: "Investigate the regression",
|
||||
});
|
||||
desktopMocks.createAgendaTask.mockResolvedValue(created);
|
||||
window.localStorage.setItem(
|
||||
"cline.code.model-selection.v1",
|
||||
JSON.stringify({
|
||||
lastProvider: "openrouter",
|
||||
lastModelByProvider: { openrouter: "anthropic/claude-sonnet-4.6" },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
workspaceRoot="/projects/current"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
|
||||
await click(
|
||||
container.querySelector('[aria-label="Show Agenda"]') as Element,
|
||||
);
|
||||
await click(
|
||||
container.querySelector('[aria-label="Create Todo item"]') as Element,
|
||||
);
|
||||
const title =
|
||||
document.querySelector<HTMLInputElement>("#agenda-task-title");
|
||||
const instructions = document.querySelector<HTMLTextAreaElement>(
|
||||
"#agenda-task-instructions",
|
||||
);
|
||||
expect(title).not.toBeNull();
|
||||
expect(instructions).not.toBeNull();
|
||||
await changeField(title as HTMLInputElement, "Investigate the regression");
|
||||
await changeField(
|
||||
instructions as HTMLTextAreaElement,
|
||||
"Inspect the failing build and implement a fix.",
|
||||
);
|
||||
await click(buttonWithText("Add to Agenda", document));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(desktopMocks.createAgendaTask).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const input = desktopMocks.createAgendaTask.mock.calls[0]?.[0];
|
||||
expect(input).toMatchObject({
|
||||
type: "todo",
|
||||
title: "Investigate the regression",
|
||||
instructions: "Inspect the failing build and implement a fix.",
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/current",
|
||||
priority: 3,
|
||||
modelSelection: {
|
||||
providerId: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
automationEligible: true,
|
||||
});
|
||||
expect(Date.parse(input.expiresAt)).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("filters scheduled sessions without changing their titles", async () => {
|
||||
it("marks scheduled sessions with a clock icon in the row", async () => {
|
||||
const scheduled = {
|
||||
...makeThread("scheduled", 1),
|
||||
...makeThread("alpha", 1),
|
||||
source: "core",
|
||||
isScheduled: true,
|
||||
};
|
||||
const regular = { ...makeThread("regular", 1), source: "core" };
|
||||
const pinnedScheduled = {
|
||||
...makeThread("alpha", 2),
|
||||
isScheduled: true,
|
||||
pinned: true,
|
||||
};
|
||||
const regular = { ...makeThread("alpha", 3), source: "core" };
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -450,9 +206,11 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([scheduled, regular], vi.fn())}
|
||||
sessionHistory={makeSessionHistory(
|
||||
[scheduled, pinnedScheduled, regular],
|
||||
vi.fn(),
|
||||
)}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
@@ -461,24 +219,127 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(sessionIsVisible("scheduled session 1")).toBe(true);
|
||||
expect(container.textContent).not.toContain("(schedule)");
|
||||
// The clock marks scheduled rows inline; a pinned scheduled session
|
||||
// shows both indicators at once.
|
||||
expect(
|
||||
sessionRow("alpha session 1").querySelector('[aria-label="Scheduled"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
sessionRow("alpha session 1").querySelector('[aria-label="Pinned"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sessionRow("alpha session 2").querySelector('[aria-label="Scheduled"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
sessionRow("alpha session 2").querySelector('[aria-label="Pinned"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
sessionRow("alpha session 3").querySelector('[aria-label="Scheduled"]'),
|
||||
).toBeNull();
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Filter sessions"]') as Element,
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("Recent");
|
||||
const schedulesOption = await vi.waitFor(() => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
].find((candidate) => candidate.textContent?.includes("Schedules"));
|
||||
expect(option).toBeDefined();
|
||||
return option as HTMLElement;
|
||||
// The old Pinned/Scheduled/Tasks category sections are gone.
|
||||
expect(container.textContent).not.toContain("Scheduled");
|
||||
expect(container.textContent).not.toContain("Tasks");
|
||||
});
|
||||
|
||||
it("pins sessions to the top of their project group", async () => {
|
||||
const pinned = { ...makeThread("alpha", 3), pinned: true };
|
||||
const threads = [makeThread("alpha", 1), makeThread("alpha", 2), pinned];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory(threads, vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
await click(schedulesOption);
|
||||
|
||||
expect(sessionIsVisible("scheduled session 1")).toBe(true);
|
||||
expect(sessionIsVisible("regular session 1")).toBe(false);
|
||||
// The pinned session leads its project group despite being the oldest
|
||||
// entry in history order, and carries the pin icon inline.
|
||||
const pinnedRow = sessionRow("alpha session 3");
|
||||
expect(pinnedRow.querySelector('[aria-label="Pinned"]')).not.toBeNull();
|
||||
for (const title of ["alpha session 1", "alpha session 2"]) {
|
||||
expect(
|
||||
pinnedRow.compareDocumentPosition(sessionRow(title)) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(container.textContent).not.toContain("Pinned");
|
||||
});
|
||||
|
||||
it("loads older history only on explicit Show more clicks", async () => {
|
||||
const tasks = Array.from({ length: 5 }, (_, index) =>
|
||||
makeThread("plain", index + 1),
|
||||
);
|
||||
const loadOlderSessions = vi.fn(async () => false);
|
||||
const renderSidebar = async (isLoadingMore: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory(tasks, vi.fn(), {
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions: true,
|
||||
})}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await renderSidebar(false);
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
|
||||
// A settled fetch never triggers an automatic follow-up request; only
|
||||
// another explicit click asks for more history.
|
||||
await renderSidebar(true);
|
||||
await renderSidebar(false);
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a flat session list when nothing is pinned or scheduled", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory(
|
||||
[makeThread("plain", 1)],
|
||||
vi.fn(),
|
||||
)}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(sessionIsVisible("plain session 1")).toBe(true);
|
||||
expect(container.textContent).not.toContain("Pinned");
|
||||
expect(container.textContent).not.toContain("Scheduled");
|
||||
expect(container.textContent).not.toContain("Tasks");
|
||||
});
|
||||
|
||||
it("defaults to all sources and filters by the selected client source", async () => {
|
||||
@@ -527,7 +388,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn(), {
|
||||
hasLoadedHistory: false,
|
||||
@@ -553,7 +413,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn(), {
|
||||
hasLoadedHistory: true,
|
||||
@@ -604,7 +463,7 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to time and keeps project expansion scoped to one project", async () => {
|
||||
it("always groups sessions by project and scopes expansion to one project", async () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
makeThread("alpha", index + 1),
|
||||
@@ -626,7 +485,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
@@ -637,44 +495,27 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]'),
|
||||
).not.toBeNull();
|
||||
expect(sessionIsVisible("alpha session 10")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(loadMoreSessions).toHaveBeenCalledWith(20);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
|
||||
);
|
||||
const projectOption = await vi.waitFor(() => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
].find((candidate) => candidate.textContent?.includes("Sort by project"));
|
||||
expect(option).toBeDefined();
|
||||
return option as HTMLElement;
|
||||
});
|
||||
await click(projectOption);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Project"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
// The sort toggle is gone: grouping is always by project.
|
||||
expect(container.querySelector('[aria-label^="Sort sessions"]')).toBeNull();
|
||||
expect(container.textContent).toContain("alpha");
|
||||
expect(container.textContent).toContain("beta");
|
||||
expect(sessionIsVisible("alpha session 10")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 10")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
|
||||
// Expanding one project leaves the others' pagination untouched.
|
||||
await click(buttonWithText("Show more in alpha"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
expect(loadMoreSessions).not.toHaveBeenCalled();
|
||||
|
||||
await click(buttonWithText("Load older projects"));
|
||||
// The trailing Show more button grows the loaded history window.
|
||||
const globalShowMore = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((button) => button.textContent?.trim() === "Show more");
|
||||
expect(globalShowMore).toBeDefined();
|
||||
await click(globalShowMore as HTMLButtonElement);
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -688,7 +529,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -734,7 +574,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={setView}
|
||||
@@ -757,8 +596,9 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the settings gear hover state while the Account screen is open", async () => {
|
||||
it("opens the General settings section from the gear in any section", async () => {
|
||||
invoke.mockResolvedValue(signedInUser);
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
|
||||
const renderSidebar = async (settingsSection: "Account" | "General") => {
|
||||
await act(async () => {
|
||||
@@ -768,8 +608,7 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection={settingsSection}
|
||||
@@ -786,13 +625,19 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
};
|
||||
|
||||
// The Account screen leaves the gear un-highlighted, but clicking it
|
||||
// still navigates to General rather than acting as a no-op.
|
||||
// (split on spaces: the variant's hover:bg-surface-hover would match a
|
||||
// plain substring check)
|
||||
const gearOnAccount = await renderSidebar("Account");
|
||||
expect(gearOnAccount.className).toContain("hover:bg-transparent");
|
||||
expect(gearOnAccount.className).not.toContain("bg-surface-hover");
|
||||
expect(gearOnAccount.className.split(" ")).not.toContain(
|
||||
"bg-surface-hover",
|
||||
);
|
||||
await click(gearOnAccount);
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("General");
|
||||
|
||||
const gearOnGeneral = await renderSidebar("General");
|
||||
expect(gearOnGeneral.className).not.toContain("hover:bg-transparent");
|
||||
expect(gearOnGeneral.className).toContain("bg-surface-hover");
|
||||
expect(gearOnGeneral.className.split(" ")).toContain("bg-surface-hover");
|
||||
});
|
||||
|
||||
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
|
||||
@@ -818,7 +663,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={onHome}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -867,7 +711,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -906,7 +749,6 @@ describe("AgentSidebar session organization", () => {
|
||||
onHome={vi.fn()}
|
||||
onNavigateBack={onNavigateBack}
|
||||
onNavigateForward={onNavigateForward}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -930,17 +772,17 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(onNavigateForward).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("places the logo and icon-only new-session action below the title bar", async () => {
|
||||
const onNewThread = vi.fn();
|
||||
it("stacks New, Schedule, and Customize as full-width rows below the logo", async () => {
|
||||
const onHome = vi.fn();
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={onNewThread}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
onHome={onHome}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
@@ -952,14 +794,126 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
|
||||
const logo = container.querySelector('[aria-label="Cline home"]');
|
||||
const showAgenda = container.querySelector('[aria-label="Show Agenda"]');
|
||||
const newSession = container.querySelector('[aria-label="New Session"]');
|
||||
const actionsNav = container.querySelector(
|
||||
'[aria-label="Sidebar actions"]',
|
||||
);
|
||||
expect(logo).not.toBeNull();
|
||||
expect(showAgenda).not.toBeNull();
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(newSession?.textContent).toBe("");
|
||||
await click(newSession as Element);
|
||||
expect(onNewThread).toHaveBeenCalledOnce();
|
||||
expect(actionsNav).not.toBeNull();
|
||||
// The rows read as labeled full-width entries stacked outside the
|
||||
// logo row, not ambiguous icons in the header cluster.
|
||||
const rows = [
|
||||
...(actionsNav?.querySelectorAll<HTMLButtonElement>("button") ?? []),
|
||||
];
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
"New",
|
||||
"Schedule",
|
||||
"Customize",
|
||||
]);
|
||||
for (const row of rows) {
|
||||
expect(row.className).toContain("w-full");
|
||||
}
|
||||
expect(actionsNav?.contains(logo as Element)).toBe(false);
|
||||
|
||||
await click(buttonWithText("New", actionsNav as ParentNode));
|
||||
expect(onHome).toHaveBeenCalledOnce();
|
||||
await click(buttonWithText("Schedule", actionsNav as ParentNode));
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Schedules");
|
||||
await click(buttonWithText("Customize", actionsNav as ParentNode));
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Customize");
|
||||
});
|
||||
|
||||
it("highlights the New row only while the new-task page is active", async () => {
|
||||
const renderSidebar = async (newTaskActive: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
newTaskActive={newTaskActive}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
return buttonWithText(
|
||||
"New",
|
||||
container.querySelector('[aria-label="Sidebar actions"]') as ParentNode,
|
||||
);
|
||||
};
|
||||
|
||||
const activeRow = await renderSidebar(true);
|
||||
expect(activeRow.className.split(" ")).toContain("bg-surface-hover");
|
||||
expect(activeRow.getAttribute("aria-current")).toBe("page");
|
||||
|
||||
const inactiveRow = await renderSidebar(false);
|
||||
expect(inactiveRow.className.split(" ")).not.toContain("bg-surface-hover");
|
||||
expect(inactiveRow.getAttribute("aria-current")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens session search in a dialog from the logo-row icon", async () => {
|
||||
const sessionHistory = makeSessionHistory(
|
||||
[makeThread("alpha", 1), makeThread("beta", 1)],
|
||||
vi.fn(),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Search lives behind the logo-row icon, not an inline sidebar input.
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
const searchButton = container.querySelector(
|
||||
'button[aria-label="Search sessions"]',
|
||||
);
|
||||
expect(searchButton).not.toBeNull();
|
||||
await click(searchButton as Element);
|
||||
// Opening search pulls the full history so unloaded sessions match too.
|
||||
expect(sessionHistory.loadAllSessions).toHaveBeenCalledOnce();
|
||||
|
||||
const searchInput = await vi.waitFor(() => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'[data-slot="command-input"]',
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
return input as HTMLInputElement;
|
||||
});
|
||||
expect(searchInput.placeholder).toBe("Search sessions...");
|
||||
|
||||
await changeField(searchInput, "alpha");
|
||||
const match = await vi.waitFor(() => {
|
||||
const items = [
|
||||
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
|
||||
];
|
||||
expect(items).toHaveLength(1);
|
||||
return items[0] as HTMLElement;
|
||||
});
|
||||
expect(match.textContent).toContain("alpha session 1");
|
||||
|
||||
await click(match);
|
||||
expect(sessionHistory.openThread).toHaveBeenCalledWith("alpha-1");
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('[data-slot="command-input"]')).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
|
||||
@@ -970,7 +924,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -983,7 +936,9 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sidebar actions"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Expand sidebar"]')?.className,
|
||||
).toContain("mt-auto");
|
||||
@@ -997,7 +952,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -1046,7 +1000,6 @@ describe("AgentSidebar session organization", () => {
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
@@ -1069,29 +1022,3 @@ describe("AgentSidebar session organization", () => {
|
||||
).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
||||
function makeAgendaTask(
|
||||
overrides: Partial<AgendaTaskRecord> = {},
|
||||
): AgendaTaskRecord {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
type: "follow-up",
|
||||
status: "pending_approval",
|
||||
title: "Review PR checks",
|
||||
description: "Confirm that CI passed.",
|
||||
instructions: "Review CI and report failures.",
|
||||
scope: "workspace",
|
||||
workspaceRoot: "/projects/cline",
|
||||
resourcePaths: [],
|
||||
priority: 1,
|
||||
availableAt: "2026-08-13T00:00:00.000Z",
|
||||
expiresAt: "2099-08-20T00:00:00.000Z",
|
||||
automationEligible: true,
|
||||
revision: 1,
|
||||
createdBy: { kind: "agent" },
|
||||
updatedBy: { kind: "agent" },
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,13 +24,15 @@ const buttonVariants = cva(
|
||||
sidebarItem:
|
||||
"!h-auto w-full justify-start text-left gap-2 rounded-md !px-2 py-2 !text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground",
|
||||
sidebarText:
|
||||
"!h-auto justify-start gap-1 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-sidebar-foreground",
|
||||
"!h-auto justify-start gap-1 px-3 py-1.5 !text-sm font-normal text-muted-foreground hover:text-sidebar-foreground",
|
||||
text: "bg-transparent text-sm font-medium text-muted-foreground hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:pl-2 has-[>svg]:pr-2.5 text-base",
|
||||
sm: "h-8 gap-1.5 px-2.5 py-1.5 has-[>svg]:pl-2.5 has-[>svg]:pr-3 text-sm",
|
||||
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:size-3 text-xs",
|
||||
// (has-[>svg]:size-3 was a leftover from when xs was a 12px micro
|
||||
// button; it collapsed any xs button containing an icon.)
|
||||
xs: "h-7 gap-1.5 px-2.5 py-1.5 has-[>svg]:px-2 text-xs",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 text-lg",
|
||||
icon: "size-5",
|
||||
"icon-sm": "size-3 p-1",
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -33,7 +33,9 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
||||
const SIDEBAR_MIN_WIDTH = 224;
|
||||
// Floor keeps session rows legible: below this the thread titles are fully
|
||||
// truncated away and row metadata (timestamps, pins) starts clipping.
|
||||
const SIDEBAR_MIN_WIDTH = 260;
|
||||
const SIDEBAR_MAX_WIDTH = 560;
|
||||
|
||||
function clampSidebarWidth(value: number): number {
|
||||
|
||||
@@ -903,14 +903,11 @@ describe("ChatInputBar", () => {
|
||||
expect(promptInput?.parentElement?.className).toContain("items-start");
|
||||
expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true);
|
||||
expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true);
|
||||
expect(promptInput?.parentElement?.contains(speechTrigger)).toBe(true);
|
||||
// The mic button is hidden for now (SHOW_VOICE_INPUT_BUTTON).
|
||||
expect(speechTrigger).toBeNull();
|
||||
expect(sendTrigger?.parentElement?.className).toContain("self-end");
|
||||
expect(rightControls?.contains(sendTrigger)).toBe(false);
|
||||
expect(rightControls?.contains(speechTrigger ?? null)).toBe(false);
|
||||
expect(speechTrigger?.parentElement?.nextElementSibling).toBe(sendTrigger);
|
||||
expect(leftControls?.parentElement).toBe(rightControls?.parentElement);
|
||||
await act(async () => speechTrigger?.click());
|
||||
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
} from "@/lib/model-selection";
|
||||
import { subscribeToPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import {
|
||||
loadProviderModelCatalog,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type { ProviderModel } from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
|
||||
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
|
||||
@@ -344,7 +346,6 @@ function ChatInputBarImpl({
|
||||
onSteerPromptInQueue,
|
||||
onEditPromptInQueue,
|
||||
onRemovePromptInQueue,
|
||||
onOpenVoiceInputSettings,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -362,6 +363,15 @@ function ChatInputBarImpl({
|
||||
const latestDraftVersionRef = useRef(promptDraft.version);
|
||||
latestDraftVersionRef.current = promptDraft.version;
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// The sidebar's "New" action asks the prompt input to grab focus through a
|
||||
// window event (see lib/prompt-input-focus.ts).
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToPromptInputFocus(() => {
|
||||
promptInputRef.current?.focus();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const batchTranscriptSessionRef = useRef<{
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -1293,44 +1303,34 @@ function ChatInputBarImpl({
|
||||
<CircleStop className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<SpeechInput
|
||||
allowUnavailableClick={!transcriptionTarget}
|
||||
key={
|
||||
transcriptionTarget
|
||||
? `${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`
|
||||
: "unconfigured"
|
||||
}
|
||||
onActiveChange={handleSpeechInputActiveChange}
|
||||
onAudioRecorded={handleAudioRecorded}
|
||||
onClick={(event) => {
|
||||
if (!transcriptionTarget) {
|
||||
event.preventDefault();
|
||||
onOpenVoiceInputSettings?.();
|
||||
{/* The mic button only appears once a voice model is
|
||||
configured in Settings → Voice; unconfigured users
|
||||
don't get a dead control. */}
|
||||
{transcriptionTarget ? (
|
||||
<SpeechInput
|
||||
key={`${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`}
|
||||
onActiveChange={handleSpeechInputActiveChange}
|
||||
onAudioRecorded={handleAudioRecorded}
|
||||
onError={handleSpeechInputError}
|
||||
onProcessingChange={setSpeechInputProcessing}
|
||||
onStartStreaming={
|
||||
transcriptionTarget.supportsStreaming
|
||||
? handleStartStreamingTranscription
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
onError={handleSpeechInputError}
|
||||
onProcessingChange={setSpeechInputProcessing}
|
||||
onStartStreaming={
|
||||
transcriptionTarget?.supportsStreaming
|
||||
? handleStartStreamingTranscription
|
||||
: undefined
|
||||
}
|
||||
onStreamingEnd={handleStreamingTranscriptionEnd}
|
||||
onStreamingStart={handleStreamingTranscriptionStart}
|
||||
onTranscriptionChange={
|
||||
transcriptionTarget?.supportsStreaming
|
||||
? undefined
|
||||
: handleTranscriptionChange
|
||||
}
|
||||
recordingMode={
|
||||
transcriptionTarget?.supportsStreaming ? "streaming" : "auto"
|
||||
}
|
||||
title={
|
||||
transcriptionTarget
|
||||
? `${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`
|
||||
: "Configure voice input in Settings → Models"
|
||||
}
|
||||
/>
|
||||
onStreamingEnd={handleStreamingTranscriptionEnd}
|
||||
onStreamingStart={handleStreamingTranscriptionStart}
|
||||
onTranscriptionChange={
|
||||
transcriptionTarget.supportsStreaming
|
||||
? undefined
|
||||
: handleTranscriptionChange
|
||||
}
|
||||
recordingMode={
|
||||
transcriptionTarget.supportsStreaming ? "streaming" : "auto"
|
||||
}
|
||||
title={`${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`}
|
||||
/>
|
||||
) : null}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
}));
|
||||
// The Agenda UI ships hidden for now; these tests force the flag on so they
|
||||
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
|
||||
// shipped (hidden) state.
|
||||
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
|
||||
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SessionContent } from "./session-content";
|
||||
import { WelcomeHero } from "./welcome-hero";
|
||||
@@ -49,7 +50,7 @@ export function WelcomeScreen({
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
limit: 8,
|
||||
},
|
||||
active && workspaceRoot.trim().length > 0,
|
||||
AGENDA_UI_ENABLED && active && workspaceRoot.trim().length > 0,
|
||||
);
|
||||
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
|
||||
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
|
||||
@@ -168,7 +169,7 @@ export function WelcomeScreen({
|
||||
{active ? composer : <SessionContent>{composer}</SessionContent>}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
{active && AGENDA_UI_ENABLED ? (
|
||||
<>
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
|
||||
@@ -1007,22 +1007,17 @@ export function MarketplaceView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{catalogEntries.length}
|
||||
</span>
|
||||
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
className="shrink-0 self-start md:self-auto"
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -1219,7 +1214,7 @@ export function MarketplaceView({
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title={variant === "directory" ? undefined : "Marketplace"}
|
||||
title={variant === "directory" ? undefined : "Browse"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -152,19 +152,19 @@ describe("SessionsView table", () => {
|
||||
expect(row?.parentElement?.className).not.toContain("min-h-14");
|
||||
});
|
||||
|
||||
it("marks favorited sessions with a star", async () => {
|
||||
it("marks pinned sessions with a pin icon", async () => {
|
||||
const plain = renderView();
|
||||
await plain.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Pinned"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
const favorited = renderView({
|
||||
const pinned = renderView({
|
||||
threads: [{ ...thread, pinned: true }],
|
||||
});
|
||||
await favorited.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
|
||||
await pinned.render();
|
||||
expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens a session on click but not while text is selected", async () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
Search,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -131,7 +131,7 @@ function sessionFilterDetails(
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
thread.pinned ? "favorite:yes" : undefined,
|
||||
thread.pinned ? "pinned:yes" : undefined,
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
@@ -572,8 +572,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.pinned ? (
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
<Pin
|
||||
aria-label="Pinned"
|
||||
className="size-3.5 shrink-0 fill-current text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
@@ -626,13 +626,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
<Pin
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
{thread.pinned ? "Unpin" : "Pin"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
const DASHBOARD_URL = "https://app.cline.bot/dashboard";
|
||||
const USAGE_DASHBOARD_URL = "https://app.cline.bot/dashboard/usage";
|
||||
@@ -554,12 +554,11 @@ export function AccountView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold text-foreground">Account</h2>
|
||||
{user && (
|
||||
<PageFrame contentClassName="max-w-3xl">
|
||||
<PageHeader
|
||||
title="Account"
|
||||
actions={
|
||||
user ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={accountActionPending !== null}
|
||||
@@ -567,284 +566,284 @@ export function AccountView() {
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground disabled:opacity-60"
|
||||
>
|
||||
{accountActionPending === "sign-out" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
<LogOut className="size-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-out" ? "Signing Out" : "Sign Out"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{!overviewLoading && signedOut && renderSignedOut()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !signedOut && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{!overviewLoading && signedOut && renderSignedOut()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !signedOut && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-(--accent-a3) text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(
|
||||
activeOrganization
|
||||
? ORGANIZATION_CREDITS_URL
|
||||
: USER_CREDITS_URL,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void openExternalUrl(CREATE_ORGANIZATION_URL)
|
||||
void openExternalUrl(
|
||||
activeOrganization
|
||||
? ORGANIZATION_CREDITS_URL
|
||||
: USER_CREDITS_URL,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground "
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
Credit
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{renderAccountRow({
|
||||
key: "personal",
|
||||
name: "Personal",
|
||||
subtitle: user.email ?? "Personal account",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
active: !activeOrganization,
|
||||
switching: switchTargetId === "",
|
||||
onSelect: () => void switchAccount(null),
|
||||
})}
|
||||
{organizations.map((org) =>
|
||||
renderAccountRow({
|
||||
key: org.organizationId,
|
||||
name: org.name,
|
||||
subtitle: org.roles.join(", "),
|
||||
icon: org.name.charAt(0),
|
||||
active: org.active,
|
||||
switching: switchTargetId === org.organizationId,
|
||||
onSelect: () => void switchAccount(org.organizationId),
|
||||
}),
|
||||
)}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading && !usageError && usageLoaded && (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
{usageTransactions.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Organizations */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
onClick={() =>
|
||||
void openExternalUrl(CREATE_ORGANIZATION_URL)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface-hover hover:text-foreground "
|
||||
>
|
||||
See More
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{renderAccountRow({
|
||||
key: "personal",
|
||||
name: "Personal",
|
||||
subtitle: user.email ?? "Personal account",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
active: !activeOrganization,
|
||||
switching: switchTargetId === "",
|
||||
onSelect: () => void switchAccount(null),
|
||||
})}
|
||||
{organizations.map((org) =>
|
||||
renderAccountRow({
|
||||
key: org.organizationId,
|
||||
name: org.name,
|
||||
subtitle: org.roles.join(", "),
|
||||
icon: org.name.charAt(0),
|
||||
active: org.active,
|
||||
switching: switchTargetId === org.organizationId,
|
||||
onSelect: () => void switchAccount(org.organizationId),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading && !usageError && usageLoaded && (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
{usageTransactions.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_5.5rem_4.5rem_5.5rem] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="flex justify-center border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(USAGE_DASHBOARD_URL)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
See More
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,10 +53,13 @@ interface NewProviderForm {
|
||||
}
|
||||
|
||||
export function AddProviderContent({
|
||||
variant = "page",
|
||||
onBack,
|
||||
onSave,
|
||||
existingProviderIds,
|
||||
}: {
|
||||
/** "dialog" renders only the form body for use inside a Dialog. */
|
||||
variant?: "page" | "dialog";
|
||||
onBack: () => void;
|
||||
onSave: (payload: AddProviderPayload) => Promise<void>;
|
||||
existingProviderIds: string[];
|
||||
@@ -180,6 +183,290 @@ export function AddProviderContent({
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<ModelIdInput models={form.models} onChange={updateModels} />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium",
|
||||
canSave && !saving
|
||||
? "bg-primary hover:bg-primary/90"
|
||||
: "bg-muted cursor-not-allowed text-foreground",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "dialog") {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame contentClassName="max-w-4xl">
|
||||
<PageHeader
|
||||
@@ -192,289 +479,12 @@ export function AddProviderContent({
|
||||
className="rounded-md p-1.5"
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="size-4" />
|
||||
Providers
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<ModelIdInput models={form.models} onChange={updateModels} />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium",
|
||||
canSave && !saving
|
||||
? "bg-primary hover:bg-primary/90"
|
||||
: "bg-muted cursor-not-allowed text-foreground",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
+56
-40
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Store } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
@@ -10,25 +9,33 @@ import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
* Unified Plugins hub: one page for everything installed (plugins, MCP
|
||||
* servers, skills) with sub-tabs and live counts. The "Browse Marketplace"
|
||||
* button navigates to the dedicated Marketplace settings page.
|
||||
* Unified Customize hub: one page for everything that extends Cline —
|
||||
* skills, MCP servers, plugins, rules, hooks, and tools — as sub-tabs with
|
||||
* live counts. Tabs with a marketplace catalog (skills, MCP, plugins) show
|
||||
* installed items followed by an inline browsable marketplace section, so
|
||||
* there is no separate Marketplace page.
|
||||
*/
|
||||
|
||||
type PluginsHubTab = "plugins" | "mcp" | "skills";
|
||||
type CustomizeTab = "skills" | "mcp" | "plugins" | "rules" | "hooks" | "tools";
|
||||
|
||||
const HUB_TABS: { id: PluginsHubTab; label: string }[] = [
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
const CUSTOMIZE_TABS: { id: CustomizeTab; label: string }[] = [
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
{ id: "rules", label: "Rules" },
|
||||
{ id: "hooks", label: "Hooks" },
|
||||
{ id: "tools", label: "Tools" },
|
||||
];
|
||||
|
||||
type HubCounts = Partial<Record<PluginsHubTab, number>>;
|
||||
type TabCounts = Partial<Record<CustomizeTab, number>>;
|
||||
|
||||
type HubInventoryResponse = {
|
||||
plugins?: unknown[];
|
||||
skills?: unknown[];
|
||||
workflows?: unknown[];
|
||||
rules?: unknown[];
|
||||
hooks?: unknown[];
|
||||
tools?: unknown[];
|
||||
mcp?: { servers?: unknown[] };
|
||||
};
|
||||
|
||||
@@ -36,13 +43,9 @@ function asCount(value: unknown): number {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
export function PluginsHubView({
|
||||
onOpenMarketplace,
|
||||
}: {
|
||||
onOpenMarketplace?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<PluginsHubTab>("plugins");
|
||||
const [counts, setCounts] = useState<HubCounts>({});
|
||||
export function CustomizeView() {
|
||||
const [tab, setTab] = useState<CustomizeTab>("skills");
|
||||
const [counts, setCounts] = useState<TabCounts>({});
|
||||
|
||||
const refreshCounts = useCallback(async () => {
|
||||
const inventory = await desktopClient
|
||||
@@ -52,9 +55,12 @@ export function PluginsHubView({
|
||||
return;
|
||||
}
|
||||
setCounts({
|
||||
plugins: asCount(inventory.plugins),
|
||||
skills: asCount(inventory.skills) + asCount(inventory.workflows),
|
||||
mcp: asCount(inventory.mcp?.servers),
|
||||
plugins: asCount(inventory.plugins),
|
||||
rules: asCount(inventory.rules),
|
||||
hooks: asCount(inventory.hooks),
|
||||
tools: asCount(inventory.tools),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -72,22 +78,14 @@ export function PluginsHubView({
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage installed plugins, MCP servers, and skills. Browse the marketplace to install more."
|
||||
title="Plugins"
|
||||
actions={
|
||||
onOpenMarketplace ? (
|
||||
<Button onClick={onOpenMarketplace} type="button" variant="outline">
|
||||
<Store className="size-4" />
|
||||
Browse Marketplace
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
description="Extend what Cline can do and change how it works. Manage what's installed and browse the marketplace for more options."
|
||||
title="Customize"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{HUB_TABS.map((hubTab) => {
|
||||
const count = counts[hubTab.id];
|
||||
const active = tab === hubTab.id;
|
||||
{CUSTOMIZE_TABS.map((customizeTab) => {
|
||||
const count = counts[customizeTab.id];
|
||||
const active = tab === customizeTab.id;
|
||||
return (
|
||||
<Button
|
||||
aria-current={active ? "page" : undefined}
|
||||
@@ -97,12 +95,12 @@ export function PluginsHubView({
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={hubTab.id}
|
||||
onClick={() => setTab(hubTab.id)}
|
||||
key={customizeTab.id}
|
||||
onClick={() => setTab(customizeTab.id)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{hubTab.label}
|
||||
{customizeTab.label}
|
||||
{typeof count === "number" ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -123,26 +121,44 @@ export function PluginsHubView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === "plugins" ? (
|
||||
{tab === "skills" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
marketplaceVariant="full"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
section="Skills"
|
||||
/>
|
||||
) : tab === "mcp" ? (
|
||||
<McpServersContent
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
/>
|
||||
) : tab === "plugins" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="full"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
/>
|
||||
) : tab === "rules" ? (
|
||||
<CustomizationSectionView
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Rules"
|
||||
/>
|
||||
) : tab === "hooks" ? (
|
||||
<CustomizationSectionView
|
||||
chrome="embedded"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Hooks"
|
||||
/>
|
||||
) : (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Skills"
|
||||
section="Tools"
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
@@ -907,25 +908,23 @@ export function CustomizationSectionView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderLocalActionRow = (target: LocalUninstallTarget) => {
|
||||
// Sized and styled to match the Install/Uninstall button on marketplace
|
||||
// entry cards so installed rows and browse rows read as one list.
|
||||
const renderLocalActionButton = (target: LocalUninstallTarget) => {
|
||||
const uninstalling = localUninstallingKeys.has(target.key);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-h-5 text-xs text-muted-foreground">
|
||||
{renderLocalActionMessage(target.key)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={uninstalling}
|
||||
onClick={() => {
|
||||
void uninstallLocalPrimitive(target);
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={uninstalling}
|
||||
onClick={() => {
|
||||
void uninstallLocalPrimitive(target);
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -973,14 +972,26 @@ export function CustomizationSectionView({
|
||||
) => {
|
||||
const key = `${item.type}:${item.path}`;
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-border px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
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>
|
||||
<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" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{item.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={item.scope} />
|
||||
@@ -993,26 +1004,16 @@ export function CustomizationSectionView({
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{item.description?.trim() || previewText(item.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{item.path}
|
||||
</p>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<div className="mt-2 ml-7">
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
</div>
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
{renderLocalActionMessage(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1049,12 +1050,10 @@ export function CustomizationSectionView({
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
return (
|
||||
<details
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<details key={plugin.path} className="rounded-lg border bg-card p-4">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={scope} />
|
||||
@@ -1142,11 +1141,19 @@ export function CustomizationSectionView({
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: "mcp",
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
<Server className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<ScopeBadge scope="Global" />
|
||||
@@ -1158,11 +1165,13 @@ export function CustomizationSectionView({
|
||||
Marketplace
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{server.disabled ? "Disabled" : "Enabled"}
|
||||
</span>
|
||||
{server.disabled ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Disabled
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{server.url ??
|
||||
([server.command, ...(server.args ?? [])]
|
||||
.filter(Boolean)
|
||||
@@ -1170,23 +1179,14 @@ export function CustomizationSectionView({
|
||||
"No launch command configured.")}
|
||||
</p>
|
||||
{mcp.settingsPath ? (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{mcp.settingsPath}
|
||||
</p>
|
||||
) : null}
|
||||
{context?.matchedEntries?.length ? (
|
||||
<div className="mt-2 ml-7">
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
</div>
|
||||
<MarketplaceEntrySetupDetails entries={context.matchedEntries} />
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
key,
|
||||
type: "mcp",
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
})}
|
||||
</div>
|
||||
{renderLocalActionMessage(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1313,11 +1313,6 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Rules" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Enabled rules discovered from configured workspace/global
|
||||
directories.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
@@ -1331,19 +1326,19 @@ export function CustomizationSectionView({
|
||||
{scopedRules.map(({ rule, scope }) => (
|
||||
<div
|
||||
key={rule.path}
|
||||
className="rounded-lg border border-border px-4 py-3"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-medium text-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</span>
|
||||
<ScopeBadge scope={scope} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{previewText(rule.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{rule.path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1360,9 +1355,6 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Hooks" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Hook config files from workspace and global hook directories.
|
||||
</p>
|
||||
{hookExecutionLoading && hookExecutionSessionId && (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Execution status is based on hook events in session{" "}
|
||||
@@ -1383,43 +1375,47 @@ export function CustomizationSectionView({
|
||||
{scopedHooks.map(({ hook, scope }) => (
|
||||
<div
|
||||
key={hook.path}
|
||||
className="rounded-lg border border-border px-4 py-3"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-mono text-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Code className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{hook.fileName}
|
||||
</span>
|
||||
<ScopeBadge scope={scope} />
|
||||
{hook.hookEventName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{hook.hookEventName}
|
||||
</span>
|
||||
</Badge>
|
||||
{(() => {
|
||||
const stats =
|
||||
hookExecutionByEvent[hook.hookEventName];
|
||||
const executed = (stats?.count ?? 0) > 0;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded border px-2 py-0.5 text-xs",
|
||||
"shrink-0",
|
||||
executed
|
||||
? "border-emerald-400/50 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-border text-muted-foreground",
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{executed
|
||||
? `${stats?.count ?? 0} executed`
|
||||
: "never executed"}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hook.hookEventName ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Last run:{" "}
|
||||
{formatExecutionTs(
|
||||
hookExecutionByEvent[hook.hookEventName]?.lastTs ??
|
||||
@@ -1427,7 +1423,7 @@ export function CustomizationSectionView({
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{hook.path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1659,27 +1655,27 @@ export function CustomizationSectionView({
|
||||
|
||||
{activeTab === "Tools" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Builtin tool groups and plugin-contributed tools available to the
|
||||
runtime.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Builtin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="mb-6 grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Builtin Tools
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{builtinTools.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{builtinTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -1694,12 +1690,12 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{!!tool.headlessToolNames?.length && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.headlessToolNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
@@ -1715,28 +1711,36 @@ export function CustomizationSectionView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Plugin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Plugin Tools
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluginTools.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pluginTools.map((tool) =>
|
||||
(() => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
className="grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
{tool.pluginName && (
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
plugin: {tool.pluginName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{tool.pluginName}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
@@ -1750,12 +1754,12 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
{tool.path && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
<p className="truncate text-xs font-mono text-muted-foreground">
|
||||
{tool.path}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -623,9 +624,9 @@ export function McpServersContent({
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="group relative rounded-lg border border-border px-5 py-4 hover:bg-surface-hover"
|
||||
className="group relative rounded-lg border bg-card p-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
@@ -634,17 +635,17 @@ export function McpServersContent({
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{TRANSPORT_TYPE_LABELS[server.transportType] ??
|
||||
server.transportType}
|
||||
</span>
|
||||
</Badge>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
{renderServerToggle(server)}
|
||||
@@ -813,7 +814,7 @@ export function McpServersContent({
|
||||
installedItems={installedItems}
|
||||
onInstalledItemsChanged={() => refreshServers()}
|
||||
primitive="mcp"
|
||||
variant={chrome === "embedded" ? "installed" : "full"}
|
||||
variant="full"
|
||||
/>
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
|
||||
+9
-9
@@ -83,11 +83,7 @@ export function NotificationSettings() {
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
||||
Allowed by system
|
||||
</span>
|
||||
) : permission === "unsupported" ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
Available in the desktop app
|
||||
</span>
|
||||
) : permission === null ? (
|
||||
) : permission === "unsupported" ? null : permission === null ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Checking…</span>
|
||||
) : (
|
||||
<Button
|
||||
@@ -101,9 +97,13 @@ export function NotificationSettings() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
// One settings section: a top-level header row like the other General
|
||||
// settings, with the per-event matrix nested in a card so its rows read
|
||||
// as children of "Desktop notifications" rather than as siblings of
|
||||
// top-level settings like Dark mode.
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="border-b py-4">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Desktop notifications
|
||||
@@ -120,7 +120,7 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
{permissionControl}
|
||||
</div>
|
||||
<div className="border-b">
|
||||
<div className="mt-4 rounded-lg border bg-card px-4">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_5rem_4rem] items-center gap-3 border-b py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Event</span>
|
||||
<span className="text-center">Notify</span>
|
||||
@@ -165,6 +165,6 @@ export function NotificationSettings() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+219
-100
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Provider, VoiceInputSelection } from "@/lib/provider-schema";
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
@@ -107,57 +107,40 @@ describe("ProviderDetailContent models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const voiceProviders: Provider[] = [
|
||||
const catalogProviders: Provider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: 4,
|
||||
color: "#000000",
|
||||
letter: "AN",
|
||||
enabled: true,
|
||||
apiKey: "sk-test",
|
||||
capabilities: ["popular"],
|
||||
modelList: [],
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 12,
|
||||
color: "#000000",
|
||||
letter: "CL",
|
||||
enabled: false,
|
||||
capabilities: ["oauth", "popular"],
|
||||
modelList: [],
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
models: 1,
|
||||
color: "#000000",
|
||||
letter: "EL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "scribe_v2",
|
||||
name: "Scribe v2",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
models: 3,
|
||||
color: "#000000",
|
||||
letter: "GR",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "whisper-large-v3",
|
||||
name: "Whisper Large v3",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "whisper-large-v3-turbo",
|
||||
name: "Whisper Large v3 Turbo",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "llama-chat",
|
||||
name: "Llama Chat",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
enabled: false,
|
||||
modelList: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProviderListContent voice input settings", () => {
|
||||
describe("ProviderListContent", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -174,74 +157,210 @@ describe("ProviderListContent voice input settings", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lets the user choose and clear the voice provider and model", async () => {
|
||||
const onVoiceInputChange = vi.fn();
|
||||
let selection: VoiceInputSelection | undefined = {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "scribe_v2",
|
||||
};
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={onVoiceInputChange}
|
||||
providers={voiceProviders}
|
||||
voiceInput={selection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const providerSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
);
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
);
|
||||
expect(providerSelect?.value).toBe("elevenlabs");
|
||||
expect(modelSelect?.value).toBe("scribe_v2");
|
||||
|
||||
it("groups providers into Configured, Popular, and All providers", async () => {
|
||||
const onConfigure = vi.fn();
|
||||
await act(async () => {
|
||||
if (!providerSelect) return;
|
||||
providerSelect.value = "groq";
|
||||
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={onConfigure}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
selection = {
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
};
|
||||
await render();
|
||||
const groqModelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
const headings = Array.from(container.querySelectorAll("h2")).map(
|
||||
(heading) => heading.textContent,
|
||||
);
|
||||
expect(headings).toEqual(["Configured", "Popular", "All providers"]);
|
||||
// No per-provider enable toggles anymore.
|
||||
expect(container.querySelector('[role="switch"]')).toBeNull();
|
||||
expect(container.textContent).toContain("1 configured");
|
||||
|
||||
const rows = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.includes("Anthropic"),
|
||||
);
|
||||
await act(async () => rows[0]?.click());
|
||||
expect(onConfigure).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("shows connection status and auth kind per row", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rowFor = (name: string) =>
|
||||
Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes(name),
|
||||
);
|
||||
expect(rowFor("Anthropic")?.textContent).toContain("Configured");
|
||||
expect(rowFor("Cline")?.textContent).toContain("Sign in");
|
||||
expect(rowFor("ElevenLabs")?.textContent).toContain("API key");
|
||||
});
|
||||
|
||||
it("filters providers by search across every group", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
providers={catalogProviders}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Search model providers"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!groqModelSelect) return;
|
||||
groqModelSelect.value = "whisper-large-v3-turbo";
|
||||
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(search, "eleven");
|
||||
search?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3-turbo",
|
||||
expect(container.textContent).toContain("ElevenLabs");
|
||||
expect(container.textContent).not.toContain("Anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderDetailContent auth flows", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
// "cline" is a featured provider, so mounting its detail refreshes the
|
||||
// model list; resolve it to keep the auth assertions deterministic.
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const signInProvider: Provider = {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
models: 0,
|
||||
color: "#000",
|
||||
letter: "CL",
|
||||
enabled: false,
|
||||
capabilities: ["oauth"],
|
||||
configFields: [
|
||||
{
|
||||
path: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
modelList: [],
|
||||
};
|
||||
|
||||
it("shows browser sign-in instead of an API key field for OAuth providers", async () => {
|
||||
const onOAuthLogin = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onOAuthLogin={onOAuthLogin}
|
||||
onUpdate={vi.fn()}
|
||||
provider={signInProvider}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
expect(container.textContent).toContain("Sign in with browser");
|
||||
expect(container.textContent).toContain("Not configured");
|
||||
// The API key input stays collapsed until explicitly requested.
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
const signIn = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Sign in with browser"),
|
||||
);
|
||||
await act(async () => signIn?.click());
|
||||
expect(onOAuthLogin).toHaveBeenCalledOnce();
|
||||
|
||||
const manualKey = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Use an API key instead"),
|
||||
);
|
||||
await act(async () => manualKey?.click());
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers sign out when OAuth is connected", async () => {
|
||||
const onDisconnect = vi.fn();
|
||||
await act(async () => {
|
||||
if (!groqProviderSelect) return;
|
||||
groqProviderSelect.value = "";
|
||||
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onDisconnect={onDisconnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{
|
||||
...signInProvider,
|
||||
enabled: true,
|
||||
oauthAccessTokenPresent: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
expect(container.textContent).toContain("Signed in via browser");
|
||||
expect(container.textContent).toContain("Configured");
|
||||
const signOut = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Sign out",
|
||||
);
|
||||
await act(async () => signOut?.click());
|
||||
expect(onDisconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers connect and disconnect for API-key providers", async () => {
|
||||
const onConnect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onConnect={onConnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{ ...provider, enabled: false }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const connect = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Connect",
|
||||
);
|
||||
await act(async () => connect?.click());
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
const onDisconnect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onDisconnect={onDisconnect}
|
||||
onUpdate={vi.fn()}
|
||||
provider={{ ...provider, apiKey: "sk-test" }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const disconnect = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Disconnect",
|
||||
);
|
||||
await act(async () => disconnect?.click());
|
||||
expect(onDisconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+563
-377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import { BookOpen, Bug, Newspaper, ShieldAlert } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface RoutineTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
name: string;
|
||||
prompt: string;
|
||||
scheduleType: "daily" | "weekly";
|
||||
scheduleDays: string[];
|
||||
scheduleHour: string;
|
||||
scheduleMinute: string;
|
||||
}
|
||||
|
||||
const FIND_CRITICAL_BUGS_PROMPT = `You are an automated bug hunter that runs on a schedule. Dig through recent changes in this repository and catch severe correctness bugs before users hit them.
|
||||
|
||||
## Scope
|
||||
|
||||
Review the commits landed since your last run (roughly the past day). Only chase problems with serious consequences: data loss or corruption, crashes in critical paths, security regressions, races that drop writes, unbounded loops, resource leaks, and silent truncation of user data.
|
||||
|
||||
Explicitly skip style nits, theoretical edge cases with no realistic trigger, and minor issues that would merely degrade UX.
|
||||
|
||||
## How to investigate
|
||||
|
||||
- Read beyond the diff. Follow the caller chain and downstream consumers so you understand the real blast radius of each change instead of pattern-matching on the patch.
|
||||
- For every suspect, construct the concrete sequence of events that makes it misbehave. If you cannot describe a plausible trigger, drop the finding.
|
||||
|
||||
## When you find something
|
||||
|
||||
- Apply the smallest fix you are confident in, and add or update a test to lock in the behavior when practical. No drive-by refactors in the same change.
|
||||
- Before opening a PR, check whether an open PR already fixes the same bug. If one exists, note that it is awaiting review with a link instead of duplicating it. If a previous fix was closed without merging, do not re-open one unless the relevant code has materially changed.
|
||||
- Only open a PR when you are highly confident the bug is real and the fix is correct. If PRs are not available in this workspace, commit the fix to a branch and describe it in your summary.
|
||||
|
||||
## Wrap up
|
||||
|
||||
Finish with a short report: what you inspected, and for each fix the bug, its impact, the root cause, and how you validated the change. If nothing clears the bar, a plain "no critical bugs found" summary is the expected outcome most runs.`;
|
||||
|
||||
const SECURITY_SCAN_PROMPT = `You are a scheduled security reviewer for this repository. Find medium, high, or critical vulnerabilities with a genuine end-to-end attack path, not theoretical weaknesses.
|
||||
|
||||
## Where to look
|
||||
|
||||
- Authentication, session handling, and permission checks
|
||||
- Request handlers, RPC endpoints, webhook receivers, and other entry points
|
||||
- Raw SQL, shell execution, file-system access, and template rendering
|
||||
- Deserialization and parsing of untrusted input
|
||||
- Secrets handling and anything that logs sensitive values
|
||||
|
||||
## Validation bar
|
||||
|
||||
A finding only counts if you can walk the entire chain: who the attacker is, what input they control, how that input reaches the vulnerable code, and what impact they gain. Trace the code to confirm every step. Skip lint-level "unsafe API" observations that lack a real route in, and skip best-practice notes without concrete impact.
|
||||
|
||||
## Avoiding repeats
|
||||
|
||||
Keep a running log of past findings in a local notes file (for example \`security-findings.local.md\` in the workspace root, excluded from version control). Read it before scanning, do not re-report anything already listed, and append new validated findings after each run.
|
||||
|
||||
## Reporting
|
||||
|
||||
For each new validated finding, write up the severity, the affected file, the full attack path, and the highest-leverage remediation. Treat findings as sensitive: keep them in the local report, and do not open a PR or publish them elsewhere from this scan. If nothing new clears the bar, say so briefly and stop.`;
|
||||
|
||||
const DAILY_DIGEST_PROMPT = `You write a daily engineering digest for this repository.
|
||||
|
||||
## Task
|
||||
|
||||
Review everything that landed in the last 24 hours (commits and merged PRs) and distill it into a brief a teammate could read in under a minute.
|
||||
|
||||
## Cover
|
||||
|
||||
- Changes that matter: new behavior, user-facing impact, and notable bug fixes
|
||||
- Risky territory: large diffs, sensitive subsystems touched, migrations, dependency or security updates
|
||||
- Loose ends: missing tests, TODOs introduced, rollout risks, likely follow-ups
|
||||
|
||||
## Style
|
||||
|
||||
- Group related changes into themes instead of listing every commit.
|
||||
- Tie every claim to a concrete commit or PR; never guess at intent or invent details.
|
||||
- Prioritize signal over completeness, and keep the whole digest easy to skim. On quiet days a two-line digest is fine.
|
||||
|
||||
## Format
|
||||
|
||||
Start with the date range covered, then 3-7 bullets of meaningful changes, then a short "Worth watching" section with 1-3 risks or pending follow-ups.`;
|
||||
|
||||
const UPDATE_DOCS_PROMPT = `You are a documentation maintainer that runs on a schedule. Keep this repository's docs accurate as the code evolves.
|
||||
|
||||
## Task
|
||||
|
||||
Compare recent code changes against the existing documentation and close the gaps.
|
||||
|
||||
## Priorities
|
||||
|
||||
- Docs that recent changes made stale or wrong. Fix these first.
|
||||
- Recently touched subsystems with thin or missing coverage.
|
||||
- Public interfaces, developer setup and troubleshooting guides, and operational runbooks.
|
||||
|
||||
## Standards
|
||||
|
||||
- Verify every statement against the source code; never document behavior you have not confirmed.
|
||||
- Prefer updating existing pages over creating redundant new ones.
|
||||
- Explain intent and usage with concrete examples and constraints, and keep pages structured for scanning.
|
||||
- Match the style, tone, and location conventions of the docs already in this repository.
|
||||
|
||||
## Output
|
||||
|
||||
Open a focused, docs-only PR (or commit the updates to a branch if PRs are unavailable). Summarize which docs you added or updated, the code paths they now cover, and the knowledge gaps you closed. If everything is already accurate, report that and finish.`;
|
||||
|
||||
export const ROUTINE_TEMPLATES: RoutineTemplate[] = [
|
||||
{
|
||||
id: "find-critical-bugs",
|
||||
title: "Find critical bugs",
|
||||
description:
|
||||
"Sweep recent commits for high-severity bugs that slipped past review, and fix the ones with a concrete trigger.",
|
||||
icon: Bug,
|
||||
name: "Find critical bugs",
|
||||
prompt: FIND_CRITICAL_BUGS_PROMPT,
|
||||
scheduleType: "daily",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
|
||||
scheduleHour: "9",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "security-scan",
|
||||
title: "Scan for vulnerabilities",
|
||||
description:
|
||||
"Audit the codebase for exploitable security issues with a validated end-to-end attack path.",
|
||||
icon: ShieldAlert,
|
||||
name: "Security scan",
|
||||
prompt: SECURITY_SCAN_PROMPT,
|
||||
scheduleType: "weekly",
|
||||
scheduleDays: ["MON"],
|
||||
scheduleHour: "8",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "daily-digest",
|
||||
title: "Summarize changes daily",
|
||||
description:
|
||||
"Get a skimmable digest of everything that landed in the last 24 hours, plus risks worth watching.",
|
||||
icon: Newspaper,
|
||||
name: "Daily change digest",
|
||||
prompt: DAILY_DIGEST_PROMPT,
|
||||
scheduleType: "daily",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
|
||||
scheduleHour: "17",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
{
|
||||
id: "update-docs",
|
||||
title: "Keep docs updated",
|
||||
description:
|
||||
"Refresh documentation whenever the code drifts away from it, verified against the source.",
|
||||
icon: BookOpen,
|
||||
name: "Update docs",
|
||||
prompt: UPDATE_DOCS_PROMPT,
|
||||
scheduleType: "weekly",
|
||||
scheduleDays: ["FRI"],
|
||||
scheduleHour: "10",
|
||||
scheduleMinute: "0",
|
||||
},
|
||||
];
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
import { ROUTINE_TEMPLATES, type RoutineTemplate } from "./routine-templates";
|
||||
|
||||
type DateTimeValue = number | string;
|
||||
|
||||
@@ -868,7 +869,7 @@ export function RoutineSchedulesContent({
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateDialog = async () => {
|
||||
const openCreateDialog = async (template?: RoutineTemplate) => {
|
||||
setEditingSchedule(null);
|
||||
setErrorMessage(null);
|
||||
setCreateFormError(null);
|
||||
@@ -893,13 +894,20 @@ export function RoutineSchedulesContent({
|
||||
? rememberedModel
|
||||
: (modelsForProvider[0] ?? createForm.model);
|
||||
setCreateForm({
|
||||
name: "",
|
||||
scheduleType: "once",
|
||||
name: template?.name ?? "",
|
||||
scheduleType: template?.scheduleType ?? "once",
|
||||
scheduleDate: defaultScheduleDate(),
|
||||
scheduleHour: "9",
|
||||
scheduleMinute: "0",
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
scheduleHour: template?.scheduleHour ?? "9",
|
||||
scheduleMinute: template?.scheduleMinute ?? "0",
|
||||
scheduleDays: template?.scheduleDays ?? [
|
||||
"MON",
|
||||
"TUE",
|
||||
"WED",
|
||||
"THU",
|
||||
"FRI",
|
||||
],
|
||||
prompt:
|
||||
template?.prompt ?? "Review PRs opened yesterday and summarize issues.",
|
||||
provider: preferredProvider,
|
||||
model: preferredModel,
|
||||
workspaceRoot: context.workspaceRoot || context.cwd,
|
||||
@@ -1075,6 +1083,16 @@ export function RoutineSchedulesContent({
|
||||
[schedules],
|
||||
);
|
||||
|
||||
// Hide suggestions the user has already created (matched by schedule name).
|
||||
const visibleTemplates = useMemo(() => {
|
||||
const existingNames = new Set(
|
||||
schedules.map((schedule) => schedule.name.trim().toLowerCase()),
|
||||
);
|
||||
return ROUTINE_TEMPLATES.filter(
|
||||
(template) => !existingNames.has(template.name.trim().toLowerCase()),
|
||||
);
|
||||
}, [schedules]);
|
||||
|
||||
const viewingExecutions = useMemo(() => {
|
||||
if (!viewingSchedule) {
|
||||
return [];
|
||||
@@ -1097,8 +1115,8 @@ export function RoutineSchedulesContent({
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Scheduled jobs are run through the hub."
|
||||
title="Schedules"
|
||||
description="Run agents on cron schedules for recurring automations like daily summaries and code reviews."
|
||||
title="Schedule"
|
||||
meta={<CommandBadge>cline schedule</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
@@ -1130,8 +1148,8 @@ export function RoutineSchedulesContent({
|
||||
<PageEmptyState>Loading schedules...</PageEmptyState>
|
||||
) : sortedSchedules.length === 0 ? (
|
||||
<PageEmptyState>
|
||||
No schedules found. Create a schedule to run routines on a recurring
|
||||
basis.
|
||||
No schedules yet. Start from a suggestion below, or create your own
|
||||
with New Schedule.
|
||||
</PageEmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1343,6 +1361,45 @@ export function RoutineSchedulesContent({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && visibleTemplates.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
Suggested
|
||||
</h2>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{visibleTemplates.map((template) => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<button
|
||||
className="group flex items-start gap-3 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter"
|
||||
key={template.id}
|
||||
onClick={() => void openCreateDialog(template)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors group-hover:text-primary">
|
||||
<Icon className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{template.title}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{template.scheduleType === "daily" ? "Daily" : "Weekly"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<Dialog
|
||||
open={Boolean(viewingSchedule)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -5,26 +5,32 @@
|
||||
* initial chat bundle. The heavy views load on demand via next/dynamic.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SECTIONS = [
|
||||
const ALL_SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Models",
|
||||
"Voice",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group. Plugins is the
|
||||
// unified hub for installed plugins, MCP servers, and skills; Marketplace is
|
||||
// the full catalog page for installing more.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Marketplace",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
"Tools",
|
||||
] as const;
|
||||
// Customize is the unified hub for everything that extends Cline — skills,
|
||||
// MCP servers, plugins, rules, hooks, and tools — each as a sub-tab with
|
||||
// inline marketplace browsing where a catalog exists.
|
||||
const ALL_CUSTOMIZATION_SECTIONS = ["Customize"] as const;
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof SETTINGS_SECTIONS)[number]
|
||||
| (typeof CUSTOMIZATION_SECTIONS)[number];
|
||||
| (typeof ALL_SETTINGS_SECTIONS)[number]
|
||||
| (typeof ALL_CUSTOMIZATION_SECTIONS)[number];
|
||||
|
||||
// Temporarily hidden from the sidebar. The views and routes still exist —
|
||||
// remove a section from this set to surface it again.
|
||||
const HIDDEN_SECTIONS: ReadonlySet<SettingsSection> = new Set(["Channels"]);
|
||||
|
||||
export const SETTINGS_SECTIONS = ALL_SETTINGS_SECTIONS.filter(
|
||||
(section) => !HIDDEN_SECTIONS.has(section),
|
||||
);
|
||||
|
||||
export const CUSTOMIZATION_SECTIONS = ALL_CUSTOMIZATION_SECTIONS.filter(
|
||||
(section) => !HIDDEN_SECTIONS.has(section),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { isBetaVersion, productNameForVersion } from "@/lib/app-channel";
|
||||
@@ -25,6 +32,10 @@ import {
|
||||
} from "@/lib/app-icon";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { resetOnboarding } from "@/lib/onboarding";
|
||||
import {
|
||||
getProviderAuthKind,
|
||||
isProviderConnected,
|
||||
} from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
invalidateProviderCatalogCache,
|
||||
@@ -37,7 +48,6 @@ import type {
|
||||
ProviderCatalogResponse,
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
VoiceInputSelection,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubAccent,
|
||||
@@ -49,17 +59,12 @@ import {
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarketplaceView } from "../marketplace-view";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
import { CustomizeView } from "./customize-view";
|
||||
import { NotificationSettings } from "./notification-settings";
|
||||
import { PluginsHubView } from "./plugins-hub-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import type { SettingsSection } from "./sections";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
import { VoiceInputContent } from "./voice-input-view";
|
||||
|
||||
// Nav categories live in ./sections so the always-mounted sidebar can import
|
||||
// them without pulling this module graph into the initial bundle.
|
||||
@@ -88,7 +94,6 @@ let providerCatalogCache: {
|
||||
providers: Provider[];
|
||||
fetchedAt: number;
|
||||
} | null = null;
|
||||
let voiceInputCache: VoiceInputSelection | undefined;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Component
|
||||
@@ -126,10 +131,14 @@ export function SettingsView({
|
||||
null,
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
|
||||
() => voiceInputCache,
|
||||
);
|
||||
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
|
||||
// Bumped by every optimistic provider mutation and catalog load. An
|
||||
// in-flight catalog response is discarded when the generation moved on,
|
||||
// so an older disk snapshot can never overwrite a newer edit.
|
||||
const catalogGenerationRef = useRef(0);
|
||||
// Bumped when a failed save resyncs the catalog from disk; keys the
|
||||
// detail panel so its local field drafts remount from the reloaded
|
||||
// props instead of keeping unpersisted values.
|
||||
const [detailResetToken, setDetailResetToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Models") {
|
||||
@@ -155,35 +164,46 @@ export function SettingsView({
|
||||
[],
|
||||
);
|
||||
|
||||
const loadProviderCatalog = useCallback(async () => {
|
||||
/**
|
||||
* Loads the catalog into view state. Resolves to false when the response
|
||||
* was discarded because a newer mutation or load superseded it while in
|
||||
* flight (so an older disk snapshot never overwrites a newer edit);
|
||||
* callers needing an authoritative resync should retry on false.
|
||||
*/
|
||||
const loadProviderCatalog = useCallback(async (): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
providerCatalogCache &&
|
||||
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
|
||||
) {
|
||||
setProviders(providerCatalogCache.providers);
|
||||
setVoiceInput(voiceInputCache);
|
||||
setProvidersLoading(false);
|
||||
setProviderCatalogError(null);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const generation = ++catalogGenerationRef.current;
|
||||
setProvidersLoading(true);
|
||||
setProviderCatalogError(null);
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
|
||||
"list_provider_catalog",
|
||||
);
|
||||
if (generation !== catalogGenerationRef.current) {
|
||||
return false;
|
||||
}
|
||||
setProvidersWithCache(payload.providers);
|
||||
voiceInputCache = payload.voiceInput;
|
||||
setVoiceInput(payload.voiceInput);
|
||||
} catch (error) {
|
||||
if (generation !== catalogGenerationRef.current) {
|
||||
return false;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProviderCatalogError(message);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
return true;
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,6 +240,19 @@ export function SettingsView({
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to save provider settings for ${id}: ${message}`);
|
||||
// The optimistic list update no longer matches disk: resync from
|
||||
// the authoritative catalog. Retry when a concurrent edit
|
||||
// superseded the in-flight response (that edit performs no
|
||||
// reload of its own), then remount the detail panel so its
|
||||
// field drafts re-seed from the reloaded state — not before,
|
||||
// or they would re-capture the unpersisted optimistic values.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
providerCatalogCache = null;
|
||||
if (await loadProviderCatalog()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setDetailResetToken((token) => token + 1);
|
||||
return false;
|
||||
} finally {
|
||||
// Keep the shared short-lived catalog cache (composer model
|
||||
@@ -227,66 +260,63 @@ export function SettingsView({
|
||||
invalidateProviderCatalogCache();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[loadProviderCatalog],
|
||||
);
|
||||
|
||||
const toggleProvider = useCallback(
|
||||
const connectProvider = useCallback(
|
||||
(id: string) => {
|
||||
// Persist an (empty) settings entry so the provider is enabled with
|
||||
// whatever credentials it resolves at runtime (env vars, local CLI,
|
||||
// keyless endpoints).
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== id) {
|
||||
return p;
|
||||
}
|
||||
const nextEnabled = !p.enabled;
|
||||
const clearsVoiceInput =
|
||||
!nextEnabled && voiceInput?.providerId === id;
|
||||
void persistProviderSettings(id, { enabled: nextEnabled }).then(
|
||||
(saved) => {
|
||||
if (saved && clearsVoiceInput) {
|
||||
voiceInputCache = undefined;
|
||||
setVoiceInput(undefined);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
}
|
||||
},
|
||||
);
|
||||
return { ...p, enabled: nextEnabled };
|
||||
}),
|
||||
prev.map((p) => (p.id === id ? { ...p, enabled: true } : p)),
|
||||
);
|
||||
void persistProviderSettings(id, { enabled: true });
|
||||
},
|
||||
[persistProviderSettings, setProvidersWithCache, voiceInput],
|
||||
[persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const updateVoiceInput = useCallback(
|
||||
async (selection: VoiceInputSelection | undefined) => {
|
||||
setVoiceInputSaving(true);
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
voiceInput?: VoiceInputSelection;
|
||||
}>("save_voice_input_settings", {
|
||||
provider: selection?.providerId,
|
||||
model: selection?.modelId,
|
||||
});
|
||||
voiceInputCache = result.voiceInput;
|
||||
setVoiceInput(result.voiceInput);
|
||||
const disconnectProvider = useCallback(
|
||||
async (id: string) => {
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
enabled: false,
|
||||
apiKey: undefined,
|
||||
oauthAccessTokenPresent: false,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
const saved = await persistProviderSettings(id, { enabled: false });
|
||||
if (saved) {
|
||||
// Disconnecting removes the persisted entry (and the sidecar drops
|
||||
// a voice-input selection pointing at it); reload so the view and
|
||||
// the chat microphone reflect the real on-disk state.
|
||||
providerCatalogCache = null;
|
||||
notifyVoiceInputSettingsChanged();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to save voice input settings: ${message}`);
|
||||
} finally {
|
||||
setVoiceInputSaving(false);
|
||||
await loadProviderCatalog();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[loadProviderCatalog, persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
(id: string, updates: ProviderSettingsUpdate) => {
|
||||
// Saving settings creates the provider's persisted entry, which is
|
||||
// what "connected" means for keyless providers — reflect it locally.
|
||||
catalogGenerationRef.current++;
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
...updates,
|
||||
enabled: true,
|
||||
configValues: updates.configValues
|
||||
? {
|
||||
...(p.configValues ?? {}),
|
||||
@@ -359,12 +389,19 @@ export function SettingsView({
|
||||
[loadProviderModels],
|
||||
);
|
||||
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
// The detail panel is always open: with no explicit selection, default to
|
||||
// the first connected provider (the one in use), then the first provider.
|
||||
const effectiveSelectedProviderId =
|
||||
selectedProviderId ??
|
||||
providers.find(isProviderConnected)?.id ??
|
||||
providers[0]?.id ??
|
||||
null;
|
||||
const selectedProvider = effectiveSelectedProviderId
|
||||
? (providers.find((p) => p.id === effectiveSelectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const usesOAuth = (provider: Provider) =>
|
||||
provider.capabilities?.includes("oauth") ?? false;
|
||||
getProviderAuthKind(provider) === "oauth";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
@@ -405,14 +442,14 @@ export function SettingsView({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderId) {
|
||||
if (!effectiveSelectedProviderId) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
void loadProviderModels(effectiveSelectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderModels, selectedProviderId]);
|
||||
}, [loadProviderModels, effectiveSelectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection("Models");
|
||||
@@ -444,17 +481,36 @@ export function SettingsView({
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
const addProviderDialog = (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAddingProvider(false);
|
||||
}
|
||||
}}
|
||||
open={addingProvider}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add an OpenAI-compatible provider and choose its available models.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={() => setAddingProvider(false)}
|
||||
onSave={saveNewProvider}
|
||||
variant="dialog"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const providerContent = providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</div>
|
||||
@@ -466,23 +522,27 @@ export function SettingsView({
|
||||
</div>
|
||||
) : selectedProvider ? (
|
||||
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
/>
|
||||
{/* min-h-0/min-w-0: grid items default to min-size auto, which lets
|
||||
the pane grow past its track and leaves the inner ScrollArea with
|
||||
nothing to scroll. */}
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
/>
|
||||
</div>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
key={`${selectedProvider.id}:${detailResetToken}`}
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onConnect={() => connectProvider(selectedProvider.id)}
|
||||
onDisconnect={() => void disconnectProvider(selectedProvider.id)}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onUpdateModels={(models) =>
|
||||
void updateProviderModels(selectedProvider.id, models)
|
||||
@@ -502,34 +562,22 @@ export function SettingsView({
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
providers={providers}
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
/>
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "Plugins" ? (
|
||||
<PluginsHubView
|
||||
onOpenMarketplace={() => onNavigateSection("Marketplace")}
|
||||
<>
|
||||
{providerContent}
|
||||
{addProviderDialog}
|
||||
</>
|
||||
) : activeNav === "Voice" ? (
|
||||
<VoiceInputContent
|
||||
onOpenModelProviders={() => onNavigateSection("Models")}
|
||||
/>
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<MarketplaceView
|
||||
onInstalledItemsChanged={invalidateExtensionInventoryCache}
|
||||
variant="directory"
|
||||
/>
|
||||
) : activeNav === "Hooks" ? (
|
||||
<CustomizationSectionView section="Hooks" />
|
||||
) : activeNav === "Rules" ? (
|
||||
<CustomizationSectionView section="Rules" />
|
||||
) : activeNav === "Agents" ? (
|
||||
<CustomizationSectionView section="Agents" />
|
||||
) : activeNav === "Tools" ? (
|
||||
<CustomizationSectionView section="Tools" />
|
||||
) : activeNav === "Customize" ? (
|
||||
<CustomizeView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// @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 type { Provider } from "@/lib/provider-schema";
|
||||
import { defaultTranscriptionModel, VoiceInputContent } from "./voice-input-view";
|
||||
|
||||
const { fetchProviderCatalogMock, invokeMock, notifyMock } = vi.hoisted(() => ({
|
||||
fetchProviderCatalogMock: vi.fn(),
|
||||
invokeMock: vi.fn(),
|
||||
notifyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-model-catalog", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/provider-model-catalog")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchProviderCatalog: fetchProviderCatalogMock,
|
||||
notifyVoiceInputSettingsChanged: notifyMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke: invokeMock },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const transcriptionProvider: Provider = {
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
models: 2,
|
||||
color: "#000000",
|
||||
letter: "EL",
|
||||
enabled: true,
|
||||
apiKey: "sk-test",
|
||||
modelList: [
|
||||
{
|
||||
id: "scribe_v1",
|
||||
name: "Scribe v1",
|
||||
operation: "transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "scribe_v2_realtime",
|
||||
name: "Scribe v2 Realtime",
|
||||
operation: "transcription",
|
||||
operationModes: ["streaming"],
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const unconnectedProvider: Provider = {
|
||||
...transcriptionProvider,
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
enabled: false,
|
||||
apiKey: undefined,
|
||||
};
|
||||
|
||||
describe("defaultTranscriptionModel", () => {
|
||||
it("prefers streaming models, then the first transcription model", () => {
|
||||
expect(
|
||||
defaultTranscriptionModel(transcriptionProvider.modelList ?? [])?.id,
|
||||
).toBe("scribe_v2_realtime");
|
||||
expect(
|
||||
defaultTranscriptionModel([
|
||||
{ id: "batch-only", name: "Batch", operation: "transcription" },
|
||||
])?.id,
|
||||
).toBe("batch-only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceInputContent", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
fetchProviderCatalogMock.mockReset();
|
||||
invokeMock.mockReset();
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (onOpenModelProviders = vi.fn()) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<VoiceInputContent onOpenModelProviders={onOpenModelProviders} />,
|
||||
);
|
||||
});
|
||||
return onOpenModelProviders;
|
||||
};
|
||||
|
||||
it("locks the page until a voice-capable provider is connected", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [unconnectedProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
const onOpenModelProviders = await render();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Voice input needs a configured model provider",
|
||||
);
|
||||
const openProviders = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("Open Model Providers"));
|
||||
await act(async () => openProviders?.click());
|
||||
expect(onOpenModelProviders).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("explains when connected providers offer no transcription models", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
...transcriptionProvider,
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
modelList: [{ id: "claude", name: "Claude" }],
|
||||
},
|
||||
unconnectedProvider,
|
||||
],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"None of your configured providers offer speech-to-text models",
|
||||
);
|
||||
expect(container.textContent).toContain("Groq");
|
||||
});
|
||||
|
||||
it("enables voice input with the default (streaming) model preselected", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [transcriptionProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
});
|
||||
invokeMock.mockResolvedValue({
|
||||
voiceInput: {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "scribe_v2_realtime",
|
||||
},
|
||||
});
|
||||
await render();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Enable voice input"]',
|
||||
);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
await act(async () => toggle?.click());
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("save_voice_input_settings", {
|
||||
provider: "elevenlabs",
|
||||
model: "scribe_v2_realtime",
|
||||
});
|
||||
expect(notifyMock).toHaveBeenCalled();
|
||||
const selected = container.querySelector('[role="radio"][aria-checked="true"]');
|
||||
expect(selected?.textContent).toContain("Scribe v2 Realtime");
|
||||
expect(selected?.textContent).toContain("Default");
|
||||
});
|
||||
|
||||
it("saves model changes and clears the selection when disabled", async () => {
|
||||
fetchProviderCatalogMock.mockResolvedValue({
|
||||
providers: [transcriptionProvider],
|
||||
settingsPath: "/tmp/providers.json",
|
||||
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v2_realtime" },
|
||||
});
|
||||
invokeMock.mockResolvedValue({
|
||||
voiceInput: { providerId: "elevenlabs", modelId: "scribe_v1" },
|
||||
});
|
||||
await render();
|
||||
|
||||
const batchModel = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((button) => button.textContent?.includes("Scribe v1"));
|
||||
await act(async () => batchModel?.click());
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
|
||||
provider: "elevenlabs",
|
||||
model: "scribe_v1",
|
||||
});
|
||||
|
||||
invokeMock.mockResolvedValue({});
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Enable voice input"]',
|
||||
);
|
||||
await act(async () => toggle?.click());
|
||||
expect(invokeMock).toHaveBeenLastCalledWith("save_voice_input_settings", {
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { AudioLines, Mic, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
isDedicatedTranscriptionModel,
|
||||
notifyVoiceInputSettingsChanged,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderModel,
|
||||
VoiceInputSelection,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type VoiceProviderEntry = {
|
||||
provider: Provider;
|
||||
models: ProviderModel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The model preselected when the user enables voice input or switches
|
||||
* provider: streaming (live) transcription when available, else the first
|
||||
* transcription model the provider offers.
|
||||
*/
|
||||
export function defaultTranscriptionModel(
|
||||
models: ProviderModel[],
|
||||
): ProviderModel | undefined {
|
||||
return (
|
||||
models.find((model) => model.operationModes?.includes("streaming")) ??
|
||||
models[0]
|
||||
);
|
||||
}
|
||||
|
||||
function isStreamingModel(model: ProviderModel): boolean {
|
||||
return model.operationModes?.includes("streaming") === true;
|
||||
}
|
||||
|
||||
export function VoiceInputContent({
|
||||
onOpenModelProviders,
|
||||
}: {
|
||||
onOpenModelProviders: () => void;
|
||||
}) {
|
||||
const [providers, setProviders] = useState<Provider[] | null>(null);
|
||||
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchProviderCatalog()
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setProviders(payload.providers ?? []);
|
||||
setVoiceInput(payload.voiceInput);
|
||||
setLoadError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setLoadError(error instanceof Error ? error.message : String(error));
|
||||
setProviders([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectedProviders = (providers ?? []).filter(isProviderConnected);
|
||||
const voiceProviders: VoiceProviderEntry[] = connectedProviders
|
||||
.map((provider) => ({
|
||||
provider,
|
||||
models: (provider.modelList ?? []).filter(isDedicatedTranscriptionModel),
|
||||
}))
|
||||
.filter((entry) => entry.models.length > 0);
|
||||
// Providers that would qualify once connected, so the empty states can
|
||||
// point the user at something actionable.
|
||||
const voiceCapableProviderNames = (providers ?? [])
|
||||
.filter((provider) =>
|
||||
(provider.modelList ?? []).some(isDedicatedTranscriptionModel),
|
||||
)
|
||||
.map((provider) => provider.name);
|
||||
|
||||
const selectedEntry = voiceProviders.find(
|
||||
(entry) => entry.provider.id === voiceInput?.providerId,
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (selection: VoiceInputSelection | undefined) => {
|
||||
const previous = voiceInput;
|
||||
setVoiceInput(selection);
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
voiceInput?: VoiceInputSelection;
|
||||
}>("save_voice_input_settings", {
|
||||
provider: selection?.providerId,
|
||||
model: selection?.modelId,
|
||||
});
|
||||
setVoiceInput(result.voiceInput);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
} catch (error) {
|
||||
setVoiceInput(previous);
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[voiceInput],
|
||||
);
|
||||
|
||||
const enableWithDefaults = useCallback(() => {
|
||||
const entry = voiceProviders[0];
|
||||
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
|
||||
if (!entry || !model) return;
|
||||
void save({ providerId: entry.provider.id, modelId: model.id });
|
||||
}, [save, voiceProviders]);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
const entry = voiceProviders.find(
|
||||
(candidate) => candidate.provider.id === providerId,
|
||||
);
|
||||
const model = entry ? defaultTranscriptionModel(entry.models) : undefined;
|
||||
if (!entry || !model) return;
|
||||
void save({ providerId: entry.provider.id, modelId: model.id });
|
||||
},
|
||||
[save, voiceProviders],
|
||||
);
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
description="Speak instead of typing: the microphone in chat transcribes your voice with the model chosen here. Live models show text as you speak; others transcribe when the recording stops."
|
||||
title="Voice input"
|
||||
/>
|
||||
);
|
||||
|
||||
if (providers === null) {
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load providers: {loadError}
|
||||
</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (voiceProviders.length === 0) {
|
||||
const hasConnected = connectedProviders.length > 0;
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<div className="flex max-w-2xl flex-col items-start gap-3 rounded-lg border border-dashed px-6 py-8">
|
||||
<Mic aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{hasConnected
|
||||
? "None of your configured providers offer speech-to-text models"
|
||||
: "Voice input needs a configured model provider"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{voiceCapableProviderNames.length > 0
|
||||
? `Connect a provider with transcription models — for example ${voiceCapableProviderNames
|
||||
.slice(0, 4)
|
||||
.join(", ")} — and this page unlocks automatically.`
|
||||
: "Connect a provider with transcription models and this page unlocks automatically."}
|
||||
</p>
|
||||
<Button onClick={onOpenModelProviders} size="sm" type="button">
|
||||
Open Model Providers
|
||||
</Button>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = Boolean(voiceInput);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
{header}
|
||||
<section className="max-w-2xl">
|
||||
<div className="flex items-center justify-between gap-5 border-y py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Enable voice input
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Turns on the microphone button in chat. A default model is
|
||||
preselected — adjust it below.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Enable voice input"
|
||||
checked={enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) enableWithDefaults();
|
||||
else void save(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<p className="mt-3 text-xs text-destructive" role="alert">
|
||||
Failed to save voice input settings: {saveError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-sm font-semibold text-foreground">
|
||||
Provider
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{voiceProviders.map(({ provider }) => {
|
||||
const isSelected = voiceInput?.providerId === provider.id;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/8 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover-lighter hover:text-foreground",
|
||||
)}
|
||||
disabled={saving}
|
||||
key={provider.id}
|
||||
onClick={() => selectProvider(provider.id)}
|
||||
type="button"
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEntry ? (
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-sm font-semibold text-foreground">
|
||||
Model
|
||||
</p>
|
||||
<div
|
||||
aria-label="Voice input model"
|
||||
className="overflow-hidden rounded-lg border"
|
||||
role="radiogroup"
|
||||
>
|
||||
{selectedEntry.models.map((model) => {
|
||||
const isSelected = voiceInput?.modelId === model.id;
|
||||
const isDefault =
|
||||
defaultTranscriptionModel(selectedEntry.models)?.id ===
|
||||
model.id;
|
||||
return (
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 border-b px-4 py-3 text-left last:border-b-0 hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isSelected && "bg-surface-hover",
|
||||
)}
|
||||
disabled={saving}
|
||||
key={model.id}
|
||||
onClick={() =>
|
||||
void save({
|
||||
providerId: selectedEntry.provider.id,
|
||||
modelId: model.id,
|
||||
})
|
||||
}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-4 shrink-0 place-items-center rounded-full border",
|
||||
isSelected
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{model.name}
|
||||
</span>
|
||||
{isStreamingModel(model) ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Radio aria-hidden="true" className="size-3" />
|
||||
Live
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-hover px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<AudioLines
|
||||
aria-hidden="true"
|
||||
className="size-3"
|
||||
/>
|
||||
After recording
|
||||
</span>
|
||||
)}
|
||||
{isDefault ? (
|
||||
<span className="shrink-0 text-[0.625rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{model.id}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -11,14 +11,7 @@ export const CHAT_WS_ENDPOINT_RETRY_DELAY_MS = 100;
|
||||
export const CHAT_WS_RECONNECT_BASE_DELAY_MS = 300;
|
||||
export const CHAT_WS_RECONNECT_MAX_DELAY_MS = 3000;
|
||||
export const CHAT_WS_REQUEST_TIMEOUT_MS = 120000;
|
||||
export const OAUTH_MANAGED_PROVIDERS = new Set([
|
||||
"cline",
|
||||
// ClinePass shares the Cline account OAuth credentials (its auth handler
|
||||
// stores under the "cline" provider), so it never has its own API key.
|
||||
"cline-pass",
|
||||
"oca",
|
||||
"openai-codex",
|
||||
]);
|
||||
export { OAUTH_PROVIDER_IDS as OAUTH_MANAGED_PROVIDERS } from "@/lib/provider-connection";
|
||||
|
||||
export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
sessionId: undefined,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
export type FeatureFlagValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: FeatureFlagValue }
|
||||
| FeatureFlagValue[];
|
||||
|
||||
type FeatureFlagsSnapshot = {
|
||||
flags: Record<string, FeatureFlagValue>;
|
||||
};
|
||||
|
||||
export type FeatureFlagsState = {
|
||||
flags: Record<string, FeatureFlagValue>;
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useFeatureFlags(): FeatureFlagsState {
|
||||
const [flags, setFlags] = useState<Record<string, FeatureFlagValue>>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const snapshot =
|
||||
await desktopClient.invoke<FeatureFlagsSnapshot>("get_feature_flags");
|
||||
setFlags(snapshot?.flags ?? {});
|
||||
} catch {
|
||||
// Sidecar down or still starting. Leave the previous values in place;
|
||||
// every consumer falls back to `false`, matching the registry default
|
||||
// for a flag nobody has been opted into.
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { flags, loaded, refresh: load };
|
||||
}
|
||||
|
||||
export function isFeatureEnabled(
|
||||
flags: Record<string, FeatureFlagValue>,
|
||||
flag: string,
|
||||
): boolean {
|
||||
return flags[flag] === true;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
subscribeToProviderCatalogInvalidation,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
|
||||
/**
|
||||
* Whether at least one model provider is connected (usable for turns).
|
||||
* Returns null while unknown so callers can avoid flashing a disabled state
|
||||
* before the catalog loads. Stays current across credential changes via the
|
||||
* shared catalog invalidation channel.
|
||||
*/
|
||||
export function useHasConnectedProvider(): boolean | null {
|
||||
const [hasConnectedProvider, setHasConnectedProvider] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
// Failures (including an unavailable transport) keep the last known
|
||||
// value; the catalog fetch is retried on the next invalidation.
|
||||
try {
|
||||
void Promise.resolve(fetchProviderCatalog())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setHasConnectedProvider(
|
||||
(payload?.providers ?? []).some(isProviderConnected),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
const unsubscribe = subscribeToProviderCatalogInvalidation(load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return hasConnectedProvider;
|
||||
}
|
||||
@@ -1209,7 +1209,7 @@ export function useSessionHistory({
|
||||
);
|
||||
};
|
||||
|
||||
// Favoriting is a single click, so apply it locally first and roll back
|
||||
// Pinning is a single click, so apply it locally first and roll back
|
||||
// if the write fails rather than blocking the row on a round trip.
|
||||
applyPinned(pinned);
|
||||
try {
|
||||
@@ -1228,7 +1228,7 @@ export function useSessionHistory({
|
||||
applyPinned(!pinned);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: pinned ? "Favorite failed" : "Unfavorite failed",
|
||||
title: pinned ? "Pin failed" : "Unpin failed",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The Agenda (Todo) UI is temporarily hidden while its UX is reworked, in
|
||||
* lockstep with `AGENDA_TODO_TOOL_ENABLED` in the hub server transport, which
|
||||
* disables the agent-facing todo kind of the `tasks` tool. All Agenda
|
||||
* components, hooks, and sidecar plumbing stay in the codebase; flip this back
|
||||
* to true (together with the hub flag) to restore the feature.
|
||||
*/
|
||||
export const AGENDA_UI_ENABLED = false;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Tiny cross-tree signal for focusing the chat prompt input. The sidebar's
|
||||
* "New" action lives far from the chat pane, so instead of threading a focus
|
||||
* callback through the whole component chain it dispatches a window event
|
||||
* that the mounted prompt input listens for.
|
||||
*/
|
||||
|
||||
const FOCUS_PROMPT_INPUT_EVENT = "cline:focus-prompt-input";
|
||||
|
||||
export function requestPromptInputFocus(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
// Deferred so a focus request issued alongside a navigation (e.g. the New
|
||||
// button remounting the chat pane) reaches the freshly mounted input.
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event(FOCUS_PROMPT_INPUT_EVENT));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function subscribeToPromptInputFocus(listener: () => void): () => void {
|
||||
window.addEventListener(FOCUS_PROMPT_INPUT_EVENT, listener);
|
||||
return () => window.removeEventListener(FOCUS_PROMPT_INPUT_EVENT, listener);
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
import type { Provider } from "@/lib/provider-schema";
|
||||
|
||||
/**
|
||||
* Providers whose credentials are managed through an OAuth sign-in flow
|
||||
* rather than a pasted API key. Mirrors the SDK's provider auth registry
|
||||
* (see sdk/packages/core/src/auth/provider-auth-registry.ts); the catalog's
|
||||
* "oauth" capability is the primary signal and this set is the fallback for
|
||||
* entries whose capability metadata is incomplete.
|
||||
*/
|
||||
export const OAUTH_PROVIDER_IDS = new Set([
|
||||
"cline",
|
||||
// ClinePass shares the Cline account OAuth credentials (its auth handler
|
||||
// stores under the "cline" provider), so it never has its own API key.
|
||||
"cline-pass",
|
||||
"oca",
|
||||
"openai-codex",
|
||||
]);
|
||||
|
||||
export type ProviderAuthKind = "oauth" | "local" | "api-key";
|
||||
|
||||
/**
|
||||
* How a provider expects to be authenticated, which drives which connect UI
|
||||
* to show: a browser sign-in button (oauth), a "uses your local CLI" notice
|
||||
* (local), or credential fields (api-key).
|
||||
*/
|
||||
export function getProviderAuthKind(provider: Provider): ProviderAuthKind {
|
||||
if (
|
||||
provider.capabilities?.includes("oauth") ||
|
||||
OAUTH_PROVIDER_IDS.has(provider.id)
|
||||
) {
|
||||
return "oauth";
|
||||
}
|
||||
if (provider.capabilities?.includes("local-auth")) {
|
||||
return "local";
|
||||
}
|
||||
return "api-key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider from the catalog is usable for turns, for the purpose
|
||||
* of the first-run "connect a model" notice. Plain API keys and OAuth are
|
||||
|
||||
@@ -8,7 +8,7 @@ export type SessionHistoryStatus =
|
||||
export type SessionMetadata = {
|
||||
title?: string;
|
||||
/**
|
||||
* Favorited sessions. Stored in session metadata rather than desktop-local
|
||||
* Pinned sessions. Stored in session metadata rather than desktop-local
|
||||
* state so every client reading the session sees the same flag.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
|
||||
@@ -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.11",
|
||||
"version": "4.1.15",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Controller } from "../../index"
|
||||
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
|
||||
const marketplaceHelpersMock = () => ({
|
||||
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
|
||||
isMcpEntryAllowedByPolicy: () => true,
|
||||
})
|
||||
|
||||
mock.module("../marketplace-helpers", marketplaceHelpersMock)
|
||||
@@ -23,6 +24,7 @@ describe("installMarketplaceEntry", () => {
|
||||
const controller = {
|
||||
mcpHub: { reconcileMcpServersFromSettingsRPC },
|
||||
invalidateUserInstructionService,
|
||||
stateManager: { getRemoteConfigSettings: () => ({}) },
|
||||
} as unknown as Controller
|
||||
installMarketplaceEntryFromCatalogStub.resolves({
|
||||
id: "chrome-devtools",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { MarketplaceCatalog } from "@shared/proto/cline/marketplace"
|
||||
import type { Controller } from "../index"
|
||||
import { fetchMarketplaceCatalog } from "./marketplace-helpers"
|
||||
import { fetchMarketplaceCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
|
||||
|
||||
export async function getMarketplaceCatalog(_controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
|
||||
return fetchMarketplaceCatalog()
|
||||
export async function getMarketplaceCatalog(controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
|
||||
const catalog = await fetchMarketplaceCatalog()
|
||||
// Filter out MCP entries blocked by enterprise remote config so they never reach the webview.
|
||||
const policy = controller.stateManager.getRemoteConfigSettings()
|
||||
return { ...catalog, entries: catalog.entries.filter((entry) => isMcpEntryAllowedByPolicy(entry, policy)) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
|
||||
import type { Controller } from "../index"
|
||||
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
|
||||
import { installMarketplaceEntryFromCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
controller: Controller,
|
||||
@@ -9,6 +9,11 @@ export async function installMarketplaceEntry(
|
||||
if (!request.entry) {
|
||||
throw new Error("Marketplace entry is required.")
|
||||
}
|
||||
if (!isMcpEntryAllowedByPolicy(request.entry, controller.stateManager.getRemoteConfigSettings())) {
|
||||
throw new Error(
|
||||
`Installing "${request.entry.name || request.entry.id}" is blocked by your organization's MCP server policy.`,
|
||||
)
|
||||
}
|
||||
const result = await installMarketplaceEntryFromCatalog(request.entry)
|
||||
if (request.entry.type === "mcp") {
|
||||
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
|
||||
|
||||
@@ -82,8 +82,17 @@ function sanitizeEntry(raw: unknown): MarketplaceEntry | undefined {
|
||||
description: typeof record.description === "string" ? record.description : undefined,
|
||||
tags: asStringArray(record.tags),
|
||||
author: typeof record.author === "string" ? record.author : undefined,
|
||||
sourceUrl: typeof record.sourceUrl === "string" ? record.sourceUrl : undefined,
|
||||
homepageUrl: typeof record.homepageUrl === "string" ? record.homepageUrl : undefined,
|
||||
// The published catalog uses "repo"/"homepage"; older entries may use
|
||||
// "sourceUrl"/"homepageUrl". Accept both so URL-based enterprise
|
||||
// allowlist ids can be matched against the entry.
|
||||
sourceUrl:
|
||||
typeof record.sourceUrl === "string" ? record.sourceUrl : typeof record.repo === "string" ? record.repo : undefined,
|
||||
homepageUrl:
|
||||
typeof record.homepageUrl === "string"
|
||||
? record.homepageUrl
|
||||
: typeof record.homepage === "string"
|
||||
? record.homepage
|
||||
: undefined,
|
||||
install: install
|
||||
? {
|
||||
args: asStringArray(install.args),
|
||||
@@ -134,6 +143,31 @@ function marketplaceKey(entry: MarketplaceEntry): string {
|
||||
return `${entry.type}:${entry.id}`
|
||||
}
|
||||
|
||||
/** Normalizes an allowlist id or entry identifier; legacy allowlist ids may be GitHub repo URLs. */
|
||||
function normalizePolicyValue(value: string | undefined): string {
|
||||
return normalizeMatchValue((value ?? "").replace(/^https?:\/\//i, "").replace(/\/+$/, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise remote config can disable the MCP marketplace (`mcpMarketplaceEnabled: false`)
|
||||
* or restrict it to an allowlist (`allowedMCPServers`). Non-MCP entries are not governed
|
||||
* by these controls. Allowlist ids match the entry id, display name, installed server
|
||||
* name, or source/homepage URL.
|
||||
*/
|
||||
export function isMcpEntryAllowedByPolicy(
|
||||
entry: MarketplaceEntry,
|
||||
policy: { mcpMarketplaceEnabled?: boolean; allowedMCPServers?: Array<{ id: string }> },
|
||||
): boolean {
|
||||
if (entry.type !== "mcp") return true
|
||||
if (policy.mcpMarketplaceEnabled === false) return false
|
||||
if (!policy.allowedMCPServers?.length) return true
|
||||
const candidates = new Set(
|
||||
[entry.id, entry.name, getEntryArgs(entry)[0], entry.sourceUrl, entry.homepageUrl].map(normalizePolicyValue),
|
||||
)
|
||||
candidates.delete("")
|
||||
return policy.allowedMCPServers.some((server) => candidates.has(normalizePolicyValue(server.id)))
|
||||
}
|
||||
|
||||
function getEntryArgs(entry: MarketplaceEntry): string[] {
|
||||
return entry.install?.args ?? []
|
||||
}
|
||||
|
||||
@@ -102,8 +102,14 @@ export class HookDiscoveryCache {
|
||||
|
||||
/**
|
||||
* Get cached hook scripts or scan if not cached
|
||||
*
|
||||
* @param hooksDirs Optional snapshot of the hooks directories to scan on a
|
||||
* cache miss. Callers that already resolved the window's workspace roots
|
||||
* pass this so discovery uses the same snapshot as cwd selection and hook
|
||||
* input metadata (and the miss costs no extra host lookup). Ignored on a
|
||||
* cache hit.
|
||||
*/
|
||||
async get(hookName: HookName): Promise<string[]> {
|
||||
async get(hookName: HookName, hooksDirs?: string[]): Promise<string[]> {
|
||||
this.log(`Getting hooks for ${hookName}`)
|
||||
|
||||
const cached = this.cache.get(hookName)
|
||||
@@ -127,7 +133,7 @@ export class HookDiscoveryCache {
|
||||
} else {
|
||||
// This caller initiates the scan
|
||||
initiatedScan = true
|
||||
scripts = await this.scan(hookName)
|
||||
scripts = await this.scan(hookName, hooksDirs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +153,7 @@ export class HookDiscoveryCache {
|
||||
/**
|
||||
* Scan for hook scripts and cache the result
|
||||
*/
|
||||
private async scan(hookName: HookName): Promise<string[]> {
|
||||
private async scan(hookName: HookName, knownHooksDirs?: string[]): Promise<string[]> {
|
||||
// Check if a scan is already in progress for this hook
|
||||
const existingPromise = this.scanningPromises.get(hookName)
|
||||
if (existingPromise) {
|
||||
@@ -158,8 +164,9 @@ export class HookDiscoveryCache {
|
||||
// Create a new scan promise
|
||||
const scanPromise = (async () => {
|
||||
try {
|
||||
// Get all current hooks directories
|
||||
const hooksDirs = await getAllHooksDirs()
|
||||
// Use the caller's hooks-dir snapshot when provided, otherwise
|
||||
// resolve the current hooks directories
|
||||
const hooksDirs = knownHooksDirs ?? (await getAllHooksDirs())
|
||||
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
|
||||
|
||||
// Ensure watchers are set up for each directory (lazy initialization)
|
||||
|
||||
@@ -26,18 +26,21 @@ fixtures/
|
||||
The `loadFixture()` helper function copies a fixture to your test environment:
|
||||
|
||||
```typescript
|
||||
import { loadFixture } from '../test-utils'
|
||||
import { createHookTestEnv, loadFixture } from '../test-utils'
|
||||
|
||||
it("should work with real hook", async () => {
|
||||
const { getEnv } = setupHookTests()
|
||||
|
||||
await loadFixture("hooks/pretooluse/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.cancel.should.be.false()
|
||||
const env = await createHookTestEnv()
|
||||
try {
|
||||
await loadFixture("hooks/pretooluse/success", env.tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
|
||||
|
||||
result.cancel.should.be.false()
|
||||
} finally {
|
||||
await env.cleanup()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { createHookTestEnv, createTestHook, HookTestEnv, resetHookCache } from "./test-utils"
|
||||
|
||||
describe("HookDiscoveryCache", () => {
|
||||
let env: HookTestEnv
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createHookTestEnv()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("scans the caller-provided hooks-dir snapshot on a cache miss", async () => {
|
||||
// A hooks dir outside what the stubbed getAllHooksDirs returns; finding
|
||||
// its script proves the provided snapshot was used instead.
|
||||
const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-cache-test-"))
|
||||
try {
|
||||
const scriptPath = await createTestHook(otherDir, "PreToolUse", { cancel: false })
|
||||
const otherHooksDir = path.dirname(scriptPath)
|
||||
|
||||
resetHookCache()
|
||||
const scripts = await HookDiscoveryCache.getInstance().get("PreToolUse", [otherHooksDir])
|
||||
scripts.should.eql([scriptPath])
|
||||
} finally {
|
||||
await fs.rm(otherDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to getAllHooksDirs when no snapshot is provided", async () => {
|
||||
const scriptPath = await createTestHook(env.tempDir, "PreToolUse", { cancel: false })
|
||||
|
||||
resetHookCache()
|
||||
const scripts = await HookDiscoveryCache.getInstance().get("PreToolUse")
|
||||
scripts.should.eql([scriptPath])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { setDistinctId } from "@/services/logging/distinctId"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { HookFactory, isPathWithin } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withPlatform, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("Hook System", () => {
|
||||
@@ -37,6 +37,8 @@ describe("Hook System", () => {
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
runner.isNoOp.should.be.true()
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
@@ -108,6 +110,8 @@ console.log(JSON.stringify({
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
runner.isNoOp.should.be.false()
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
@@ -686,4 +690,40 @@ console.log(JSON.stringify({
|
||||
result.contextModification?.should.equal("Global observed: true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace root matching", () => {
|
||||
it("isPathWithin requires a whole path-segment boundary", () => {
|
||||
const root = path.join(path.sep, "repo", "app")
|
||||
const sibling = path.join(path.sep, "repo", "app-web", "x")
|
||||
const child = path.join(root, "x")
|
||||
|
||||
isPathWithin(root, root).should.be.true()
|
||||
isPathWithin(root, child).should.be.true()
|
||||
isPathWithin(root, sibling).should.be.false()
|
||||
isPathWithin(root + path.sep, child).should.be.true()
|
||||
})
|
||||
|
||||
it("determineHookCwd picks the owning root for prefix-sharing and nested roots", () => {
|
||||
const factory = new HookFactory() as any
|
||||
const app = path.join(path.sep, "repo", "app")
|
||||
const appWeb = path.join(path.sep, "repo", "app-web")
|
||||
const hooksDir = (root: string) => path.join(root, ".clinerules", "hooks")
|
||||
const script = (root: string) => path.join(hooksDir(root), "PreToolUse")
|
||||
|
||||
// Prefix-sharing sibling roots: the hook must run from its own root,
|
||||
// not the root that happens to be a string prefix of it.
|
||||
factory.determineHookCwd(script(appWeb), [hooksDir(app), hooksDir(appWeb)], [app, appWeb]).should.equal(appWeb)
|
||||
|
||||
// Nested roots: the innermost matching root wins regardless of order.
|
||||
const outer = path.join(path.sep, "repo")
|
||||
const inner = path.join(path.sep, "repo", "packages", "x")
|
||||
factory.determineHookCwd(script(inner), [hooksDir(outer), hooksDir(inner)], [outer, inner]).should.equal(inner)
|
||||
|
||||
// Global hooks (and unplaceable scripts) fall back to the primary root.
|
||||
const globalScript = path.join(path.sep, "home", "Documents", "Cline", "Hooks", "PreToolUse")
|
||||
factory
|
||||
.determineHookCwd(globalScript, [path.join(path.sep, "home", "Documents", "Cline", "Hooks")], [app, appWeb])
|
||||
.should.equal(app)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { afterEach, beforeEach } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { createHooksDirectory } from "./test-utils"
|
||||
|
||||
/**
|
||||
* Test environment containing temp directories and cleanup functions.
|
||||
*/
|
||||
export interface HookTestEnvironment {
|
||||
/** Temporary directory for this test */
|
||||
tempDir: string
|
||||
/** Array of hooks directories (.clinerules/hooks paths) */
|
||||
hooksDirs: string[]
|
||||
/** Cleanup function to remove temp directories */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh test environment with temp directories.
|
||||
* Automatically creates .clinerules/hooks structure.
|
||||
*
|
||||
* @returns Test environment with cleanup function
|
||||
*
|
||||
* @example
|
||||
* const env = await createHookTestEnvironment()
|
||||
* // Use env.tempDir, env.hooksDirs in tests
|
||||
* await env.cleanup() // Clean up after tests
|
||||
*/
|
||||
export async function createHookTestEnvironment(): Promise<HookTestEnvironment> {
|
||||
const tempDir = path.join(os.tmpdir(), `hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
const hooksDir = await createHooksDirectory(tempDir)
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
hooksDirs: [hooksDir],
|
||||
cleanup: async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error: any) {
|
||||
// Only ignore ENOENT (already deleted), log other errors
|
||||
if (error.code !== "ENOENT") {
|
||||
console.warn(`Cleanup warning for ${tempDir}:`, error.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard setup for hook tests. Returns accessor to environment.
|
||||
* Use in describe() blocks for automatic setup/teardown.
|
||||
*
|
||||
* @returns Object with getEnv() method to access test environment
|
||||
*
|
||||
* @example
|
||||
* describe("My Hook Tests", () => {
|
||||
* const { getEnv } = setupHookTests()
|
||||
*
|
||||
* it("should do something", async () => {
|
||||
* const env = getEnv()
|
||||
* // env.tempDir is ready to use
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function setupHookTests(): {
|
||||
getEnv: () => HookTestEnvironment
|
||||
} {
|
||||
let env: HookTestEnvironment
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
env = await createHookTestEnvironment()
|
||||
|
||||
// Mock StateManager to return test workspace
|
||||
mockStateManager(sandbox, [env.tempDir])
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
getEnv: () => {
|
||||
if (!env) {
|
||||
throw new Error("Test environment not initialized. Called getEnv() outside of test?")
|
||||
}
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks StateManager to return test workspace roots.
|
||||
* Useful for testing hook discovery across multiple workspace roots.
|
||||
*
|
||||
* @param sandbox Sinon sandbox for cleanup
|
||||
* @param workspaceRoots Array of workspace root paths
|
||||
*
|
||||
* @example
|
||||
* const sandbox = sinon.createSandbox()
|
||||
* mockStateManager(sandbox, ["/path/to/workspace1", "/path/to/workspace2"])
|
||||
* // StateManager.get().getGlobalStateKey("workspaceRoots") now returns mocked roots
|
||||
* sandbox.restore() // Clean up after tests
|
||||
*/
|
||||
export function mockStateManager(sandbox: sinon.SinonSandbox, workspaceRoots: string[]): void {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => workspaceRoots.map((rootPath) => ({ path: rootPath })),
|
||||
} as any)
|
||||
}
|
||||
@@ -6,9 +6,8 @@ import should from "should"
|
||||
import sinon from "sinon"
|
||||
import { HostProvider } from "../../../hosts/host-provider"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { setVscodeHostProviderMock } from "../../../test/host-provider-test-utils"
|
||||
import { setVscodeHostProviderMock, stubWorkspacePaths } from "../../../test/host-provider-test-utils"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
|
||||
|
||||
@@ -97,17 +96,7 @@ export async function createHookTestEnv(): Promise<HookTestEnv> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
|
||||
const hooksDir = await createHooksDirectory(tempDir)
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: (key: string) => {
|
||||
if (key === "workspaceRoots") {
|
||||
return [{ path: tempDir }]
|
||||
}
|
||||
if (key === "primaryRootIndex") {
|
||||
return 0
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [tempDir])
|
||||
|
||||
resetHookCache()
|
||||
stubHookDirs(sandbox, [hooksDir])
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
TaskStartData,
|
||||
UserPromptSubmitData,
|
||||
} from "../../shared/proto/cline/hooks"
|
||||
import { getAllHooksDirs } from "../storage/disk"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { getAllHooksDirs, getWindowWorkspaceRoots } from "../storage/disk"
|
||||
import { HookExecutionError } from "./HookError"
|
||||
import { HookProcess } from "./HookProcess"
|
||||
|
||||
@@ -163,7 +162,23 @@ const exec = Symbol()
|
||||
* - Results are immediately consumed and added to the conversation context
|
||||
*/
|
||||
export abstract class HookRunner<Name extends HookName> {
|
||||
constructor(public readonly hookName: Name) {}
|
||||
/**
|
||||
* @param workspaceRoots This window's workspace root paths, resolved when
|
||||
* the runner was created. Kept alongside the runner so hook input metadata
|
||||
* matches the roots used for discovery and cwd selection.
|
||||
*/
|
||||
constructor(
|
||||
public readonly hookName: Name,
|
||||
protected readonly workspaceRoots?: string[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* True when no hook scripts were discovered and run() is a no-op. Callers
|
||||
* use this to skip status reporting for hooks that don't exist.
|
||||
*/
|
||||
get isNoOp(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the hook with the given parameters.
|
||||
@@ -196,10 +211,7 @@ export abstract class HookRunner<Name extends HookName> {
|
||||
* @returns Complete HookInput ready to be serialized and sent to the hook script
|
||||
*/
|
||||
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
|
||||
const workspaceRoots =
|
||||
StateManager.get()
|
||||
.getGlobalStateKey("workspaceRoots")
|
||||
?.map((root) => root.path) || []
|
||||
const workspaceRoots = this.workspaceRoots ?? (await getWindowWorkspaceRoots())
|
||||
|
||||
const model: HookModelContext = {
|
||||
provider: params.model?.provider?.trim() || "unknown",
|
||||
@@ -228,6 +240,10 @@ export abstract class HookRunner<Name extends HookName> {
|
||||
* @template Name The type of hook this runner represents
|
||||
*/
|
||||
class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
override get isNoOp(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a no-op hook that always succeeds.
|
||||
* @param _ Hook input (ignored)
|
||||
@@ -282,8 +298,9 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
private readonly taskId?: string,
|
||||
private readonly toolName?: string,
|
||||
private readonly cwd?: string,
|
||||
workspaceRoots?: string[],
|
||||
) {
|
||||
super(hookName)
|
||||
super(hookName, workspaceRoots)
|
||||
}
|
||||
|
||||
override async [exec](input: HookInput): Promise<HookOutput> {
|
||||
@@ -652,8 +669,9 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
constructor(
|
||||
hookName: Name,
|
||||
private readonly runners: readonly HookRunner<Name>[],
|
||||
workspaceRoots?: string[],
|
||||
) {
|
||||
super(hookName)
|
||||
super(hookName, workspaceRoots)
|
||||
}
|
||||
|
||||
override async [exec](input: HookInput): Promise<HookOutput> {
|
||||
@@ -717,13 +735,26 @@ function isExpectedHookError(error: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `child` is the same path as `parent` or inside it. A plain
|
||||
* startsWith check would treat /a/b as containing /a/bc, so require the match
|
||||
* to end on a whole path segment.
|
||||
*/
|
||||
export function isPathWithin(parent: string, child: string): boolean {
|
||||
if (child === parent) {
|
||||
return true
|
||||
}
|
||||
const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep
|
||||
return child.startsWith(prefix)
|
||||
}
|
||||
|
||||
export class HookFactory {
|
||||
/**
|
||||
* @param options.sessionWorkspaceRoot The workspace root of the session the
|
||||
* hooks run for. Discovery additionally scans this root's .clinerules/hooks:
|
||||
* the global `workspaceRoots` state is shared across every Cline instance,
|
||||
* so another window can repoint it and workspace hooks would silently stop
|
||||
* being discovered without this session-scoped fallback.
|
||||
* hooks run for. Discovery additionally scans this root's .clinerules/hooks
|
||||
* and hooks discovered there execute from it: the session's root is not
|
||||
* always among the window's workspace folders (e.g. the chat-workspace
|
||||
* fallback when no folder is open).
|
||||
*/
|
||||
constructor(private readonly options?: { sessionWorkspaceRoot?: string }) {}
|
||||
|
||||
@@ -742,34 +773,11 @@ export class HookFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about discovered hooks including their script paths
|
||||
* @param hookName The type of hook to query
|
||||
* @returns Object containing array of script paths
|
||||
*/
|
||||
async getHookInfo<Name extends HookName>(
|
||||
hookName: Name,
|
||||
): Promise<{
|
||||
scriptPaths: string[]
|
||||
}> {
|
||||
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
|
||||
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
|
||||
return { scriptPaths: scripts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any hook scripts exist for the given hook name
|
||||
* @returns true if at least one hook script exists, false otherwise
|
||||
*/
|
||||
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
|
||||
const scripts = await HookFactory.findHookScripts(hookName)
|
||||
if (scripts.length > 0) {
|
||||
return true
|
||||
}
|
||||
return (await this.findSessionScripts(hookName)).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook runner without streaming support (backwards compatible)
|
||||
* Create a hook runner without streaming support (backwards compatible).
|
||||
* Check the returned runner's isNoOp to learn whether any hook scripts
|
||||
* exist — creation is the single point where workspace roots and hook
|
||||
* discovery are resolved, so there is no separate existence check that
|
||||
* could disagree with what the runner will execute.
|
||||
*/
|
||||
async create<Name extends HookName>(hookName: Name, taskId?: string, toolName?: string): Promise<HookRunner<Name>> {
|
||||
return this.createWithStreaming(hookName, undefined, undefined, taskId, toolName)
|
||||
@@ -802,15 +810,26 @@ export class HookFactory {
|
||||
taskId?: string,
|
||||
toolName?: string,
|
||||
): Promise<HookRunner<Name>> {
|
||||
// Resolve this window's workspace roots once, then reuse the same set
|
||||
// for hooks-dir discovery, cwd selection, and hook input metadata so
|
||||
// they can't disagree. The first workspace folder is the primary root;
|
||||
// the session's root joins the set when it isn't a window folder, so
|
||||
// session-discovered hooks execute from their own workspace.
|
||||
const windowRoots = await getWindowWorkspaceRoots()
|
||||
const sessionRoot = this.options?.sessionWorkspaceRoot
|
||||
const workspaceRoots = sessionRoot && !windowRoots.includes(sessionRoot) ? [...windowRoots, sessionRoot] : windowRoots
|
||||
|
||||
const hooksDirs = await getAllHooksDirs(windowRoots)
|
||||
|
||||
// Use cache for hook discovery instead of direct file system scan,
|
||||
// unioned with a session-scoped scan (see constructor doc).
|
||||
// giving it the window-scoped hooks-dir snapshot for cache misses. The
|
||||
// session dir is scanned outside the cache: the cache is a process-wide
|
||||
// singleton, so per-session results must not seed it.
|
||||
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
|
||||
const cachedScripts = await HookDiscoveryCache.getInstance().get(hookName)
|
||||
const cachedScripts = await HookDiscoveryCache.getInstance().get(hookName, hooksDirs)
|
||||
const sessionScripts = await this.findSessionScripts(hookName)
|
||||
const scripts = [...new Set([...cachedScripts, ...sessionScripts])]
|
||||
|
||||
// Fetch hooks dirs once for source determination and telemetry
|
||||
const hooksDirs = await getAllHooksDirs()
|
||||
const sessionDir = this.sessionHooksDir()
|
||||
if (sessionDir && !hooksDirs.includes(sessionDir)) {
|
||||
hooksDirs.push(sessionDir)
|
||||
@@ -826,31 +845,29 @@ export class HookFactory {
|
||||
)
|
||||
}
|
||||
|
||||
// Get workspace roots for cwd determination; the session root joins
|
||||
// them so session-discovered hooks execute from their own workspace.
|
||||
const stateManager = StateManager.get()
|
||||
const storedRoots = stateManager.getGlobalStateKey("workspaceRoots")
|
||||
const sessionRoot = this.options?.sessionWorkspaceRoot
|
||||
const workspaceRoots =
|
||||
sessionRoot && !storedRoots?.some((root) => root.path === sessionRoot)
|
||||
? [...(storedRoots ?? []), { path: sessionRoot }]
|
||||
: storedRoots
|
||||
const primaryRootIndex = stateManager.getGlobalStateKey("primaryRootIndex") ?? 0
|
||||
const primaryCwd = storedRoots?.[primaryRootIndex]?.path ?? sessionRoot
|
||||
|
||||
// Create runners with source and cwd determination for each script
|
||||
// Global hooks run from primary workspace root
|
||||
// Workspace-specific hooks run from their respective workspace root
|
||||
const runners = scripts.map((script) => {
|
||||
const source = this.determineScriptSource(script, hooksDirs)
|
||||
const cwd = this.determineHookCwd(script, hooksDirs, workspaceRoots, primaryCwd)
|
||||
return new StdioHookRunner(hookName, script, source, streamCallback, abortSignal, taskId, toolName, cwd)
|
||||
const cwd = this.determineHookCwd(script, hooksDirs, workspaceRoots)
|
||||
return new StdioHookRunner(
|
||||
hookName,
|
||||
script,
|
||||
source,
|
||||
streamCallback,
|
||||
abortSignal,
|
||||
taskId,
|
||||
toolName,
|
||||
cwd,
|
||||
workspaceRoots,
|
||||
)
|
||||
})
|
||||
|
||||
if (runners.length === 0) {
|
||||
return new NoOpRunner(hookName)
|
||||
return new NoOpRunner(hookName, workspaceRoots)
|
||||
}
|
||||
return runners.length === 1 ? runners[0] : new CombinedHookRunner(hookName, runners)
|
||||
return runners.length === 1 ? runners[0] : new CombinedHookRunner(hookName, runners, workspaceRoots)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -865,7 +882,7 @@ export class HookFactory {
|
||||
* Determines if a single script is from global or workspace location
|
||||
*/
|
||||
private determineScriptSource(scriptPath: string, hooksDirs: string[]): "global" | "workspace" {
|
||||
const containingDir = hooksDirs.find((dir) => scriptPath.startsWith(dir))
|
||||
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, scriptPath))
|
||||
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
|
||||
return "global"
|
||||
}
|
||||
@@ -883,35 +900,29 @@ export class HookFactory {
|
||||
*
|
||||
* @param scriptPath The full path to the hook script
|
||||
* @param hooksDirs Array of all hooks directories
|
||||
* @param workspaceRoots Array of workspace root objects
|
||||
* @param primaryCwd The primary workspace root path (fallback)
|
||||
* @param workspaceRoots Array of workspace root paths; the first entry is
|
||||
* the primary root
|
||||
* @returns The working directory to use for this hook
|
||||
*/
|
||||
private determineHookCwd(
|
||||
scriptPath: string,
|
||||
hooksDirs: string[],
|
||||
workspaceRoots: Array<{ path: string }> | undefined,
|
||||
primaryCwd: string | undefined,
|
||||
): string | undefined {
|
||||
const containingDir = hooksDirs.find((dir) => scriptPath.startsWith(dir))
|
||||
private determineHookCwd(scriptPath: string, hooksDirs: string[], workspaceRoots: string[]): string | undefined {
|
||||
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, scriptPath))
|
||||
|
||||
// If global hook, use primary workspace root
|
||||
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
|
||||
return primaryCwd
|
||||
}
|
||||
|
||||
// If workspace hook, find which workspace root it belongs to
|
||||
// Workspace hooks are at: workspaceRoot/.clinerules/hooks/
|
||||
// So find the workspace root whose path is a prefix of the containing hooks dir
|
||||
if (containingDir && workspaceRoots) {
|
||||
const workspaceRoot = workspaceRoots.find((root) => containingDir.startsWith(root.path))
|
||||
// If workspace hook, find which workspace root it belongs to.
|
||||
// Workspace hooks are at: workspaceRoot/.clinerules/hooks/. Prefer the
|
||||
// longest matching root so nested workspace roots resolve to the
|
||||
// innermost one.
|
||||
if (containingDir && !HookFactory.isGlobalHooksDir(containingDir)) {
|
||||
const workspaceRoot = [...workspaceRoots]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((root) => isPathWithin(root, containingDir))
|
||||
if (workspaceRoot) {
|
||||
return workspaceRoot.path
|
||||
return workspaceRoot
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to primary cwd
|
||||
return primaryCwd
|
||||
// Global hooks (and any script we can't place) run from the primary
|
||||
// workspace root.
|
||||
return workspaceRoots[0]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -932,7 +943,7 @@ export class HookFactory {
|
||||
let workspaceCount = 0
|
||||
|
||||
for (const script of scripts) {
|
||||
const containingDir = hooksDirs.find((dir) => script.startsWith(dir))
|
||||
const containingDir = hooksDirs.find((dir) => isPathWithin(dir, script))
|
||||
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
|
||||
globalCount++
|
||||
} else {
|
||||
@@ -943,20 +954,6 @@ export class HookFactory {
|
||||
return { globalCount, workspaceCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A list of paths to scripts for the given hook name.
|
||||
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
|
||||
* (from .clinerules/hooks/ in each workspace root).
|
||||
*/
|
||||
private static async findHookScripts(hookName: HookName): Promise<string[]> {
|
||||
const hookScripts = []
|
||||
for (const hooksDir of await getAllHooksDirs()) {
|
||||
hookScripts.push(HookFactory.findHookInHooksDir(hookName, hooksDir))
|
||||
}
|
||||
const isDefined = (scriptPath: string | undefined): scriptPath is string => Boolean(scriptPath)
|
||||
return (await Promise.all(hookScripts)).filter(isDefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to a hook in a .clinerules hooks directory.
|
||||
*
|
||||
|
||||
@@ -6,7 +6,7 @@ import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import { setVscodeHostProviderMock, stubWorkspacePaths } from "@/test/host-provider-test-utils"
|
||||
|
||||
// bun loads real ESM, so sinon cannot stub the `@utils/fs` namespace export
|
||||
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
|
||||
@@ -19,8 +19,7 @@ const fsUtilsMock = () => ({ ...actualFsUtils, isDirectory: isDirectoryStub })
|
||||
mock.module("@utils/fs", fsUtilsMock)
|
||||
mock.module("@/utils/fs", fsUtilsMock)
|
||||
|
||||
import { getAllHooksDirs, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
|
||||
import { StateManager } from "../StateManager"
|
||||
import { getAllHooksDirs, getWindowWorkspaceRoots, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
|
||||
|
||||
describe("disk - hooks functionality", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -47,20 +46,8 @@ describe("disk - hooks functionality", () => {
|
||||
})
|
||||
|
||||
describe("getWorkspaceHooksDirs", () => {
|
||||
it("should return empty array when no workspace roots exist", async () => {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => undefined,
|
||||
} as any)
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
result.length.should.equal(0)
|
||||
})
|
||||
|
||||
it("should return empty array when workspace roots is empty array", async () => {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [],
|
||||
} as any)
|
||||
it("should return empty array when the window has no workspace folders", async () => {
|
||||
stubWorkspacePaths(sandbox, [])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -72,9 +59,7 @@ describe("disk - hooks functionality", () => {
|
||||
const workspaceRoot = path.join(tempDir, "workspace1")
|
||||
await fs.mkdir(workspaceRoot, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -87,9 +72,7 @@ describe("disk - hooks functionality", () => {
|
||||
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
|
||||
await fs.mkdir(hooksDir, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -104,9 +87,7 @@ describe("disk - hooks functionality", () => {
|
||||
await fs.mkdir(path.dirname(hooksPath), { recursive: true })
|
||||
await fs.writeFile(hooksPath, "not a directory")
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -123,9 +104,7 @@ describe("disk - hooks functionality", () => {
|
||||
await fs.mkdir(hooksDir1, { recursive: true })
|
||||
await fs.mkdir(hooksDir2, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot1, workspaceRoot2])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -146,9 +125,7 @@ describe("disk - hooks functionality", () => {
|
||||
await fs.mkdir(workspaceRoot2, { recursive: true }) // No hooks dir
|
||||
await fs.mkdir(hooksDir3, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }, { path: workspaceRoot3 }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot1, workspaceRoot2, workspaceRoot3])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -162,9 +139,7 @@ describe("disk - hooks functionality", () => {
|
||||
const workspaceRoot = path.join(tempDir, "workspace1")
|
||||
await fs.mkdir(workspaceRoot, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot])
|
||||
|
||||
// Stub isDirectory to throw an error
|
||||
isDirectoryStub.rejects(new Error("Permission denied"))
|
||||
@@ -183,9 +158,7 @@ describe("disk - hooks functionality", () => {
|
||||
const expectedHooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
|
||||
await fs.mkdir(expectedHooksDir, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRoot }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRoot])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result[0].should.equal(expectedHooksDir)
|
||||
@@ -199,9 +172,7 @@ describe("disk - hooks functionality", () => {
|
||||
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
|
||||
await fs.mkdir(hooksDir, { recursive: true })
|
||||
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: workspaceRootWithSlash }],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [workspaceRootWithSlash])
|
||||
|
||||
const result = await getWorkspaceHooksDirs()
|
||||
result.should.be.an.Array()
|
||||
@@ -210,15 +181,34 @@ describe("disk - hooks functionality", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWindowWorkspaceRoots", () => {
|
||||
it("should filter out blank workspace paths", async () => {
|
||||
const workspaceRoot = path.join(tempDir, "workspace1")
|
||||
stubWorkspacePaths(sandbox, ["", " ", workspaceRoot])
|
||||
|
||||
const result = await getWindowWorkspaceRoots()
|
||||
result.should.eql([workspaceRoot])
|
||||
})
|
||||
|
||||
it("should return empty array when the host lookup fails", async () => {
|
||||
sandbox.stub(HostProvider, "workspace").get(() => ({
|
||||
getWorkspacePaths: async () => {
|
||||
throw new Error("host bridge unavailable")
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await getWindowWorkspaceRoots()
|
||||
result.should.eql([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllHooksDirs", () => {
|
||||
it("should include the runtime hooks directory when it exists", async () => {
|
||||
const runtimeHooksDir = path.join(tempDir, "runtime-hooks")
|
||||
await fs.mkdir(runtimeHooksDir, { recursive: true })
|
||||
|
||||
sandbox.stub(os, "homedir").returns(tempDir)
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [])
|
||||
|
||||
isDirectoryStub.callsFake(async (targetPath: string) => targetPath === runtimeHooksDir)
|
||||
|
||||
@@ -232,9 +222,7 @@ describe("disk - hooks functionality", () => {
|
||||
const runtimeHooksDir = path.join(tempDir, "missing-runtime-hooks")
|
||||
|
||||
sandbox.stub(os, "homedir").returns(tempDir)
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [],
|
||||
} as any)
|
||||
stubWorkspacePaths(sandbox, [])
|
||||
|
||||
isDirectoryStub.resolves(false)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getDocumentsPath } from "./documents-path"
|
||||
import { StateManager } from "./StateManager"
|
||||
|
||||
export { getDocumentsPath } from "./documents-path"
|
||||
|
||||
@@ -298,7 +297,7 @@ export function setRuntimeHooksDir(dir: string | undefined): void {
|
||||
* A workspace may not use hooks, and the resulting array will be empty. A
|
||||
* multi-root workspace may have multiple hooks directories.
|
||||
*/
|
||||
export async function getAllHooksDirs(): Promise<string[]> {
|
||||
export async function getAllHooksDirs(workspaceRootPaths?: string[]): Promise<string[]> {
|
||||
const hooksDirs: string[] = []
|
||||
|
||||
// Add runtime hooks directory (set by --hooks-dir CLI flag)
|
||||
@@ -313,22 +312,38 @@ export async function getAllHooksDirs(): Promise<string[]> {
|
||||
}
|
||||
|
||||
// Add workspace hooks directories
|
||||
const workspaceHooksDirs = await getWorkspaceHooksDirs()
|
||||
const workspaceHooksDirs = await getWorkspaceHooksDirs(workspaceRootPaths)
|
||||
hooksDirs.push(...workspaceHooksDirs)
|
||||
|
||||
return hooksDirs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves this window's workspace root paths for hook discovery. Workspace
|
||||
* identity must come from the window's host, not global state: ~/.cline global
|
||||
* state is shared across every Cline instance, so a persisted value can point
|
||||
* at another window's project.
|
||||
*
|
||||
* Blank entries are filtered out, and a host lookup failure degrades to no
|
||||
* workspace roots (global and runtime hooks don't depend on them).
|
||||
*/
|
||||
export async function getWindowWorkspaceRoots(): Promise<string[]> {
|
||||
try {
|
||||
const { paths } = await HostProvider.workspace.getWorkspacePaths({})
|
||||
return paths.filter((workspacePath) => workspacePath.trim().length > 0)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to resolve workspace paths for hooks", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the paths to the workspace's .clinerules/hooks directories to search for
|
||||
* hooks. A workspace may not use hooks, and the resulting array will be empty. A
|
||||
* multi-root workspace may have multiple hooks directories.
|
||||
*/
|
||||
export async function getWorkspaceHooksDirs(): Promise<string[]> {
|
||||
const workspaceRootPaths =
|
||||
StateManager.get()
|
||||
.getGlobalStateKey("workspaceRoots")
|
||||
?.map((root) => root.path) || []
|
||||
export async function getWorkspaceHooksDirs(workspaceRootPaths?: string[]): Promise<string[]> {
|
||||
workspaceRootPaths ??= await getWindowWorkspaceRoots()
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
|
||||
@@ -37,4 +37,4 @@ export async function getDocumentsPath(): Promise<string> {
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ export class Controller {
|
||||
},
|
||||
shouldAutoApproveTool: (request) => {
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
return autoApprovalSettings ? isToolAutoApproved(request.toolName, autoApprovalSettings, this.mcpHub) : false
|
||||
return autoApprovalSettings ? isToolAutoApproved(request.toolName, autoApprovalSettings) : false
|
||||
},
|
||||
getCwd: () => this.lastKnownWorkspaceRoot,
|
||||
})
|
||||
|
||||
@@ -130,18 +130,26 @@ vi.mock("@cline/core", async () => ({
|
||||
loginOpenAICodex: vi.fn(),
|
||||
refreshClineToken: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
// Mirrors the SDK registry: cline-pass stores credentials under "cline".
|
||||
getProviderAuthStorageId: (providerId: string) =>
|
||||
providerId === "cline" || providerId === "cline-pass" ? "cline" : undefined,
|
||||
}))
|
||||
|
||||
// Stateful in-memory provider-settings store. Cline credentials are persisted
|
||||
// to providers.json (via the SDK's ProviderSettingsManager), not to secrets, so
|
||||
// the credential round-trip tests exercise this store.
|
||||
const mockProviderSettings = new Map<string, Record<string, unknown>>()
|
||||
let mockLastUsedProvider: string | undefined
|
||||
vi.mock("./provider-migration", () => ({
|
||||
getProviderSettingsManager: () => ({
|
||||
getProviderSettings: (provider: string) => mockProviderSettings.get(provider),
|
||||
saveProviderSettings: (settings: Record<string, unknown>) => {
|
||||
read: () => ({ lastUsedProvider: mockLastUsedProvider }),
|
||||
saveProviderSettings: (settings: Record<string, unknown>, options?: { setLastUsed?: boolean }) => {
|
||||
const provider = settings.provider as string
|
||||
mockProviderSettings.set(provider, { ...settings })
|
||||
if (options?.setLastUsed !== false) {
|
||||
mockLastUsedProvider = provider
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -237,6 +245,7 @@ describe("AuthService", () => {
|
||||
mockSecrets.clear()
|
||||
mockGlobalState.clear()
|
||||
mockProviderSettings.clear()
|
||||
mockLastUsedProvider = undefined
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -590,6 +599,53 @@ describe("AuthService", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a ClinePass last-used selection when credentials are refreshed on restore (#13501)", async () => {
|
||||
mockLastUsedProvider = "cline-pass"
|
||||
mockProviderSettings.set("cline", {
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:persisted-access-token",
|
||||
refreshToken: "persisted-refresh-token",
|
||||
accountId: "user-123",
|
||||
},
|
||||
})
|
||||
vi.mocked(getValidClineCredentials).mockResolvedValue({
|
||||
access: "refreshed-access-token",
|
||||
refresh: "refreshed-refresh-token",
|
||||
expires: Date.now() + 3600 * 1000,
|
||||
accountId: "user-123",
|
||||
email: "test@example.com",
|
||||
})
|
||||
|
||||
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
|
||||
expect(testAccess(authService)._authenticated).toBe(true)
|
||||
expect(mockLastUsedProvider).toBe("cline-pass")
|
||||
})
|
||||
|
||||
it("claims the last-used slot on restore when no cline-backed provider is selected", async () => {
|
||||
mockLastUsedProvider = undefined
|
||||
mockProviderSettings.set("cline", {
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:persisted-access-token",
|
||||
refreshToken: "persisted-refresh-token",
|
||||
accountId: "user-123",
|
||||
},
|
||||
})
|
||||
vi.mocked(getValidClineCredentials).mockResolvedValue({
|
||||
access: "refreshed-access-token",
|
||||
refresh: "refreshed-refresh-token",
|
||||
expires: Date.now() + 3600 * 1000,
|
||||
accountId: "user-123",
|
||||
email: "test@example.com",
|
||||
})
|
||||
|
||||
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
|
||||
expect(mockLastUsedProvider).toBe("cline")
|
||||
})
|
||||
|
||||
it("sets unauthenticated state when providers.json has no Cline auth", async () => {
|
||||
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { ITelemetryService, OAuthCredentials, ProviderSettings } from "@cline/core"
|
||||
import {
|
||||
createOAuthClientCallbacks,
|
||||
getProviderAuthStorageId,
|
||||
getValidClineCredentials,
|
||||
hashSecret,
|
||||
loginClineOAuth,
|
||||
@@ -184,13 +185,21 @@ function writeClineCredentials(credentials: {
|
||||
auth.expiresAt = credentials.expiresAt
|
||||
}
|
||||
|
||||
// Token refreshes and startup restores also land here. Only claim the
|
||||
// last-used slot when the current selection isn't already a provider
|
||||
// backed by this credential entry (cline-pass stores under "cline") —
|
||||
// otherwise every refresh resets a ClinePass selection back to
|
||||
// usage-billing in the shared providers.json (#13501).
|
||||
const lastUsedProvider = manager.read().lastUsedProvider
|
||||
const setLastUsed = getProviderAuthStorageId(lastUsedProvider ?? "") !== "cline"
|
||||
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...(existing ?? { provider: "cline" }),
|
||||
provider: "cline",
|
||||
auth: auth as { accessToken?: string; refreshToken?: string; accountId?: string; metadata?: AuthMetadata },
|
||||
},
|
||||
{ tokenSource: "oauth", setLastUsed: true },
|
||||
{ tokenSource: "oauth", setLastUsed },
|
||||
)
|
||||
sdkDebug(
|
||||
`[SdkAuthService] writeClineCredentials: wrote (accessHash=${hashSecret(credentials.accessToken)}, refreshHash=${hashSecret(credentials.refreshToken)}, expiresAt=${credentials.expiresAt}, sessionStartedAtMs=${sessionStartedAtMs})`,
|
||||
|
||||
@@ -908,6 +908,35 @@ describe("buildSessionConfig", () => {
|
||||
expect(knownModel.modalities).toEqual({ input: ["text", "image"], output: ["text", "image"] })
|
||||
})
|
||||
|
||||
it("defaults tool-calling on when the preserved capability list is defined but empty", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openrouter",
|
||||
actModeOpenRouterModelId: "mock/empty-capabilities-model",
|
||||
openRouterApiKey: "openrouter-key",
|
||||
// A capabilities field that round-tripped through a boundary
|
||||
// defaulting the missing array to [] — same "no signal" state as
|
||||
// an absent one (modelHasCapability treats both as unspecified).
|
||||
// Before the fix, the strict `=== undefined` guard skipped the
|
||||
// tools seeding, supportsReasoning populated the array, and the
|
||||
// runtime gate silently dropped every tool definition (#13463).
|
||||
actModeOpenRouterModelInfo: {
|
||||
name: "Empty Capabilities Model",
|
||||
contextWindow: 16_000,
|
||||
// Required by the store's isModelInfo gate: without a boolean
|
||||
// supportsPromptCache the state snapshot is rejected and the
|
||||
// model never reaches knownModels at all.
|
||||
supportsPromptCache: false,
|
||||
supportsReasoning: true,
|
||||
capabilities: [],
|
||||
},
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
const knownModel = (config.providerConfig as any).knownModels["mock/empty-capabilities-model"]
|
||||
|
||||
expect(knownModel.capabilities).toEqual(expect.arrayContaining(["reasoning", "tools"]))
|
||||
})
|
||||
|
||||
it("keeps legacy supportsTools=false authoritative for dynamic-list models", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "openrouter",
|
||||
|
||||
@@ -258,13 +258,21 @@ function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
|
||||
setCapability("prompt-cache", modelInfo.supportsPromptCache)
|
||||
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
|
||||
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
|
||||
if (preservedCapabilities === undefined) {
|
||||
if (preservedCapabilities === undefined || preservedCapabilities.length === 0) {
|
||||
// No authoritative SDK list survived to here (dynamic-list snapshot,
|
||||
// fallback metadata, or a custom model). The array we are rebuilding
|
||||
// from booleans must still carry a definitive tool-calling signal,
|
||||
// because a non-empty capabilities array without "tools" reads as
|
||||
// "cannot call tools" to the SDK runtime. Legacy metadata only models
|
||||
// tool support for OpenAI-compatible entries via `supportsTools`.
|
||||
//
|
||||
// An EMPTY array is the same "no signal" state as an absent one —
|
||||
// modelHasCapability treats both as unspecified — and configs carried
|
||||
// over from before the field existed (or round-tripped through a
|
||||
// boundary that defaults it to []) land exactly here. Guarding only
|
||||
// `undefined` let those custom models keep a non-empty, tool-less
|
||||
// array once any boolean projection (e.g. reasoning) populated it,
|
||||
// silently disabling tool calling at the runtime gate (#13463).
|
||||
const supportsTools = (modelInfo as { supportsTools?: boolean }).supportsTools
|
||||
setCapability("tools", supportsTools !== false)
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ export function buildAgentHooks(
|
||||
sessionWorkspaceRoot?: string,
|
||||
): AgentHooks {
|
||||
const hooksEnabled = () => getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled"))
|
||||
// Session-scoped discovery: the shared workspaceRoots global state can be
|
||||
// repointed by another Cline instance, so the factory also scans this
|
||||
// session's own workspace for hook files.
|
||||
// Session-scoped discovery: the session's root is not always among the
|
||||
// window's workspace folders (e.g. the chat-workspace fallback when no
|
||||
// folder is open), so the factory also scans this session's own workspace.
|
||||
const createFactory = () => new HookFactory({ sessionWorkspaceRoot })
|
||||
|
||||
return {
|
||||
@@ -124,7 +124,8 @@ export function buildAgentHooks(
|
||||
}
|
||||
|
||||
const factory = createFactory()
|
||||
if (!(await factory.hasHook("PreToolUse"))) {
|
||||
const runner = await factory.create("PreToolUse")
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -133,7 +134,6 @@ export function buildAgentHooks(
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const runner = await factory.create("PreToolUse")
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
preToolUse: {
|
||||
@@ -183,7 +183,8 @@ export function buildAgentHooks(
|
||||
}
|
||||
|
||||
const factory = createFactory()
|
||||
if (!(await factory.hasHook("PostToolUse"))) {
|
||||
const runner = await factory.create("PostToolUse")
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -192,7 +193,6 @@ export function buildAgentHooks(
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const runner = await factory.create("PostToolUse")
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
postToolUse: {
|
||||
@@ -254,7 +254,8 @@ export function buildAgentHooks(
|
||||
}
|
||||
|
||||
const factory = createFactory()
|
||||
if (!(await factory.hasHook(hookName))) {
|
||||
const runner = await factory.create(hookName)
|
||||
if (runner.isNoOp) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,7 +265,6 @@ export function buildAgentHooks(
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
if (hookName === "TaskComplete") {
|
||||
const runner = await factory.create("TaskComplete")
|
||||
await runner.run({
|
||||
taskId,
|
||||
taskComplete: {
|
||||
@@ -277,7 +277,6 @@ export function buildAgentHooks(
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const runner = await factory.create("TaskCancel")
|
||||
await runner.run({
|
||||
taskId,
|
||||
taskCancel: {
|
||||
@@ -315,7 +314,8 @@ async function runTaskStart(
|
||||
}
|
||||
|
||||
const factory = createFactory()
|
||||
if (!(await factory.hasHook("TaskStart"))) {
|
||||
const runner = await factory.create("TaskStart")
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -324,7 +324,6 @@ async function runTaskStart(
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const taskId = taskIdFromSnapshot(ctx.snapshot)
|
||||
const runner = await factory.create("TaskStart")
|
||||
const result = await runner.run({
|
||||
taskId,
|
||||
taskStart: {
|
||||
@@ -364,7 +363,8 @@ async function runUserPromptSubmit(
|
||||
}
|
||||
|
||||
const factory = createFactory()
|
||||
if (!(await factory.hasHook("UserPromptSubmit"))) {
|
||||
const runner = await factory.create("UserPromptSubmit")
|
||||
if (runner.isNoOp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -372,7 +372,6 @@ async function runUserPromptSubmit(
|
||||
runningTs = runningMsg.ts
|
||||
emitHookMessage?.(runningMsg)
|
||||
|
||||
const runner = await factory.create("UserPromptSubmit")
|
||||
const result = await runner.run({
|
||||
taskId: taskIdFromSnapshot(ctx.snapshot),
|
||||
userPromptSubmit: {
|
||||
|
||||
@@ -490,7 +490,11 @@ describe("createProviderConfigStore", () => {
|
||||
name: "Legacy Custom",
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 64_000,
|
||||
capabilities: ["prompt-cache"],
|
||||
// "tools" must always ride along: legacy ModelInfo carries no
|
||||
// tool-calling boolean, and a persisted capability list without
|
||||
// "tools" reads as authoritative "cannot call tools" to the SDK
|
||||
// runtime (#13463).
|
||||
capabilities: ["tools", "prompt-cache"],
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
|
||||
@@ -731,7 +731,13 @@ function legacyModelInfoToOverrides(modelInfo: ModelInfo, fallback: ModelInfo):
|
||||
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
|
||||
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
|
||||
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
|
||||
const capabilities: string[] = []
|
||||
// Legacy ModelInfo has no tool-calling boolean, so this projection
|
||||
// carries no "no tools" signal. Persisting the list without "tools"
|
||||
// would read as an authoritative tool-less capability list to the SDK
|
||||
// runtime once stored in models.json, silently disabling tool calling
|
||||
// (#13463). Match the providers.json migration, which always writes
|
||||
// "tools" for OpenAI-compatible custom models.
|
||||
const capabilities: string[] = ["tools"]
|
||||
if (supportsVision) capabilities.push("images")
|
||||
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
|
||||
overrides.capabilities = capabilities
|
||||
|
||||
@@ -19,4 +19,22 @@ describe("isToolAutoApproved", () => {
|
||||
|
||||
expect(isToolAutoApproved("run_commands", settings)).toBe(false)
|
||||
})
|
||||
|
||||
it("auto-approves all MCP tools when the Use MCP servers toggle is on", () => {
|
||||
const settings = {
|
||||
...DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
actions: { ...DEFAULT_AUTO_APPROVAL_SETTINGS.actions, useMcp: true },
|
||||
}
|
||||
|
||||
expect(isToolAutoApproved("firecrawl__scrape", settings)).toBe(true)
|
||||
})
|
||||
|
||||
it("prompts for MCP tools when the Use MCP servers toggle is off", () => {
|
||||
const settings = {
|
||||
...DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
actions: { ...DEFAULT_AUTO_APPROVAL_SETTINGS.actions, useMcp: false },
|
||||
}
|
||||
|
||||
expect(isToolAutoApproved("firecrawl__scrape", settings)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { McpHub } from "@/services/mcp/McpHub"
|
||||
* Build SDK `toolPolicies` for tools governed by Cline's auto-approval UI.
|
||||
*
|
||||
* The SDK defaults unlisted tools to auto-approved. For tools controlled by
|
||||
* AutoApproveBar/MCP per-tool settings, force the SDK to call
|
||||
* the AutoApproveBar toggles (including all MCP tools), force the SDK to call
|
||||
* `requestToolApproval`; the approval callback then evaluates the latest
|
||||
* settings and either silently approves or shows the approval UI. This keeps
|
||||
* active sessions in sync when the user toggles auto-approval mid-task.
|
||||
@@ -45,7 +45,7 @@ export function buildToolPolicies(
|
||||
* approval callback, so changes from the AutoApproveBar are respected even if
|
||||
* an SDK session was created before the toggle changed.
|
||||
*/
|
||||
export function isToolAutoApproved(toolName: string, settings: AutoApprovalSettings, mcpHub?: McpHub): boolean {
|
||||
export function isToolAutoApproved(toolName: string, settings: AutoApprovalSettings): boolean {
|
||||
if (isReadTool(toolName)) {
|
||||
return !!settings.actions.readFiles
|
||||
}
|
||||
@@ -59,14 +59,9 @@ export function isToolAutoApproved(toolName: string, settings: AutoApprovalSetti
|
||||
return !!settings.actions.useBrowser
|
||||
}
|
||||
|
||||
const mcpTool = parseMcpToolName(toolName)
|
||||
if (mcpTool) {
|
||||
if (!settings.actions.useMcp || !mcpHub) {
|
||||
return false
|
||||
}
|
||||
const server = mcpHub.getServers().find((entry) => entry.name === mcpTool.serverName)
|
||||
const tool = server?.tools?.find((entry) => entry.name === mcpTool.toolName)
|
||||
return !!tool?.autoApprove
|
||||
if (isMcpToolName(toolName)) {
|
||||
// One global toggle governs all MCP tools; there is no per-tool opt-in.
|
||||
return !!settings.actions.useMcp
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -90,11 +85,8 @@ function isBrowserTool(toolName: string): boolean {
|
||||
return toolName === "fetch_web_content" || toolName === "web_fetch" || toolName === "web_search"
|
||||
}
|
||||
|
||||
function parseMcpToolName(toolName: string): { serverName: string; toolName: string } | undefined {
|
||||
/** MCP tools are registered by `createMcpTools` under `serverName__toolName`. */
|
||||
function isMcpToolName(toolName: string): boolean {
|
||||
const separatorIndex = toolName.indexOf("__")
|
||||
if (separatorIndex <= 0) return undefined
|
||||
const serverName = toolName.substring(0, separatorIndex)
|
||||
const mcpToolName = toolName.substring(separatorIndex + 2)
|
||||
if (!mcpToolName) return undefined
|
||||
return { serverName, toolName: mcpToolName }
|
||||
return separatorIndex > 0 && separatorIndex + 2 < toolName.length
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { DEFAULT_FOCUS_CHAIN_SETTINGS, FocusChainSettings } from "@shared/FocusC
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@shared/McpDisplayMode"
|
||||
import { toLegacyApiProvider } from "@shared/model-catalog/provider-helpers"
|
||||
import { WorkspaceRoot } from "@shared/multi-root/types"
|
||||
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
@@ -83,8 +82,6 @@ const GLOBAL_STATE_FIELDS = {
|
||||
isNewUser: { default: true as boolean },
|
||||
welcomeViewCompleted: { default: undefined as boolean | undefined },
|
||||
mcpDisplayMode: { default: DEFAULT_MCP_DISPLAY_MODE as McpDisplayMode },
|
||||
workspaceRoots: { default: undefined as WorkspaceRoot[] | undefined },
|
||||
primaryRootIndex: { default: 0 as number },
|
||||
multiRootEnabled: { default: true as boolean },
|
||||
lastDismissedInfoBannerVersion: { default: 0 as number },
|
||||
lastDismissedModelBannerVersion: { default: 0 as number },
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// E2E fixture hook: records where it ran and what workspace identity it was
|
||||
// given, so the test can assert workspace-scoped discovery and cwd selection.
|
||||
const fs = require("fs")
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf-8"))
|
||||
fs.writeFileSync("hook-ran.json", JSON.stringify({ cwd: process.cwd(), workspaceRoots: input.workspaceRoots }))
|
||||
console.log(JSON.stringify({ cancel: false }))
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# E2E fixture hook (Windows): records where it ran and what workspace identity
|
||||
# it was given, so the test can assert workspace-scoped discovery and cwd.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$hookInput = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
||||
@{ cwd = (Get-Location).Path; workspaceRoots = $hookInput.workspaceRoots } | ConvertTo-Json -Compress | Set-Content -Path "hook-ran.json"
|
||||
Write-Output '{"cancel": false}'
|
||||
@@ -0,0 +1 @@
|
||||
hook-ran.json
|
||||
@@ -0,0 +1,2 @@
|
||||
# Dedicated e2e fixture for hooks.test.ts — keep hooks out of the shared
|
||||
# workspace fixture so other specs don't pay hook spawn cost per prompt.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect } from "@playwright/test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { e2e, E2ETestHelper } from "./utils/helpers"
|
||||
|
||||
// This spec runs against its own fixture workspace: hooks execute on every
|
||||
// prompt once discovered (hooksEnabled defaults to true), so keeping the hook
|
||||
// out of the shared workspace fixture spares every other prompt-sending spec
|
||||
// the per-prompt hook spawn (a cold PowerShell start on Windows).
|
||||
const hooksE2e = e2e.extend({
|
||||
workspaceDir: async ({}, use) => {
|
||||
await use(path.join(E2ETestHelper.E2E_TESTS_DIR, "fixtures", "workspace-hooks"))
|
||||
},
|
||||
})
|
||||
|
||||
// The fixture ships a UserPromptSubmit hook
|
||||
// (.clinerules/hooks/UserPromptSubmit[.ps1]) that writes hook-ran.json into its
|
||||
// working directory, recording process.cwd() and the workspaceRoots it received
|
||||
// on stdin. Discovery, cwd selection, and hook input metadata all resolve from
|
||||
// the window's actual workspace folders, so the marker must land in this
|
||||
// window's workspace root and name it — regardless of what any other Cline
|
||||
// instance recorded in shared state.
|
||||
hooksE2e("Hooks - workspace hook runs from this window's workspace root", async ({ helper, sidebar, workspaceDir }) => {
|
||||
const markerPath = path.join(workspaceDir, "hook-ran.json")
|
||||
await fs.rm(markerPath, { force: true })
|
||||
|
||||
try {
|
||||
await helper.signin(sidebar)
|
||||
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await expect(inputbox).toBeVisible()
|
||||
await inputbox.fill("Trigger the prompt hook")
|
||||
await sidebar.getByTestId("send-button").click()
|
||||
|
||||
// The hook runs during beforeRun, ahead of the model call, so the
|
||||
// marker exists by the time the mock response renders.
|
||||
await expect(sidebar.getByText("mock Cline API response")).toBeVisible()
|
||||
|
||||
let markerRaw: string | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
markerRaw = await fs.readFile(markerPath, "utf-8").catch(() => undefined)
|
||||
return markerRaw
|
||||
},
|
||||
{ message: "workspace hook should have written hook-ran.json into the workspace root" },
|
||||
)
|
||||
.toBeDefined()
|
||||
const marker = JSON.parse(markerRaw ?? "{}")
|
||||
|
||||
// Normalize with realpath: macOS reports temp/workspace paths under
|
||||
// /private while the fixture path may not carry the prefix.
|
||||
const expectedRoot = await fs.realpath(workspaceDir)
|
||||
expect(await fs.realpath(marker.cwd)).toBe(expectedRoot)
|
||||
|
||||
expect(Array.isArray(marker.workspaceRoots)).toBe(true)
|
||||
const reportedRoots: string[] = []
|
||||
for (const root of marker.workspaceRoots) {
|
||||
reportedRoots.push(await fs.realpath(root))
|
||||
}
|
||||
expect(reportedRoots).toContain(expectedRoot)
|
||||
} finally {
|
||||
await fs.rm(markerPath, { force: true })
|
||||
}
|
||||
})
|
||||
@@ -11,8 +11,7 @@ import { refreshHooks } from "../core/controller/file/refreshHooks"
|
||||
import { toggleHook } from "../core/controller/file/toggleHook"
|
||||
import { hookFileName } from "../core/hooks/__tests__/test-utils"
|
||||
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
|
||||
import { StateManager } from "../core/storage/StateManager"
|
||||
import { HostProvider } from "../hosts/host-provider"
|
||||
import { stubWorkspacePaths } from "./host-provider-test-utils"
|
||||
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
|
||||
|
||||
/**
|
||||
@@ -26,8 +25,6 @@ describe("Hook Management Integration", () => {
|
||||
let globalHooksDir: string
|
||||
let workspaceHooksDir: string
|
||||
let mockController: Controller
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
let getWorkspacePathsStub: sinon.SinonStub
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the hook discovery cache before each test
|
||||
@@ -48,23 +45,8 @@ describe("Hook Management Integration", () => {
|
||||
},
|
||||
} as any
|
||||
|
||||
// Mock StateManager to return test workspace
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: (key: string) => {
|
||||
if (key === "workspaceRoots") {
|
||||
return [{ path: path.join(tempDir, "workspace") }]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as any)
|
||||
|
||||
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
|
||||
getWorkspacePathsStub = sinon.stub().resolves({
|
||||
paths: [path.join(tempDir, "workspace")],
|
||||
})
|
||||
sinon.stub(HostProvider, "workspace").value({
|
||||
getWorkspacePaths: getWorkspacePathsStub,
|
||||
})
|
||||
// Resolve the test workspace as this window's workspace root
|
||||
stubWorkspacePaths(sinon, [path.join(tempDir, "workspace")])
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ import { refreshHooks } from "../core/controller/file/refreshHooks"
|
||||
import { toggleHook } from "../core/controller/file/toggleHook"
|
||||
import { hookFileName, withPlatform } from "../core/hooks/__tests__/test-utils"
|
||||
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
|
||||
import { StateManager } from "../core/storage/StateManager"
|
||||
import { HostProvider } from "../hosts/host-provider"
|
||||
import { stubWorkspacePaths } from "./host-provider-test-utils"
|
||||
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
|
||||
|
||||
/**
|
||||
@@ -27,8 +26,6 @@ describe("Hook Management", () => {
|
||||
let globalHooksDir: string
|
||||
let workspaceHooksDir: string
|
||||
let mockController: Controller
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
let getWorkspacePathsStub: sinon.SinonStub
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the hook discovery cache before each test
|
||||
@@ -49,23 +46,8 @@ describe("Hook Management", () => {
|
||||
},
|
||||
} as any
|
||||
|
||||
// Mock StateManager to return test workspace
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: (key: string) => {
|
||||
if (key === "workspaceRoots") {
|
||||
return [{ path: path.join(tempDir, "workspace") }]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as any)
|
||||
|
||||
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
|
||||
getWorkspacePathsStub = sinon.stub().resolves({
|
||||
paths: [path.join(tempDir, "workspace")],
|
||||
})
|
||||
sinon.stub(HostProvider, "workspace").value({
|
||||
getWorkspacePaths: getWorkspacePathsStub,
|
||||
})
|
||||
// Resolve the test workspace as this window's workspace root
|
||||
stubWorkspacePaths(sinon, [path.join(tempDir, "workspace")])
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type sinon from "sinon"
|
||||
import { CommentReviewControllerCreator, EditPreviewCreator, HostProvider, WebviewProviderCreator } from "@/hosts/host-provider"
|
||||
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
|
||||
@@ -32,3 +33,13 @@ export function setVscodeHostProviderMock(options?: {
|
||||
options?.globalStorageFsPath ?? "/mock/path/to/globalstorage",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs HostProvider.workspace so code under test resolves the given paths as
|
||||
* this window's workspace roots.
|
||||
*/
|
||||
export function stubWorkspacePaths(sandbox: sinon.SinonSandbox, paths: string[]): void {
|
||||
sandbox.stub(HostProvider, "workspace").get(() => ({
|
||||
getWorkspacePaths: async () => ({ paths }),
|
||||
}))
|
||||
}
|
||||
|
||||
+7
-1
@@ -10,6 +10,12 @@ type McpToolRowProps = {
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
// Per-tool MCP auto-approve checkboxes are hidden for now: the SDK approval
|
||||
// path (shared with the CLI and desktop app) is all-or-nothing via the global
|
||||
// "Use MCP servers" toggle, so they would be no-ops. Flip this back on if the
|
||||
// SDK ever gains per-tool approval granularity.
|
||||
export const SHOW_MCP_PER_TOOL_AUTO_APPROVE = false
|
||||
|
||||
const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
|
||||
const { autoApprovalSettings } = useExtensionState()
|
||||
|
||||
@@ -50,7 +56,7 @@ const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
|
||||
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px", flexShrink: 0 }}></span>
|
||||
<span style={{ fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis" }}>{tool.name}</span>
|
||||
</div>
|
||||
{serverName && autoApprovalSettings.actions.useMcp && (
|
||||
{SHOW_MCP_PER_TOOL_AUTO_APPROVE && serverName && autoApprovalSettings.actions.useMcp && (
|
||||
<VSCodeCheckbox
|
||||
checked={tool.autoApprove ?? false}
|
||||
data-tool={tool.name}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import { McpServiceClient } from "@/services/grpc-client"
|
||||
import type { MarketplaceMcpMetadata } from "../ServersToggleList"
|
||||
import McpPromptRow from "./McpPromptRow"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
import McpToolRow from "./McpToolRow"
|
||||
import McpToolRow, { SHOW_MCP_PER_TOOL_AUTO_APPROVE } from "./McpToolRow"
|
||||
|
||||
// constant JSX.Elements
|
||||
const TimeoutOptions = [
|
||||
@@ -328,7 +328,7 @@ const ServerRow = ({
|
||||
<VSCodePanelView id="tools-view">
|
||||
{server.tools && server.tools.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 w-full pt-2">
|
||||
{server.name && autoApprovalSettings.actions.useMcp && (
|
||||
{SHOW_MCP_PER_TOOL_AUTO_APPROVE && server.name && autoApprovalSettings.actions.useMcp && (
|
||||
<VSCodeCheckbox
|
||||
checked={server.tools.every((tool) => tool.autoApprove)}
|
||||
className="mb-1 text-xs"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.56",
|
||||
"version": "3.0.58",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -219,6 +219,7 @@
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
@@ -635,7 +636,7 @@
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"dependencies": {
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
@@ -644,7 +645,7 @@
|
||||
},
|
||||
"sdk/packages/core": {
|
||||
"name": "@cline/core",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"dependencies": {
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -682,7 +683,7 @@
|
||||
},
|
||||
"sdk/packages/llms": {
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^5.0.50",
|
||||
"@ai-sdk/anthropic": "^4.0.36",
|
||||
@@ -698,6 +699,7 @@
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@jerome-benoit/sap-ai-provider": "4.8.0",
|
||||
"@langfuse/core": "5.10.1",
|
||||
"@langfuse/otel": "5.10.1",
|
||||
"@langfuse/vercel-ai-sdk": "5.9.1",
|
||||
"@openrouter/ai-sdk-provider": "^3",
|
||||
@@ -728,14 +730,14 @@
|
||||
},
|
||||
"sdk/packages/sdk": {
|
||||
"name": "@cline/sdk",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"sdk/packages/shared": {
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"jsonrepair": "^3.13.2",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ sidebarTitle: "MCP Server Controls"
|
||||
description: "Enterprise controls for MCP server allowlisting and remote MCP server management"
|
||||
---
|
||||
|
||||
Cline no longer exposes an MCP Marketplace browse/install surface in the SDK-backed VS Code extension. For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and now govern MCP server access: local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers.
|
||||
For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and govern MCP access: the MCP marketplace in the Customize view, local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers. `mcpMarketplaceEnabled: false` also hides the MCP marketplace, and `allowedMCPServers` also restricts which marketplace entries are visible and installable.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+17
-6
@@ -130,12 +130,16 @@ Completion telemetry is anchored to the assistant's explicit completion
|
||||
declaration, not session shutdown. After each agent turn, the local
|
||||
runtime inspects `AgentResult.toolCalls` and emits `task.completed` the
|
||||
moment a successful `submit_and_exit` (the SDK analog of original
|
||||
Cline's `attempt_completion`) is observed. `shutdownSession(...)`
|
||||
retains a fallback emission for completed sessions that finished
|
||||
without an explicit completion-tool observation, so non-interactive
|
||||
runs not using the yolo preset still produce a `task.completed` signal.
|
||||
Each session emits at most one `task.completed`. See `DOC.md` for the
|
||||
event payload and `source` field.
|
||||
Cline's `attempt_completion`) is observed. A single teardown choke
|
||||
point (`emitTaskCompletedOnTeardown(...)`) retains a fallback emission
|
||||
for sessions whose final turn finished cleanly without an explicit
|
||||
completion-tool observation (non-interactive runs not using the yolo
|
||||
preset, or hosts that disable `submit_and_exit`). It is invoked from
|
||||
every session exit path — both `shutdownSession(...)` and
|
||||
`releaseSessionRuntime(...)` — so the emission never depends on which
|
||||
teardown branch a stop routes through. Each session emits at most one
|
||||
`task.completed`. See `DOC.md` for the event payload and `source`
|
||||
field.
|
||||
|
||||
### Hub-Backed Runtime
|
||||
|
||||
@@ -554,6 +558,13 @@ agent-team task board. Shared, browser-safe contracts use `AgendaTaskRecord`
|
||||
and `AgendaTaskRunRecord`; orchestration and persistence remain in
|
||||
`@cline/core`.
|
||||
|
||||
> **Status:** the agent-facing `kind: "todo"` half of the `tasks` tool and the
|
||||
> desktop Agenda UI are temporarily disabled while the Agenda UX is reworked
|
||||
> (`AGENDA_TODO_TOOL_ENABLED` in `hub-server-transport.ts` and
|
||||
> `AGENDA_UI_ENABLED` in the desktop webview). The backend described below —
|
||||
> the manager, storage, `task.*` Hub commands, and desktop plumbing — stays
|
||||
> fully wired, and the schedule kind remains active.
|
||||
|
||||
### Authority and persistence
|
||||
|
||||
- A Hub process owns one Agenda task manager. Its
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.79
|
||||
|
||||
- The hub's durable event log is now capped at 64 MiB on disk. Event envelopes carrying full session snapshots could previously accumulate to tens of gigabytes, since row and time retention alone did not bound file size and deletes never shrink a SQLite file. Oldest events are dropped first and the file is vacuumed to return the space, and pruning now also runs after every 16 MiB appended instead of waiting for the hourly sweep
|
||||
- Fixed `task.completed` telemetry being dropped for most interactive sessions. The event is now emitted from every session teardown path, exactly once per session
|
||||
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 0.0.78
|
||||
|
||||
- The hub can now be drained and upgraded without losing work. A draining hub refuses new mutating work while it finishes what it is running, a durable event log lets a reconnecting client replay everything it missed, and durable runs are queued rather than dropped. Replayed events are deduped by event id, so an event that arrives on both the replay and the live stream is delivered once
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was synthesized from convenience flags like `supportsReasoning`. An inferred list read as an authoritative denial and stripped every tool from the request; explicitly authored capability lists still decide
|
||||
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs. Hub sessions rebuild client name and version from request headers, and delegated agents group under their parent session instead of appearing as unattributed traces
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT). If you use one of those providers without pinning a model, you will get a different default
|
||||
|
||||
## 0.0.77
|
||||
|
||||
- The `tasks` tool (durable todos and one-time or recurring agent schedules) is now scoped to the clients that can service it. Hosts declare their client type and the core tool catalog resolves availability centrally, so CLI and VS Code sessions no longer register a tool they cannot act on; hub sessions resolve the same way from the requesting client's metadata
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1026,6 +1026,9 @@ export class AgentRuntime {
|
||||
const usageBeforeModel = cloneUsage(this.state.usage);
|
||||
const modelRequestMetadata = omitUndefinedValues({
|
||||
distinctId: trimNonEmpty(this.config.distinctId),
|
||||
clientName: trimNonEmpty(this.config.clientName),
|
||||
clientVersion: trimNonEmpty(this.config.clientVersion),
|
||||
clineCoreVersion: trimNonEmpty(this.config.clineCoreVersion),
|
||||
sessionId: trimNonEmpty(this.config.sessionId),
|
||||
agentId: this.state.agentId,
|
||||
conversationId: trimNonEmpty(this.config.conversationId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -131,6 +131,31 @@ describe("createApplyPatchExecutor", () => {
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("hello");
|
||||
});
|
||||
|
||||
// The CRLF branch is exercised for real by Windows CI, where os.EOL is
|
||||
// "\r\n" (github.com/cline/cline/issues/13504).
|
||||
it("adds new files with the platform-native line ending", async () => {
|
||||
const filePath = path.join(tempDir, "added.txt");
|
||||
const execute = createApplyPatchExecutor();
|
||||
|
||||
await execute(
|
||||
{
|
||||
input: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: added.txt",
|
||||
"+one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
tempDir,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
|
||||
["one", "two"].join(os.EOL),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects incomplete patch sentinels", async () => {
|
||||
const execute = createApplyPatchExecutor();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PatchParser,
|
||||
type PatchWarning,
|
||||
} from "./apply-patch-parser";
|
||||
import { normalizeNewFileLineEndings } from "./line-endings";
|
||||
|
||||
export interface PatchFileChange {
|
||||
type: PatchActionType;
|
||||
@@ -232,7 +233,7 @@ function patchToChanges(
|
||||
}
|
||||
changes[filePath] = {
|
||||
type: PatchActionType.ADD,
|
||||
newContent: action.newFile,
|
||||
newContent: normalizeNewFileLineEndings(action.newFile),
|
||||
};
|
||||
break;
|
||||
case PatchActionType.UPDATE:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user