mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e31fb9e0d | ||
|
|
e0eb0167da | ||
|
|
d5748b2939 | ||
|
|
ffd6a6b1db | ||
|
|
51784a3bf1 | ||
|
|
149abb0ddb | ||
|
|
b3cee3f973 | ||
|
|
c68f553856 | ||
|
|
fd794ce3be | ||
|
|
f5f1071af2 | ||
|
|
4540390096 | ||
|
|
b590e14b91 | ||
|
|
54cc156089 | ||
|
|
6c599d18c3 | ||
|
|
513aacc0e6 | ||
|
|
ff2f860941 | ||
|
|
62a6b5a0b2 | ||
|
|
0caf617b50 | ||
|
|
930575991d | ||
|
|
e40d7d44ae | ||
|
|
e973ce4f33 | ||
|
|
4f25692d70 | ||
|
|
b0bba2e5d6 | ||
|
|
a6e2c0c431 | ||
|
|
d011d049a1 | ||
|
|
e09afced69 | ||
|
|
3f83be51a2 | ||
|
|
1efe5577e1 | ||
|
|
9cbc24d6e5 | ||
|
|
551286d33b | ||
|
|
361ac90978 | ||
|
|
4eb7402334 | ||
|
|
ffb61a865f | ||
|
|
d3616b96ae | ||
|
|
98e5458b09 | ||
|
|
031b8d94e1 | ||
|
|
e5bcba8ef9 | ||
|
|
83182c0d96 | ||
|
|
40ebd09dfa | ||
|
|
7bc18f7e15 | ||
|
|
fad8006730 | ||
|
|
28f35a2ec8 | ||
|
|
1e24807d1a | ||
|
|
a5f90e0d53 | ||
|
|
adabfc6bd5 | ||
|
|
397d6f344f | ||
|
|
8ca3068b73 | ||
|
|
6e6befdb65 | ||
|
|
71536e55aa | ||
|
|
7348ba1847 | ||
|
|
3e96fc6112 | ||
|
|
d84d09c543 | ||
|
|
6f7f817d63 | ||
|
|
c18d9478ba | ||
|
|
574b8eb45e | ||
|
|
81cce3d70e | ||
|
|
e1352fa709 | ||
|
|
394fb04518 | ||
|
|
f1aebbfd5a | ||
|
|
543dd0d818 | ||
|
|
1f2cbbeb9f | ||
|
|
b1a89156d6 | ||
|
|
1d7d9ce5e2 | ||
|
|
78b7c3d8ac | ||
|
|
d626cfb0b5 | ||
|
|
e14f354c59 | ||
|
|
41ba332f0a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
|
||||
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
apps/examples/desktop-app/.cursor/settings.json
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
|
||||
- Support Fable 5 on Vertex.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
|
||||
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
|
||||
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respect your configured max output tokens when the compaction summarizer requests a summary.
|
||||
- Remove the stale "Double-Check Completion" feature tip.
|
||||
|
||||
## [4.1.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
|
||||
- Bring back a copy button on turn-final response rows.
|
||||
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
|
||||
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
|
||||
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
|
||||
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
|
||||
- Settle the turn phase when a mode switch aborts a running turn.
|
||||
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
|
||||
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
|
||||
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
|
||||
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
|
||||
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
|
||||
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
|
||||
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
|
||||
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
|
||||
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
|
||||
|
||||
## [4.1.6]
|
||||
|
||||
### Added
|
||||
|
||||
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
|
||||
|
||||
## [4.1.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
|
||||
|
||||
## [4.1.4]
|
||||
|
||||
### Added
|
||||
|
||||
- Recognize Chutes as a provider.
|
||||
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
|
||||
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
|
||||
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
|
||||
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
|
||||
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
|
||||
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
|
||||
- Show installed plugins under their real package names instead of all appearing as "index".
|
||||
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
|
||||
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
|
||||
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
|
||||
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
|
||||
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
|
||||
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
|
||||
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
|
||||
|
||||
## [4.1.3]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.52
|
||||
|
||||
- Added `cline mcp uninstall` for removing an installed MCP server
|
||||
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
|
||||
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
|
||||
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
|
||||
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
|
||||
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
|
||||
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
|
||||
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
|
||||
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
|
||||
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
|
||||
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
|
||||
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
|
||||
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
|
||||
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
|
||||
|
||||
## 3.0.51
|
||||
|
||||
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
|
||||
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
|
||||
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
|
||||
|
||||
## 3.0.50
|
||||
|
||||
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
|
||||
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
|
||||
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
|
||||
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
|
||||
- Auto-approval settings are now honored over ACP
|
||||
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
|
||||
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
|
||||
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
|
||||
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
|
||||
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
|
||||
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
|
||||
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
|
||||
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
|
||||
|
||||
## 3.0.49
|
||||
|
||||
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
|
||||
|
||||
+4
-2
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
|
||||
|
||||
Schedule agents on cron-like intervals or external events.
|
||||
|
||||
If `--provider` and `--model` are omitted, schedules use the last configured
|
||||
provider and model. If only `--provider` is given, the schedule uses that
|
||||
provider's saved model.
|
||||
|
||||
```sh
|
||||
cline schedule create "Daily code review" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--prompt "Review PRs opened yesterday and summarize issues." \
|
||||
--workspace /path/to/repo \
|
||||
--provider cline \
|
||||
--model openai/gpt-5.3-codex \
|
||||
--timeout 3600 \
|
||||
--tags automation,review
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.49",
|
||||
"version": "3.0.52",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -767,6 +767,71 @@ Break work into clear steps.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes mcp uninstall and its rm alias", () => {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
|
||||
tempDirs.push(tempRoot);
|
||||
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
|
||||
const writeSettings = () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: { transport: { type: "stdio", command: "node" } },
|
||||
remote: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
const readServers = () =>
|
||||
(
|
||||
JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
}
|
||||
).mcpServers ?? {};
|
||||
|
||||
writeSettings();
|
||||
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(uninstallResult.status).toBe(0);
|
||||
expect(asText(uninstallResult.stdout)).toContain(
|
||||
"Uninstalled MCP server docs.",
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["remote"]);
|
||||
|
||||
writeSettings();
|
||||
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(aliasResult.status).toBe(0);
|
||||
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
|
||||
name: "remote",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(Object.keys(readServers())).toEqual(["docs"]);
|
||||
|
||||
writeSettings();
|
||||
const missingResult = runCli(["mcp", "remove", "missing"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(missingResult.status).toBe(1);
|
||||
expect(asText(missingResult.stderr)).toContain(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
runMcpUninstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mcp-remote marketplace entries as native remote servers", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "linear",
|
||||
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "linear",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp uninstall command", () => {
|
||||
let root = "";
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSettings(): string {
|
||||
const settingsPath = join(root, "cline_mcp_settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
keep: {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
function readSettings(settingsPath: string): Record<string, unknown> & {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
} {
|
||||
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { mcpServers?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("uninstalls the requested server and reports success", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
|
||||
const written = readSettings(settingsPath);
|
||||
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
|
||||
expect(written.mcpServers?.keep).toEqual({
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
});
|
||||
expect(written.customTopLevelKey).toBe(true);
|
||||
});
|
||||
|
||||
it("prints uninstall JSON with --json", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
|
||||
name: "docs",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports an error and leaves settings intact for an unknown server", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "missing",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(writeln).not.toHaveBeenCalled();
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("rejects a blank name without rewriting settings", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: " ",
|
||||
settingsPath,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
buildMcpInstallTransport as buildCoreMcpInstallTransport,
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
type McpUninstallOptions as CoreMcpUninstallOptions,
|
||||
type McpUninstallResult as CoreMcpUninstallResult,
|
||||
uninstallMcpServer,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
const { name, transport } = buildCoreMcpInstallTransport(options);
|
||||
if (transport.type === "stdio") {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
type: transport.type,
|
||||
command: [transport.command, ...(transport.args ?? [])]
|
||||
.map(quoteCommandArg)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
type: transport.type,
|
||||
url: transport.url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
|
||||
io?: McpCommandIo;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
|
||||
|
||||
export function uninstallMcpServerDirect(
|
||||
options: McpUninstallOptions,
|
||||
): McpUninstallDirectResult {
|
||||
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMcpUninstallCommand(
|
||||
options: McpUninstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const name = options.name?.trim() ?? "";
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const result = uninstallMcpServerDirect({ ...options, name });
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
providers: {} as Record<string, { provider?: string; model?: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockProviderSettings.providers[providerId];
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
parseHubEndpointOverride: (rawAddress: string | undefined) => {
|
||||
@@ -47,6 +65,8 @@ async function runScheduleCommand(
|
||||
describe("runScheduleCommand list output", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it('prints "No schedules found." for empty non-json list output', async () => {
|
||||
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand create", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("uses the last used provider and model when both flags are omitted", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: (text?: string) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an explicit provider with that provider's configured model", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
};
|
||||
mockProviderSettings.providers.anthropic = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when an explicit provider has no configured model and no model flag", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand import", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("preserves exported modelSelection providerId/modelId values", async () => {
|
||||
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
|
||||
describe("runScheduleCommand export", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("writes JSON content to the --to file path", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
registerScheduleImportCommand,
|
||||
registerScheduleUpdateCommand,
|
||||
} from "./import-export";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
export function registerScheduleCommands(
|
||||
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--model <model>", "Model to use")
|
||||
.option("--provider <id>", "Provider ID")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
.option("--timeout <seconds>", "Timeout in seconds");
|
||||
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
|
||||
parseJsonObjectFlag(opts.metadataJson),
|
||||
opts,
|
||||
);
|
||||
const modelSelection = resolveScheduleModelSelection({
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
});
|
||||
const created = await client.createSchedule({
|
||||
name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -18,8 +17,13 @@ import {
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
!Array.isArray(parsed.modelSelection)
|
||||
? (parsed.modelSelection as Record<string, unknown>)
|
||||
: undefined;
|
||||
const provider = String(
|
||||
modelSelection?.providerId ??
|
||||
parsed.providerId ??
|
||||
parsed.provider ??
|
||||
"cline",
|
||||
).trim();
|
||||
const model = String(
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
return resolveScheduleModelSelection({
|
||||
provider:
|
||||
stringValue(modelSelection?.providerId) ??
|
||||
stringValue(parsed.providerId) ??
|
||||
stringValue(parsed.provider),
|
||||
model:
|
||||
stringValue(modelSelection?.modelId) ??
|
||||
stringValue(parsed.modelId) ??
|
||||
stringValue(parsed.model),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerScheduleExportCommand(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
|
||||
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
|
||||
|
||||
interface ProviderSettingsReader {
|
||||
getLastUsedProviderSettings(): ProviderSettings | undefined;
|
||||
getProviderSettings(providerId: string): ProviderSettings | undefined;
|
||||
}
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveScheduleModelSelection(
|
||||
options: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
providerSettingsManager?: ProviderSettingsReader,
|
||||
): { provider: string; model: string } {
|
||||
const explicitProvider = trimToUndefined(options.provider);
|
||||
const explicitModel = trimToUndefined(options.model);
|
||||
if (explicitProvider && explicitModel) {
|
||||
return { provider: explicitProvider, model: explicitModel };
|
||||
}
|
||||
|
||||
const manager = providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const lastUsedSettings = manager.getLastUsedProviderSettings();
|
||||
const provider =
|
||||
explicitProvider ??
|
||||
trimToUndefined(lastUsedSettings?.provider) ??
|
||||
DEFAULT_SCHEDULE_PROVIDER;
|
||||
const selectedProviderSettings = explicitProvider
|
||||
? manager.getProviderSettings(provider)
|
||||
: lastUsedSettings;
|
||||
const model =
|
||||
explicitModel ??
|
||||
trimToUndefined(selectedProviderSettings?.model) ??
|
||||
(provider === DEFAULT_SCHEDULE_PROVIDER
|
||||
? CLINE_DEFAULT_MODEL_ID
|
||||
: undefined);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -505,6 +505,24 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const mcpUninstallCmd = mcpCmd
|
||||
.command("uninstall")
|
||||
.alias("remove")
|
||||
.alias("rm")
|
||||
.description("Uninstall an MCP server by name")
|
||||
.argument("<name>", "MCP server name")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string) => {
|
||||
const opts = mcpUninstallCmd.opts<{
|
||||
json?: boolean;
|
||||
}>();
|
||||
const { runMcpUninstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpUninstallCommand({
|
||||
name,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
const createDoctorRuntimeCommand = async () => {
|
||||
const { createDoctorCommand } = await import("./commands/doctor");
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page-object helpers for the /settings view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
|
||||
import { expectVisible } from "../terminal.js";
|
||||
|
||||
const TAB_ORDER = [
|
||||
"API",
|
||||
"Auto-approve",
|
||||
"Features",
|
||||
"Account",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
export type SettingsTab = (typeof TAB_ORDER)[number];
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
|
||||
* Waits for each tab's content to appear before pressing the next key, making
|
||||
* navigation deterministic regardless of machine speed.
|
||||
*/
|
||||
export async function goToSettingsTab(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
const targetIndex = TAB_ORDER.indexOf(tab);
|
||||
for (let i = 0; i < targetIndex; i++) {
|
||||
terminal.keyRight();
|
||||
// Wait for the next tab's content to appear before pressing again
|
||||
await assertTabContent(terminal, TAB_ORDER[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert the API tab content is visible */
|
||||
export async function assertApiTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Provider:", "Model ID:"]);
|
||||
}
|
||||
|
||||
/** Assert the Auto-approve tab content is visible */
|
||||
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Read project files",
|
||||
"Execute safe commands",
|
||||
"Edit project files",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Features tab content is visible */
|
||||
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Subagents",
|
||||
"Web tools",
|
||||
"Double-check completion",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Account tab content is visible */
|
||||
export async function assertAccountTab(terminal: Terminal): Promise<void> {
|
||||
// The account tab shows sign-in options when not authenticated to Cline
|
||||
await expectVisible(terminal, /sign in|sign out/i);
|
||||
}
|
||||
|
||||
/** Assert the Other tab content is visible */
|
||||
export async function assertOtherTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the content for a given tab is visible.
|
||||
* Used internally by goToSettingsTab to confirm navigation landed correctly.
|
||||
*/
|
||||
export async function assertTabContent(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
switch (tab) {
|
||||
case "API":
|
||||
return assertApiTab(terminal);
|
||||
case "Auto-approve":
|
||||
return assertAutoApproveTab(terminal);
|
||||
case "Features":
|
||||
return assertFeaturesTab(terminal);
|
||||
case "Account":
|
||||
return assertAccountTab(terminal);
|
||||
case "Other":
|
||||
return assertOtherTab(terminal);
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,10 @@ function QueuedPromptRow(props: {
|
||||
flexGrow={1}
|
||||
/>
|
||||
) : (
|
||||
<text fg={selected ? theme.textOnSelection : undefined} flexGrow={1}>
|
||||
<text
|
||||
fg={selected ? theme.textOnSelection : theme.defaultForeground}
|
||||
flexGrow={1}
|
||||
>
|
||||
{truncatePrompt(item.prompt)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { getMcpDescription } from "./interactive-config";
|
||||
|
||||
describe("getMcpDescription", () => {
|
||||
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
|
||||
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
|
||||
it("shows one configured timeout when it also applies to initialize", () => {
|
||||
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: Number.NaN,
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:path";
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
hasMcpSettingsFile,
|
||||
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
|
||||
const timeoutDescription =
|
||||
registration.transport.type === "stdio" &&
|
||||
!isMcpTimeoutConfigured(registration.timeoutSeconds)
|
||||
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
|
||||
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
|
||||
: `timeout ${timeoutSeconds}s`;
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,35 @@ describe("hydrateSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/13036:
|
||||
// persisted sessions with malformed tool inputs must stay resumable.
|
||||
it("hydrates tool calls with malformed inputs without throwing", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "run_commands",
|
||||
input: { command: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolName: "run_commands",
|
||||
inputSummary: "",
|
||||
rawInput: { command: null },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves mode undefined for transcripts without user_input wrappers", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "plain old message" },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
extractFullOutputText,
|
||||
parseReadFilesInput,
|
||||
} from "./tool-parsing";
|
||||
|
||||
describe("buildReadFilesKeys", () => {
|
||||
it("produces unique keys when the same path is read twice", () => {
|
||||
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
|
||||
expect(buildReadFilesKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFullOutputText", () => {
|
||||
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "# Memory\n\nline one" },
|
||||
{ type: "text", text: "line two" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
|
||||
});
|
||||
|
||||
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "before" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
|
||||
},
|
||||
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
|
||||
{ type: "text", text: "after" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe(
|
||||
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
|
||||
);
|
||||
});
|
||||
|
||||
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
|
||||
"[image: image/png]",
|
||||
"A".repeat(76),
|
||||
"A".repeat(76),
|
||||
"A".repeat(8),
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts embedded resource text from MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
|
||||
});
|
||||
|
||||
it("falls back to pretty JSON for objects without text content", () => {
|
||||
const raw = { structuredContent: { ok: true } };
|
||||
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
|
||||
return { task: input.task };
|
||||
}
|
||||
|
||||
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
|
||||
// line-based collapse stays compact and expand shows the full data.
|
||||
function chunkBase64(data: string): string {
|
||||
return data.match(/.{1,76}/g)?.join("\n") ?? data;
|
||||
}
|
||||
|
||||
export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
if (typeof raw === "string") return raw;
|
||||
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
|
||||
// text so multi-line results keep real newlines instead of being
|
||||
// JSON-escaped into one giant line that floods the terminal (#13038).
|
||||
// Non-text blocks keep their identifying metadata plus their base64
|
||||
// payloads so mixed results are not silently truncated.
|
||||
const content = (raw as { content?: unknown }).content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((part) => {
|
||||
if (!isRecord(part)) return "";
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "resource" && isRecord(part.resource)) {
|
||||
if (typeof part.resource.text === "string") {
|
||||
return part.resource.text;
|
||||
}
|
||||
if (typeof part.resource.blob === "string" && part.resource.blob) {
|
||||
const label =
|
||||
typeof part.resource.uri === "string"
|
||||
? `[resource: ${part.resource.uri}]`
|
||||
: "[resource]";
|
||||
return `${label}\n${chunkBase64(part.resource.blob)}`;
|
||||
}
|
||||
if (typeof part.resource.uri === "string") {
|
||||
return `[resource: ${part.resource.uri}]`;
|
||||
}
|
||||
}
|
||||
if (part.type === "resource_link" && typeof part.uri === "string") {
|
||||
return `[resource_link: ${part.uri}]`;
|
||||
}
|
||||
if (
|
||||
(part.type === "image" || part.type === "audio") &&
|
||||
typeof part.mimeType === "string"
|
||||
) {
|
||||
if (typeof part.data === "string" && part.data) {
|
||||
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
|
||||
}
|
||||
return `[${part.type}: ${part.mimeType}]`;
|
||||
}
|
||||
return typeof part.type === "string" ? `[${part.type}]` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isCliHookPayload,
|
||||
normalizeAutoApproveArgs,
|
||||
parseArgs,
|
||||
truncate,
|
||||
} from "./helpers";
|
||||
|
||||
type EnvSnapshot = {
|
||||
@@ -374,6 +375,73 @@ describe("format helpers", () => {
|
||||
).toBe("first (+2 more)");
|
||||
expect(formatToolOutput(null)).toBe("");
|
||||
});
|
||||
|
||||
// Regression tests for https://github.com/cline/cline/issues/13036:
|
||||
// malformed tool inputs crossing the model/tool boundary must never
|
||||
// throw from display-only formatters.
|
||||
it("does not crash on run_commands with a null command", () => {
|
||||
expect(formatToolInput("run_commands", { command: null })).toBe("");
|
||||
});
|
||||
|
||||
it("does not crash on run_commands with a non-string command", () => {
|
||||
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
|
||||
'{"nested":true}',
|
||||
);
|
||||
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
|
||||
"42",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid empty-string args in structured command summaries", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
|
||||
}),
|
||||
).toBe("grep pattern file.txt");
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "git", args: [null, "status", undefined] }],
|
||||
}),
|
||||
).toBe("git status");
|
||||
});
|
||||
|
||||
it("skips null entries in run_commands command arrays", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
|
||||
).toBe("echo hi");
|
||||
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not crash on fetch_web_content with malformed requests", () => {
|
||||
expect(
|
||||
formatToolInput("fetch_web_content", {
|
||||
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
|
||||
}),
|
||||
).toBe("https://example.com, 42");
|
||||
});
|
||||
|
||||
it("falls back to an empty summary for unserializable inputs", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(formatToolInput("unknown_tool", circular)).toBe("");
|
||||
expect(formatToolOutput(circular)).toBe("");
|
||||
expect(
|
||||
formatToolInput("unknown_tool", {
|
||||
toJSON() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("truncates non-string values without throwing", () => {
|
||||
expect(truncate(null, 10)).toBe("");
|
||||
expect(truncate(undefined, 10)).toBe("");
|
||||
expect(truncate(42, 10)).toBe("42");
|
||||
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook payload validation and audit logging", () => {
|
||||
|
||||
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function truncate(str: string, maxLen: number): string {
|
||||
const oneLine = str.replace(/\n/g, " ").trim();
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an untrusted runtime value into a display string without ever
|
||||
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
|
||||
* not match their TypeScript annotations (e.g. `{ command: null }`).
|
||||
*/
|
||||
export function toDisplayString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return safeJsonStringify(value);
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(value: unknown, maxLen: number): string {
|
||||
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
|
||||
if (oneLine.length <= maxLen) {
|
||||
return oneLine;
|
||||
}
|
||||
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
|
||||
return cmd;
|
||||
}
|
||||
if (cmd && typeof cmd === "object" && "command" in cmd) {
|
||||
const structured = cmd as { command: string; args?: unknown };
|
||||
const args = Array.isArray(structured.args) ? structured.args : [];
|
||||
const structured = cmd as { command?: unknown; args?: unknown };
|
||||
const command = toDisplayString(structured.command);
|
||||
// Drop only nullish entries: they carry no display value, while an
|
||||
// empty string is a valid argv entry that must stay in the summary.
|
||||
const args = Array.isArray(structured.args)
|
||||
? structured.args
|
||||
.filter((arg) => arg !== null && arg !== undefined)
|
||||
.map(toDisplayString)
|
||||
: [];
|
||||
if (args.length === 0) {
|
||||
return structured.command;
|
||||
return command;
|
||||
}
|
||||
return `${structured.command} ${args.join(" ")}`;
|
||||
return `${command} ${args.join(" ")}`;
|
||||
}
|
||||
return String(cmd);
|
||||
return toDisplayString(cmd);
|
||||
}
|
||||
|
||||
function summarizeRunCommandsInput(input: unknown): string {
|
||||
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(formatStructuredCommand).join("; ");
|
||||
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (obj.commands !== undefined) {
|
||||
if (Array.isArray(obj.commands)) {
|
||||
return obj.commands.map(formatStructuredCommand).join("; ");
|
||||
return obj.commands
|
||||
.map(formatStructuredCommand)
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
return formatStructuredCommand(obj.commands);
|
||||
}
|
||||
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
if (Array.isArray(obj.requests)) {
|
||||
return truncate(
|
||||
obj.requests
|
||||
.map((r) => r.url)
|
||||
.map((r) =>
|
||||
r && typeof r === "object" && "url" in r
|
||||
? toDisplayString((r as { url?: unknown }).url)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
120,
|
||||
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
return "list";
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(input), 60);
|
||||
return truncate(input, 60);
|
||||
}
|
||||
|
||||
export function formatToolOutput(output: unknown): string {
|
||||
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ") || "Successfully read image"
|
||||
: String(result ?? "");
|
||||
: toDisplayString(result);
|
||||
return truncate(resultStr, 80);
|
||||
}
|
||||
return truncate(JSON.stringify(item), 80);
|
||||
return truncate(item, 80);
|
||||
})
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
|
||||
return `${results[0]} (+${results.length - 1} more)`;
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(output), 100);
|
||||
return truncate(output, 100);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type McpServerEntry,
|
||||
type McpTransport,
|
||||
removeServer,
|
||||
setServerOAuthClient,
|
||||
toggleServer,
|
||||
updateServer,
|
||||
} from "./settings";
|
||||
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
|
||||
interface UrlServerConfig {
|
||||
transport: McpTransport;
|
||||
authMode: RemoteAuthMode;
|
||||
oauthClient?: { clientId: string; clientSecret?: string };
|
||||
}
|
||||
|
||||
export interface McpAddDefaults {
|
||||
@@ -196,7 +198,29 @@ async function collectUrlTransport(
|
||||
});
|
||||
if (isCancel(authMode)) return null;
|
||||
|
||||
if (authMode === "oauth" || authMode === "none") {
|
||||
if (authMode === "oauth") {
|
||||
const clientId = await p.text({
|
||||
message: "OAuth client ID (leave empty for dynamic registration)",
|
||||
});
|
||||
if (isCancel(clientId)) return null;
|
||||
const normalizedClientId = (clientId as string).trim();
|
||||
let clientSecret: string | undefined;
|
||||
if (normalizedClientId) {
|
||||
const secret = await p.password({
|
||||
message: "OAuth client secret (leave empty for public clients)",
|
||||
});
|
||||
if (isCancel(secret)) return null;
|
||||
clientSecret = (secret as string).trim() || undefined;
|
||||
}
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
oauthClient: normalizedClientId
|
||||
? { clientId: normalizedClientId, clientSecret }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (authMode === "none") {
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport(defaults?.command);
|
||||
} else {
|
||||
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
);
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
addServer(serverName, transport);
|
||||
if (authMode !== "oauth") {
|
||||
clearServerOAuth(serverName);
|
||||
} else {
|
||||
setServerOAuthClient(serverName, oauthClient);
|
||||
}
|
||||
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
|
||||
if (authMode === "oauth") {
|
||||
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport();
|
||||
} else {
|
||||
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
updateServer(name, transport);
|
||||
if (type === "stdio" || authMode !== "oauth") {
|
||||
clearServerOAuth(name);
|
||||
} else {
|
||||
setServerOAuthClient(name, oauthClient);
|
||||
}
|
||||
p.log.success(`Updated "${name}"`);
|
||||
if (authMode === "oauth") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
clearServerOAuth,
|
||||
loadServers,
|
||||
removeServer,
|
||||
setServerOAuthClient,
|
||||
} from "./settings";
|
||||
|
||||
describe("MCP wizard settings", () => {
|
||||
@@ -139,4 +140,35 @@ describe("MCP wizard settings", () => {
|
||||
|
||||
await expect(readFile(settingsPath, "utf8")).resolves.toBe(before);
|
||||
});
|
||||
|
||||
it("clears OAuth state when the configured client changes", async () => {
|
||||
const settingsPath = await useTempSettingsPath();
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://api.githubcopilot.com/mcp/",
|
||||
},
|
||||
oauthClient: { clientId: "old-client", clientSecret: "old-secret" },
|
||||
oauth: { tokens: { access_token: "old-token" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
setServerOAuthClient("github", {
|
||||
clientId: "new-client",
|
||||
clientSecret: "new-secret",
|
||||
});
|
||||
|
||||
const [github] = loadServers();
|
||||
expect(github?.oauth).toBeUndefined();
|
||||
expect(github?.oauthClient).toEqual({
|
||||
clientId: "new-client",
|
||||
clientSecret: "new-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthClientConfig,
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
@@ -10,6 +11,7 @@ export interface McpServerEntry {
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
disabled?: boolean;
|
||||
oauthClient?: McpServerOAuthClientConfig;
|
||||
oauth?: McpServerOAuthState;
|
||||
}
|
||||
|
||||
@@ -49,6 +51,9 @@ export function loadServers(): McpServerEntry[] {
|
||||
name,
|
||||
transport,
|
||||
disabled: entry.disabled === true,
|
||||
oauthClient: entry.oauthClient as
|
||||
| McpServerOAuthClientConfig
|
||||
| undefined,
|
||||
oauth,
|
||||
};
|
||||
});
|
||||
@@ -138,6 +143,7 @@ export function clearServerOAuth(name: string): void {
|
||||
);
|
||||
}
|
||||
delete existing.oauth;
|
||||
delete existing.oauthClient;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,6 +154,29 @@ export function clearServerOAuth(name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setServerOAuthClient(
|
||||
name: string,
|
||||
client: McpServerOAuthClientConfig | undefined,
|
||||
): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing)
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
const previous = existing.oauthClient as
|
||||
| McpServerOAuthClientConfig
|
||||
| undefined;
|
||||
if (
|
||||
previous?.clientId !== client?.clientId ||
|
||||
previous?.clientSecret !== client?.clientSecret
|
||||
) {
|
||||
delete existing.oauth;
|
||||
}
|
||||
if (client) existing.oauthClient = client;
|
||||
else delete existing.oauthClient;
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import {
|
||||
ensureSchedulerHub,
|
||||
type HubScheduleClient,
|
||||
} from "../../commands/schedule/client";
|
||||
import { resolveAddress } from "../../commands/schedule/common";
|
||||
import { resolveScheduleModelSelection } from "../../commands/schedule/model-selection";
|
||||
import { CRON_PRESETS } from "./cron-presets";
|
||||
|
||||
function isCancel(value: unknown): value is symbol {
|
||||
@@ -211,12 +211,13 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const modelSelection = resolveScheduleModelSelection({ provider, model });
|
||||
const created = (await client.createSchedule({
|
||||
name: (name as string).trim(),
|
||||
cronPattern,
|
||||
prompt: (prompt as string).trim(),
|
||||
provider: provider ?? "cline",
|
||||
model: model ?? CLINE_DEFAULT_MODEL_ID,
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
mode: mode as "act" | "plan" | "yolo",
|
||||
workspaceRoot: (workspace as string).trim(),
|
||||
systemPrompt,
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
@@ -297,7 +294,13 @@ export async function handleDesktopCommand(
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
toggleDisabledTool(toolName);
|
||||
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "tools",
|
||||
name: toolName,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_tool_disabled") {
|
||||
@@ -306,13 +309,30 @@ export async function handleDesktopCommand(
|
||||
.map((name) => String(name ?? "").trim())
|
||||
.filter(Boolean);
|
||||
if (toolNames.length === 0) throw new Error("tool name is required");
|
||||
setDisabledTools(toolNames, args?.disabled === true);
|
||||
for (const name of toolNames) {
|
||||
if (!ctx.uiClient)
|
||||
throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "tools",
|
||||
name,
|
||||
enabled: args?.disabled !== true,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_plugin_disabled") {
|
||||
const pluginPath = String(args?.path ?? "").trim();
|
||||
if (!pluginPath) throw new Error("plugin path is required");
|
||||
setDisabledPlugin(pluginPath, args?.disabled === true);
|
||||
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "plugins",
|
||||
path: pluginPath,
|
||||
enabled: args?.disabled !== true,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
throw new Error(`unsupported desktop command: ${command}`);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { extname, join, basename as pathBasename } from "node:path";
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getPluginDisplayName,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
readGlobalSettings,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
@@ -99,49 +96,21 @@ export async function listUserInstructionConfigs(
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlugins = (): Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}> => {
|
||||
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
||||
const pluginsByPath = new Map<
|
||||
string,
|
||||
{ name: string; path: string; enabled: boolean }
|
||||
>();
|
||||
const directories = resolvePluginConfigSearchPaths(
|
||||
targetWorkspaceRoot,
|
||||
).filter((d) => existsSync(d));
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) continue;
|
||||
pluginsByPath.set(filePath, {
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
const [rules, workflows, skills, pluginTools] = await Promise.all([
|
||||
const [rules, workflows, skills, settingsSnapshot] = await Promise.all([
|
||||
loadUserInstructionSnapshot("rule"),
|
||||
loadUserInstructionSnapshot("workflow"),
|
||||
loadUserInstructionSnapshot("skill"),
|
||||
listPluginTools({
|
||||
workspacePath: targetWorkspaceRoot,
|
||||
createCoreSettingsService().list({
|
||||
workspaceRoot: targetWorkspaceRoot,
|
||||
cwd: targetWorkspaceRoot,
|
||||
}),
|
||||
]);
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the desktop
|
||||
// sidecar's (sidecar/commands.ts) even if the preset defaults change.
|
||||
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
disabledToolIds: disabledTools,
|
||||
});
|
||||
|
||||
@@ -151,7 +120,12 @@ export async function listUserInstructionConfigs(
|
||||
workflows,
|
||||
skills,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
plugins: settingsSnapshot.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
...builtinToolCatalog.map((tool) => ({
|
||||
id: tool.id,
|
||||
@@ -163,11 +137,11 @@ export async function listUserInstructionConfigs(
|
||||
source: "builtin",
|
||||
headlessToolNames: tool.headlessToolNames,
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
...settingsSnapshot.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
enabled: tool.enabled !== false,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import {
|
||||
Bot,
|
||||
Code,
|
||||
Copy,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
@@ -16,6 +17,12 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
@@ -97,6 +104,19 @@ type PluginItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
contributions?: PluginContributions;
|
||||
};
|
||||
|
||||
type PluginContributions = {
|
||||
inspectionStatus?: "available" | "disabled" | "failed";
|
||||
capabilities: string[];
|
||||
tools: string[];
|
||||
skills: string[];
|
||||
rules: string[];
|
||||
hooks: string[];
|
||||
commands: string[];
|
||||
mcpServers: string[];
|
||||
providers: string[];
|
||||
};
|
||||
|
||||
type ToolItem = {
|
||||
@@ -856,6 +876,46 @@ export function CustomizationSectionView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginMenu = (target: LocalUninstallTarget) => {
|
||||
const uninstalling = localUninstallingKeys.has(target.key);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={`More actions for ${target.name ?? "plugin"}`}
|
||||
className="m-0 size-auto shrink-0 p-0 text-muted-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(target.path ?? "")
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={uninstalling}
|
||||
onClick={() => void uninstallLocalPrimitive(target)}
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkillCard = (item: CommandItem) => {
|
||||
const key = `${item.type}:${item.path}`;
|
||||
return (
|
||||
@@ -910,13 +970,32 @@ export function CustomizationSectionView({
|
||||
scope: ItemScope;
|
||||
}) => {
|
||||
const key = plugin.path;
|
||||
const contributionGroups = [
|
||||
{
|
||||
label: "Tools",
|
||||
items:
|
||||
plugin.contributions?.tools ??
|
||||
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
},
|
||||
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
|
||||
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
|
||||
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
|
||||
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
|
||||
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
|
||||
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
|
||||
{
|
||||
label: "Capabilities",
|
||||
items: plugin.contributions?.capabilities ?? [],
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
return (
|
||||
<div
|
||||
<details
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -929,61 +1008,61 @@ export function CustomizationSectionView({
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
{renderPluginMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{plugin.contributions?.inspectionStatus === "disabled" ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Enable this plugin to inspect its dynamic contributions.
|
||||
</p>
|
||||
) : null}
|
||||
{contributionGroups.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 py-2 text-xs font-medium text-foreground">
|
||||
<span className="mr-1">Contributions</span>
|
||||
{contributionGroups.map((group) => (
|
||||
<Badge key={group.label} variant="outline">
|
||||
{group.label} {group.items.length}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid max-h-56 gap-3 overflow-y-auto pt-2 sm:grid-cols-2">
|
||||
{contributionGroups.map((group) => (
|
||||
<div key={group.label} className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.items.map((item) => (
|
||||
<Badge key={item} variant="secondary">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin contributions found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderLocalActionMessage(key) ? (
|
||||
<div className="mt-3">{renderLocalActionMessage(key)}</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1000,53 +1079,56 @@ export function CustomizationSectionView({
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
{renderPluginMenu({
|
||||
key: plugin.path,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderPluginMatchedMeta = (plugin: PluginItem) => (
|
||||
<p className="min-w-0 truncate text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
);
|
||||
|
||||
const renderPluginMatchedDetails = (plugin: PluginItem) => {
|
||||
const pluginTools = pluginToolsByPluginKey.get(plugin.path) ?? [];
|
||||
if (pluginTools.length === 0) {
|
||||
const contributionGroups = [
|
||||
{
|
||||
label: "Tools",
|
||||
items:
|
||||
plugin.contributions?.tools ??
|
||||
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
},
|
||||
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
|
||||
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
|
||||
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
|
||||
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
|
||||
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
|
||||
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
|
||||
{
|
||||
label: "Capabilities",
|
||||
items: plugin.contributions?.capabilities ?? [],
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
if (contributionGroups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{pluginTools.map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid max-h-56 gap-3 overflow-y-auto sm:grid-cols-2">
|
||||
{contributionGroups.map((group) => (
|
||||
<div key={group.label} className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.items.map((item) => (
|
||||
<Badge key={item} variant="secondary">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1147,10 +1229,12 @@ export function CustomizationSectionView({
|
||||
renderMatchedControls: () =>
|
||||
renderPluginMatchedControls(item.plugin),
|
||||
renderMatchedDetails:
|
||||
Object.values(item.plugin.contributions ?? {}).some(
|
||||
(values) => values.length > 0,
|
||||
) ||
|
||||
(pluginToolsByPluginKey.get(item.plugin.path) ?? []).length > 0
|
||||
? () => renderPluginMatchedDetails(item.plugin)
|
||||
: undefined,
|
||||
renderMatchedMeta: () => renderPluginMatchedMeta(item.plugin),
|
||||
}),
|
||||
)
|
||||
: catalogPrimitive === "mcp"
|
||||
@@ -1476,7 +1560,6 @@ export function CustomizationSectionView({
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -1492,13 +1575,9 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
@@ -1513,19 +1592,6 @@ export function CustomizationSectionView({
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1558,7 +1624,6 @@ export function CustomizationSectionView({
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -1574,13 +1639,9 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
@@ -1595,19 +1656,6 @@ export function CustomizationSectionView({
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.11
|
||||
|
||||
- Images can now be pasted straight from the clipboard into the composer.
|
||||
- Opening a folder that isn't a git repo no longer shows git jargon, and the welcome suggestions now adapt to what's actually in the folder instead of assuming a code project.
|
||||
- The folder picker now reports failures instead of doing nothing, and offers a manual path entry as a fallback.
|
||||
- Opening an existing session no longer overwrites the model you had selected.
|
||||
- The diff panel now resolves file paths against the session's working directory, so diffs open correctly for sessions rooted outside the app's own directory.
|
||||
- `/team` prompts now run through the core runtime.
|
||||
- Failed turns surface their error in the transcript instead of leaving the chat blank.
|
||||
- Plugins left behind as empty install directories are no longer listed as installed, and plugin settings and contributions are now managed centrally with atomic toggles.
|
||||
- Fixed a startup script-load error, and webview errors are now attributed to the source URL that caused them.
|
||||
- Signing out is handled as a normal state rather than surfacing as a command error.
|
||||
- Native-feel and performance polish: the browser context menu is suppressed on app chrome (kept for text fields and selections), UI chrome is no longer text-selectable while chat content still is, inner scrollers no longer rubber-band the window, Settings/Sessions/Onboarding/Diff load lazily, the composer no longer flickers the caret on every keystroke, slash commands are cached across menu opens, and Escape closes the provider/model picker.
|
||||
- Tool output no longer nests its own scrollbar.
|
||||
- Prompts queued during a turn now survive being interrupted — they're preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped. Queued turns that fail are reported as failures.
|
||||
- Session context stays durable across aborts and hub restarts.
|
||||
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely.
|
||||
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright.
|
||||
- LiteLLM requests route through Chat Completions instead of the Responses API.
|
||||
- Network interruptions mid-stream but before any model output are retried instead of failing the turn.
|
||||
- Checkpoints are picked up when git is initialized part-way through a session, and checkpoint diffs include files that were untracked when the snapshot was taken.
|
||||
- Scheduled run reports carry execution context — schedule metadata, durations, and lifecycle error details.
|
||||
|
||||
## 0.0.10
|
||||
|
||||
- Remote MCP servers can now authenticate with OAuth from Settings → MCP — authorize a server, see its auth status, and cancel or retry a pending authorization. Servers that require a pre-registered OAuth client (client ID/secret) instead of dynamic registration are now supported, and stored tokens are invalidated when a server's client configuration changes.
|
||||
- MCP errors are now shown on the individual server rather than as a page-level error, and a server with invalid configuration is surfaced with its error instead of silently disappearing from the list.
|
||||
- Failed turns no longer fail silently. Sending a message with no model credentials — or any queued turn that fails — now shows an error in the transcript, enriched with the underlying cause and a pointer to Settings → Models.
|
||||
- Fixed the first message of a chat (and some queued messages) rendering twice.
|
||||
- Fixed the composer getting stuck on "Agent is working…" after a turn already finished.
|
||||
- New "Connect a model" notice on the welcome screen when no provider has credentials, with one click to onboarding or model settings. It reacts live as you add credentials, and correctly recognizes Bedrock/Vertex and keyless local endpoints as already connected.
|
||||
- Added "Get an API key" links for popular providers in onboarding and Settings → Models, plus a link to the Cline dashboard from the Cline API key form.
|
||||
- The onboarding welcome step now explains what Cline is.
|
||||
- The stop button is now actually visible and clickable, Esc stops the current turn, and new shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for settings.
|
||||
- Reasoning controls now resolve consistently across AI SDK providers, including Ollama, so effort levels and thinking on/off are honored wherever the provider supports them.
|
||||
- Vertex AI: credential refreshes now use the configured fetch, fixing ADC authentication behind proxies and custom networking.
|
||||
- Refreshed the bundled provider and model catalog.
|
||||
|
||||
## 0.0.9
|
||||
|
||||
- Cline Code now ships as a single universal macOS download that runs natively on both Apple Silicon and Intel — no more picking the right architecture. Existing per-architecture installs migrate to it automatically on their next update.
|
||||
- Session history can now be filtered by where a session came from — Desktop, CLI, extension, or scheduled — from a new filter control in the sidebar.
|
||||
- The composer now shows a token usage ring for the active model's context window, with cumulative cost, and it changes color as you approach the limit.
|
||||
- Skills now appear in the slash command menu alongside workflows, and commands that share a name are disambiguated instead of shadowing each other.
|
||||
- Installed plugins now show their real package names instead of all appearing as "index".
|
||||
- The agent header can be dragged to move the window again, including on read-only titles.
|
||||
- Chat message actions (copy, fork, edit, restore) no longer collide with the descenders of the message's last line.
|
||||
- Application errors are now reported in diagnostics, and the packaged app's telemetry configuration is baked into the sidecar at build time — previously the packaged build shipped with it empty, so no diagnostics were ever sent.
|
||||
- Plan mode now hard-blocks file-editing shell commands rather than relying on prompting alone; read-only investigation still works.
|
||||
- Running out of context is now recovered from automatically — the run compacts and retries once instead of failing with a raw provider error.
|
||||
- Empty model responses are now retried on every provider, not just Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported".
|
||||
- Bedrock prompt caching works again — cache reads and writes were always 0 — and Bedrock foundation models now route through geo inference profiles.
|
||||
- Reasoning models on OpenAI-compatible endpoints now get the correct token parameter, and models without image support substitute image content instead of failing.
|
||||
- Refreshed the bundled provider and model catalog, adding Infomaniak and SCX.ai.
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native provider.
|
||||
|
||||
## 0.0.8
|
||||
|
||||
- Edit any earlier message in a conversation — the app forks the session at that point, rewinds the workspace to that run's checkpoint, and re-runs from your edited prompt. Restores are transactional and workspace-atomic, so a failed restore won't leave you half-rewound.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -65,13 +65,11 @@
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.11",
|
||||
@@ -83,7 +81,6 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
|
||||
@@ -151,6 +151,8 @@ Supported commands:
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
|
||||
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
|
||||
@@ -17,11 +17,52 @@ import {
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
rewriteDesktopTeamPrompt,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("rewriteDesktopTeamPrompt", () => {
|
||||
it("rewrites /team for the core runtime", () => {
|
||||
expect(
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toBe(
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects /team when the Teams tool is disabled", () => {
|
||||
expect(() =>
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
disabledTools: new Set(["teams"]),
|
||||
}),
|
||||
).toThrow("Agent teams are disabled");
|
||||
});
|
||||
|
||||
it("rejects /team when the mode's tool preset has no team tools", () => {
|
||||
expect(() =>
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
mode: "yolo",
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toThrow("Agent teams are not available in yolo mode");
|
||||
});
|
||||
|
||||
it("accepts /team in act and plan modes", () => {
|
||||
for (const mode of ["act", "plan", undefined]) {
|
||||
expect(
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
mode,
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toContain('<user_command slash="team">');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
@@ -141,6 +182,8 @@ describe("pathless session starts", () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
@@ -163,6 +206,10 @@ describe("pathless session starts", () => {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
enableTools: true,
|
||||
// Legacy desktop capability flags must not override the SDK's
|
||||
// current tool preset or global tool customizations.
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
},
|
||||
})) as {
|
||||
sessionId: string;
|
||||
@@ -1420,6 +1467,25 @@ Follow the desktop send skill instructions.`,
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites a team command when a queued prompt is edited", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "update_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "queued-team",
|
||||
prompt: "/team inspect the app",
|
||||
});
|
||||
|
||||
expect(updatePendingPrompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
promptId: "queued-team",
|
||||
prompt:
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves built-in and unknown slash commands untouched", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, send, sessionId } = createContext(workspace);
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
getCoreBuiltinToolCatalog,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
@@ -133,6 +135,63 @@ async function expandRuntimeSlashCommand(
|
||||
}
|
||||
}
|
||||
|
||||
type TeamPromptAvailability = {
|
||||
/** Session mode as stored in config; anything but plan/yolo counts as act. */
|
||||
mode?: unknown;
|
||||
disabledTools?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function rewriteDesktopTeamPrompt(
|
||||
prompt: string,
|
||||
availability: TeamPromptAvailability = {},
|
||||
): string {
|
||||
const match = /^\/team\b([\s\S]*)$/i.exec(prompt.trim());
|
||||
if (!match) return prompt;
|
||||
const task = match[1]?.trim();
|
||||
if (!task) {
|
||||
throw new Error(
|
||||
"Usage: /team <task description>. Starts a team of agents for the given task.",
|
||||
);
|
||||
}
|
||||
const disabledTools =
|
||||
availability.disabledTools ??
|
||||
new Set(readGlobalSettings().disabledTools ?? []);
|
||||
if (disabledTools.has("teams")) {
|
||||
throw new Error(
|
||||
"Agent teams are disabled. Enable the Teams tool in Customizations → Tools.",
|
||||
);
|
||||
}
|
||||
// The runtime resolves tool availability from the mode's preset, so a
|
||||
// preset without team tools must reject /team here rather than send the
|
||||
// model an instruction it cannot act on.
|
||||
const mode =
|
||||
availability.mode === "plan" || availability.mode === "yolo"
|
||||
? availability.mode
|
||||
: "act";
|
||||
const teamsAvailable = getCoreBuiltinToolCatalog({ mode }).some(
|
||||
(entry) => entry.id === "teams" && entry.defaultEnabled,
|
||||
);
|
||||
if (!teamsAvailable) {
|
||||
throw new Error(`Agent teams are not available in ${mode} mode.`);
|
||||
}
|
||||
return formatUserCommandBlock(
|
||||
`spawn a team of agents for the following task: ${task}`,
|
||||
"team",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDesktopRuntimePrompt(
|
||||
ctx: SidecarContext,
|
||||
workspacePath: string | undefined,
|
||||
prompt: string,
|
||||
mode?: unknown,
|
||||
): Promise<string> {
|
||||
return rewriteDesktopTeamPrompt(
|
||||
await expandRuntimeSlashCommand(ctx, workspacePath, prompt),
|
||||
{ mode },
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveWorkspaceTurn(session: LiveSession): boolean {
|
||||
return (
|
||||
session.busy ||
|
||||
@@ -357,16 +416,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
maxIterations: config.maxIterations ?? config.max_iterations,
|
||||
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
||||
enableSpawnAgent:
|
||||
config.enableSpawn ??
|
||||
config.enableSpawnAgent ??
|
||||
config.enable_spawn ??
|
||||
false,
|
||||
enableAgentTeams:
|
||||
config.enableTeams ??
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
@@ -856,12 +905,13 @@ async function handleSend(
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
// Dispatch the expanded instructions, but keep the raw `/command` token as
|
||||
// the session's display prompt.
|
||||
const expandedPrompt = await expandRuntimeSlashCommand(
|
||||
// Dispatch the expanded or rewritten instructions, but keep the raw
|
||||
// `/command` token as the session's display prompt.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
|
||||
prompt,
|
||||
request.config?.mode ?? session?.config?.mode,
|
||||
);
|
||||
let delivery = request.delivery;
|
||||
if (!delivery && session?.busy) {
|
||||
@@ -929,7 +979,7 @@ async function handleSend(
|
||||
}
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
@@ -953,7 +1003,7 @@ async function handleSend(
|
||||
try {
|
||||
result = await manager.send({
|
||||
sessionId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
@@ -1417,18 +1467,19 @@ async function handleUpdatePendingPrompt(
|
||||
throw new Error("prompt is required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const sessionConfig = ctx.liveSessions.get(sessionId)?.config;
|
||||
// Queued prompts are delivered by the runtime without another pass
|
||||
// through handleSend, so expand a leading slash command here too.
|
||||
const expandedPrompt = await expandRuntimeSlashCommand(
|
||||
// through handleSend, so resolve slash commands here too.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(ctx.liveSessions.get(sessionId)?.config) ??
|
||||
ctx.workspaceRoot,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
|
||||
prompt,
|
||||
sessionConfig?.mode,
|
||||
);
|
||||
const result = await manager.pendingPrompts.update({
|
||||
sessionId,
|
||||
promptId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
});
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -112,8 +112,6 @@ async function main() {
|
||||
cwd: cwd,
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: unknown) {
|
||||
clineAccountServiceCtorMock(options);
|
||||
}
|
||||
},
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
} as const;
|
||||
|
||||
async function runClineAccountCommand(ctx: SidecarContext) {
|
||||
const { handleCommand } = await import("./commands");
|
||||
return handleCommand(ctx, "cline_account", { ...FETCH_ME_ARGS });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clineAccountServiceCtorMock.mockReset();
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
signedIn: false,
|
||||
code: "ACCOUNT_NOT_AUTHENTICATED",
|
||||
});
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the account action unchanged when a fresh token resolves", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const user = { id: "user-1", email: "beatrix@cline.bot" };
|
||||
executeClineAccountActionMock.mockResolvedValue(user);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(result).toBe(user);
|
||||
expect(executeClineAccountActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining(FETCH_ME_ARGS),
|
||||
expect.anything(),
|
||||
);
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe("fresh-token");
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the persisted token silently when the refresh fails", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockRejectedValue(
|
||||
new Error("Token refresh failed: 500"),
|
||||
);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accessToken: "persisted-token" },
|
||||
});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "user-1" });
|
||||
|
||||
await runClineAccountCommand(ctx);
|
||||
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe(
|
||||
"persisted-token",
|
||||
);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one auth refresh soft-failure event when the refresh fails and no fallback token exists", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
const refreshError = new Error(
|
||||
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
|
||||
);
|
||||
refreshError.name = "OAuthReauthRequiredError";
|
||||
resolveProviderApiKeyMock.mockRejectedValue(refreshError);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
event: "user.auth_refresh_soft_failure",
|
||||
properties: expect.objectContaining({
|
||||
provider: "cline",
|
||||
errorName: "OAuthReauthRequiredError",
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,11 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, extname, isAbsolute, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
CoreSettingsSnapshot,
|
||||
ProviderCapability,
|
||||
ProviderClient,
|
||||
ProviderProtocol,
|
||||
@@ -19,31 +14,29 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
captureAuthRefreshSoftFailure,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
getPluginDisplayName,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
parseMcpServerRegistration,
|
||||
probeMcpServerConnection,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setMcpServerDisabled,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateLocalProvider,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
@@ -56,6 +49,7 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -72,6 +66,17 @@ import {
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
ensureMcpSettingsFile,
|
||||
readMcpServersResponse,
|
||||
shouldProbeMcpServerAfterUpsert,
|
||||
} from "./mcp";
|
||||
import {
|
||||
cancelMcpOAuthAuthorizationForReason,
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
runCancellableMcpOAuthAuthorization,
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation,
|
||||
} from "./mcp-oauth";
|
||||
import {
|
||||
cancelProviderOAuthLogin,
|
||||
runCancellableProviderOAuthLogin,
|
||||
@@ -95,6 +100,7 @@ import type {
|
||||
JsonRecord,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
import { pickWorkspaceDirectory } from "./workspace-picker";
|
||||
|
||||
// All child processes in this module run asynchronously: the sidecar is a
|
||||
// single event loop shared by every UI command and streaming chat session, so
|
||||
@@ -162,82 +168,21 @@ function readProviderSettingsUpdate(
|
||||
: {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP settings helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const rawTransportType =
|
||||
transport?.type ?? record.transportType ?? record.type;
|
||||
const transportType = String(
|
||||
rawTransportType ??
|
||||
(typeof transport?.url === "string" || typeof record.url === "string"
|
||||
? "sse"
|
||||
: "stdio"),
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport type + URL a server record actually points at, tolerating both
|
||||
* the nested `transport` shape and legacy flat fields (mirrors
|
||||
* readMcpServersResponse).
|
||||
*/
|
||||
function mcpTransportIdentity(record: JsonRecord): string {
|
||||
function mcpTransportIdentity(name: string, record: JsonRecord): string {
|
||||
try {
|
||||
const resolved = parseMcpServerRegistration(name, record).transport;
|
||||
return resolved.type === "stdio"
|
||||
? "stdio\u0000"
|
||||
: `${resolved.type}\u0000${resolved.url}`;
|
||||
} catch {
|
||||
// Preserve a best-effort identity for malformed entries so the editor can
|
||||
// still repair them without requiring the entire settings file to parse.
|
||||
}
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
@@ -257,20 +202,6 @@ function mcpTransportIdentity(record: JsonRecord): string {
|
||||
return `${normalizedType}\u0000${url}`;
|
||||
}
|
||||
|
||||
function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function removePathIfExists(
|
||||
path: string,
|
||||
options?: { recursive?: boolean },
|
||||
@@ -293,8 +224,10 @@ function removePathIfExists(
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
let refreshError: Error | undefined;
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
@@ -303,11 +236,28 @@ async function resolveFreshClineAuthToken(
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the persisted token; the account request surfaces the
|
||||
// auth failure to the caller.
|
||||
} catch (error) {
|
||||
// Fall back to the persisted token; when one exists the account request
|
||||
// surfaces the auth failure to the caller.
|
||||
refreshError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
|
||||
const persisted = resolveLocalClineAuthToken(
|
||||
manager.getProviderSettings("cline"),
|
||||
);
|
||||
// Never-signed-in resolves to undefined without a refresh attempt and is
|
||||
// silent. A refresh failure with no persisted fallback means credentials
|
||||
// existed but yielded nothing — that is the signal a real auth regression
|
||||
// would show up as, so report exactly one event for it.
|
||||
if (!persisted && refreshError) {
|
||||
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
|
||||
errorName: refreshError.name,
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function mergePersistedSessionRecord(
|
||||
@@ -731,9 +681,51 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
||||
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
||||
}
|
||||
|
||||
async function listHubSettings(
|
||||
ctx: SidecarContext,
|
||||
): Promise<CoreSettingsSnapshot> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command("settings.list", {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? "hub command failed: settings.list",
|
||||
);
|
||||
}
|
||||
return reply.payload?.snapshot as CoreSettingsSnapshot;
|
||||
}
|
||||
|
||||
async function toggleHubSetting(
|
||||
ctx: SidecarContext,
|
||||
input: {
|
||||
type: "plugins" | "tools";
|
||||
path?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<CoreSettingsSnapshot> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command("settings.toggle", {
|
||||
...input,
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? "hub command failed: settings.toggle",
|
||||
);
|
||||
}
|
||||
return reply.payload?.snapshot as CoreSettingsSnapshot;
|
||||
}
|
||||
|
||||
async function listUserInstructionConfigs(
|
||||
workspaceRoot: string,
|
||||
ctx: SidecarContext,
|
||||
settingsSnapshot?: CoreSettingsSnapshot,
|
||||
): Promise<JsonRecord> {
|
||||
const workspaceRoot = ctx.workspaceRoot;
|
||||
const hubSettings = settingsSnapshot ?? (await listHubSettings(ctx));
|
||||
const warnings: string[] = [];
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: { workspacePath: workspaceRoot },
|
||||
@@ -804,40 +796,6 @@ async function listUserInstructionConfigs(
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlugins = (): Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}> => {
|
||||
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
||||
const pluginsByPath = new Map<
|
||||
string,
|
||||
{ name: string; path: string; enabled: boolean }
|
||||
>();
|
||||
const directories = resolvePluginConfigSearchPaths(workspaceRoot).filter(
|
||||
(d) => existsSync(d),
|
||||
);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) {
|
||||
continue;
|
||||
}
|
||||
pluginsByPath.set(filePath, {
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await userInstructionService.start();
|
||||
} catch (error) {
|
||||
@@ -858,13 +816,14 @@ async function listUserInstructionConfigs(
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
}
|
||||
const pluginTools = await listPluginTools({
|
||||
workspacePath: workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the hub's
|
||||
// (apps/cline-hub/src/server/user-instructions.ts) even if the preset
|
||||
// defaults change.
|
||||
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
disabledToolIds: disabledTools,
|
||||
});
|
||||
|
||||
@@ -875,7 +834,12 @@ async function listUserInstructionConfigs(
|
||||
skills,
|
||||
runtimeCommands,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
plugins: hubSettings.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
...builtinToolCatalog.map((tool) => ({
|
||||
id: tool.id,
|
||||
@@ -887,11 +851,11 @@ async function listUserInstructionConfigs(
|
||||
source: "builtin",
|
||||
headlessToolNames: tool.headlessToolNames,
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
...hubSettings.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
enabled: tool.enabled !== false,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
@@ -907,41 +871,6 @@ async function listUserInstructionConfigs(
|
||||
// Native OS commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Async is load-bearing here: the native picker blocks until the user chooses
|
||||
// a folder, and a synchronous exec would freeze every other sidecar command
|
||||
// (chat streams, history, settings) for however long the dialog stays open.
|
||||
async function pickWorkspaceDirectory(): Promise<string | null> {
|
||||
const platform = process.platform;
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"osascript",
|
||||
[
|
||||
"-e",
|
||||
'set theFolder to choose folder with prompt "Select workspace directory"',
|
||||
"-e",
|
||||
"return POSIX path of theFolder",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Linux — try zenity
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"zenity",
|
||||
["--file-selection", "--directory", "--title=Select workspace directory"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function openFileInEditor(filePath: string): void {
|
||||
const platform = process.platform;
|
||||
const cmd =
|
||||
@@ -1460,11 +1389,19 @@ export async function handleCommand(
|
||||
const operation = String(args?.operation ?? "").trim();
|
||||
if (!operation) throw new Error("operation is required");
|
||||
const manager = new ProviderSettingsManager();
|
||||
// Signed out is an expected state, not a command failure: resolve the
|
||||
// token up front and return a typed result the webview can act on
|
||||
// instead of letting the account service throw a generic error that
|
||||
// would be captured as error telemetry and shown raw to the user.
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveFreshClineAuthToken(manager),
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -1604,22 +1541,78 @@ export async function handleCommand(
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = {
|
||||
...(current as JsonRecord),
|
||||
disabled: Boolean(args?.disabled),
|
||||
};
|
||||
settings.mcpServers = servers;
|
||||
if (command === "authorize_mcp_server_oauth") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
const registration = resolveMcpServerRegistration(name, {
|
||||
filePath: settingsPath,
|
||||
});
|
||||
if (!registration) {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
const wasDisabled = registration.disabled === true;
|
||||
setMcpServerDisabled({ filePath: settingsPath, name, disabled: true });
|
||||
try {
|
||||
await runCancellableMcpOAuthAuthorization(
|
||||
{
|
||||
serverName: name,
|
||||
filePath: settingsPath,
|
||||
openUrl: openUrlInDefaultBrowser,
|
||||
},
|
||||
options?.connection,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof McpOAuthAuthorizationCancelledError)) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation(
|
||||
wasDisabled,
|
||||
error.reason,
|
||||
)
|
||||
) {
|
||||
setMcpServerDisabled({
|
||||
filePath: settingsPath,
|
||||
name,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
setMcpServerDisabled({ filePath: settingsPath, name, disabled: false });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "cancel_mcp_server_oauth") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
cancelMcpOAuthAuthorizationForReason(name, "user");
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const disabled = Boolean(args?.disabled);
|
||||
const path = ensureMcpSettingsFile();
|
||||
if (disabled) {
|
||||
cancelMcpOAuthAuthorizationForReason(name, "server-disabled");
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: true });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
const registration = resolveMcpServerRegistration(name, { filePath: path });
|
||||
if (!registration) {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
if (registration.transport.type !== "stdio") {
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: true });
|
||||
const probe = await probeMcpServerConnection({
|
||||
serverName: name,
|
||||
filePath: path,
|
||||
});
|
||||
if (!probe.connected) {
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
}
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: false });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "upsert_mcp_server") {
|
||||
@@ -1635,6 +1628,8 @@ export async function handleCommand(
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const requestedDisabled = Boolean(input.disabled);
|
||||
const isRemote = transportType !== "stdio";
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
@@ -1645,7 +1640,7 @@ export async function handleCommand(
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: Boolean(input.disabled),
|
||||
disabled: requestedDisabled,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
: {
|
||||
@@ -1654,40 +1649,78 @@ export async function handleCommand(
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: Boolean(input.disabled),
|
||||
disabled: requestedDisabled,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
// Preserve machine-managed fields the editor dialog doesn't expose:
|
||||
// oauth tokens for remote servers and plugin-ownership metadata.
|
||||
const sourceName =
|
||||
previousName && servers[previousName] ? previousName : name;
|
||||
const existing = servers[sourceName];
|
||||
const upserted = { ...next };
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
if (upserted.metadata === undefined && record.metadata !== undefined) {
|
||||
upserted.metadata = record.metadata;
|
||||
const { shouldProbeAfterSave } = updateMcpSettingsFileSync(
|
||||
path,
|
||||
(settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
// Preserve machine-managed fields the editor dialog doesn't expose:
|
||||
// oauth tokens for remote servers and plugin-ownership metadata.
|
||||
const existingName =
|
||||
previousName && servers[previousName] ? previousName : name;
|
||||
const existing = servers[existingName];
|
||||
let existingTransportIdentity: string | undefined;
|
||||
let existingWasEnabled = false;
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
existingTransportIdentity = mcpTransportIdentity(
|
||||
existingName,
|
||||
record,
|
||||
);
|
||||
existingWasEnabled = record.disabled !== true;
|
||||
}
|
||||
// OAuth tokens were issued for a specific endpoint; carrying them
|
||||
// onto an edited transport or URL would send the old server's
|
||||
// credentials to a different endpoint.
|
||||
if (
|
||||
record.oauth !== undefined &&
|
||||
mcpTransportIdentity(record) === mcpTransportIdentity(upserted)
|
||||
) {
|
||||
upserted.oauth = record.oauth;
|
||||
const nextTransportIdentity = mcpTransportIdentity(name, next);
|
||||
const transportIdentityUnchanged =
|
||||
existingTransportIdentity === nextTransportIdentity;
|
||||
const shouldProbe = shouldProbeMcpServerAfterUpsert({
|
||||
isRemote,
|
||||
requestedDisabled,
|
||||
existingWasEnabled,
|
||||
transportIdentityUnchanged,
|
||||
});
|
||||
const upserted: JsonRecord = {
|
||||
...next,
|
||||
disabled: requestedDisabled || shouldProbe,
|
||||
};
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
if (
|
||||
upserted.metadata === undefined &&
|
||||
record.metadata !== undefined
|
||||
) {
|
||||
upserted.metadata = record.metadata;
|
||||
}
|
||||
// OAuth tokens were issued for a specific endpoint; carrying them
|
||||
// onto an edited transport or URL would send the old server's
|
||||
// credentials to a different endpoint.
|
||||
if (record.oauth !== undefined && transportIdentityUnchanged) {
|
||||
upserted.oauth = record.oauth;
|
||||
}
|
||||
if (record.oauthClient !== undefined && transportIdentityUnchanged) {
|
||||
upserted.oauthClient = record.oauthClient;
|
||||
}
|
||||
}
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = upserted;
|
||||
settings.mcpServers = servers;
|
||||
return { shouldProbeAfterSave: shouldProbe };
|
||||
},
|
||||
);
|
||||
if (shouldProbeAfterSave) {
|
||||
const probe = await probeMcpServerConnection({
|
||||
serverName: name,
|
||||
filePath: path,
|
||||
});
|
||||
if (probe.connected) {
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: false });
|
||||
}
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = upserted;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "delete_mcp_server") {
|
||||
@@ -1750,12 +1783,12 @@ export async function handleCommand(
|
||||
|
||||
// ── User instruction configs ──────────────────────────────────────
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
return await listUserInstructionConfigs(ctx);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
await listUserInstructionConfigs(ctx),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
@@ -1777,8 +1810,11 @@ export async function handleCommand(
|
||||
if (!toolName) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
toggleDisabledTool(toolName);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
const snapshot = await toggleHubSetting(ctx, {
|
||||
type: "tools",
|
||||
name: toolName,
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
if (command === "set_tool_disabled") {
|
||||
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
|
||||
@@ -1788,26 +1824,45 @@ export async function handleCommand(
|
||||
if (toolNames.length === 0) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
setDisabledTools(toolNames, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
let snapshot: CoreSettingsSnapshot | undefined;
|
||||
for (const name of toolNames) {
|
||||
snapshot = await toggleHubSetting(ctx, {
|
||||
type: "tools",
|
||||
name,
|
||||
enabled: args?.disabled !== true,
|
||||
});
|
||||
}
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
if (command === "set_plugin_disabled") {
|
||||
const pluginPath = String(args?.path ?? "").trim();
|
||||
if (!pluginPath) {
|
||||
throw new Error("plugin path is required");
|
||||
}
|
||||
setDisabledPlugin(pluginPath, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
const snapshot = await toggleHubSetting(ctx, {
|
||||
type: "plugins",
|
||||
path: pluginPath,
|
||||
enabled: args?.disabled !== true,
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
const workspacePath = String(args?.path ?? "").trim();
|
||||
if (!workspacePath) return { valid: false };
|
||||
// Support typed/pasted paths like "~/projects/app" from the manual
|
||||
// path-entry fallback; return the resolved path so the caller adopts it.
|
||||
const resolved =
|
||||
workspacePath === "~"
|
||||
? homedir()
|
||||
: workspacePath.startsWith("~/") || workspacePath.startsWith("~\\")
|
||||
? join(homedir(), workspacePath.slice(2))
|
||||
: workspacePath;
|
||||
try {
|
||||
return { valid: statSync(workspacePath).isDirectory() };
|
||||
return { valid: statSync(resolved).isDirectory(), path: resolved };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
return { valid: false, path: resolved };
|
||||
}
|
||||
}
|
||||
if (command === "pick_workspace_directory") {
|
||||
|
||||
@@ -222,6 +222,79 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("announces a queued prompt start once when drain emits both queue events", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
let onEvent: ((event: unknown) => void) | undefined;
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn((handler: (event: unknown) => void) => {
|
||||
onEvent = handler;
|
||||
return () => {};
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
await initializeSessionManager(ctx);
|
||||
const session = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [
|
||||
{ id: "prompt-1", prompt: "hi there", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
} satisfies LiveSession;
|
||||
ctx.liveSessions.set("session-1", session);
|
||||
|
||||
// PendingPromptService.drain() emits both events for the same prompt:
|
||||
// a queue snapshot with the head removed, then the submitted event.
|
||||
onEvent?.({
|
||||
type: "pending_prompts",
|
||||
payload: { sessionId: "session-1", prompts: [] },
|
||||
});
|
||||
onEvent?.({
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
id: "prompt-1",
|
||||
prompt: "hi there",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const starts = readEvents(ctx).filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
);
|
||||
expect(starts).toHaveLength(1);
|
||||
|
||||
// A different prompt id must still be announced.
|
||||
onEvent?.({
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
id: "prompt-2",
|
||||
prompt: "second",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
readEvents(ctx).filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
|
||||
@@ -325,6 +325,35 @@ function handleAgentEvent(
|
||||
// CoreSessionEvent routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
|
||||
// and a pending_prompt_submitted event for the same prompt back-to-back, and
|
||||
// both are translated here into chat_queued_prompt_start — dedupe by prompt
|
||||
// id or the UI renders the user message twice.
|
||||
function emitQueuedPromptStart(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
input: {
|
||||
promptId: string;
|
||||
prompt: string;
|
||||
attachmentCount: number;
|
||||
userImages?: string[];
|
||||
},
|
||||
): void {
|
||||
if (session) {
|
||||
if (session.lastQueuedPromptStartId === input.promptId) {
|
||||
return;
|
||||
}
|
||||
session.lastQueuedPromptStartId = input.promptId;
|
||||
}
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart(input),
|
||||
);
|
||||
}
|
||||
|
||||
function handleCoreSessionEvent(
|
||||
ctx: SidecarContext,
|
||||
event: CoreSessionEvent,
|
||||
@@ -367,17 +396,12 @@ function handleCoreSessionEvent(
|
||||
previous[0] &&
|
||||
previous[0].id !== mapped[0]?.id
|
||||
) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart({
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
}),
|
||||
);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
});
|
||||
}
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
@@ -386,18 +410,26 @@ function handleCoreSessionEvent(
|
||||
case "pending_prompt_submitted": {
|
||||
const { sessionId, id, prompt, attachmentCount, userImages } =
|
||||
event.payload;
|
||||
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart({
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
markQueuedAttachmentsSubmitted(session, id);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
});
|
||||
// The prompt left the queue; without a fresh snapshot the webview
|
||||
// keeps a stale busy queue and the composer never returns to idle
|
||||
// after the turn completes.
|
||||
if (session) {
|
||||
const remaining = session.promptsInQueue.filter(
|
||||
(item) => item.id !== id,
|
||||
);
|
||||
if (remaining.length !== session.promptsInQueue.length) {
|
||||
session.promptsInQueue = remaining;
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ended": {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
installMarketplaceEntry,
|
||||
listMarketplaceInstalledEntries,
|
||||
} from "./marketplace";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
const GOAL_ENTRY = {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
};
|
||||
|
||||
let tempClineDir: string;
|
||||
let previousClineDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
|
||||
previousClineDir = process.env.CLINE_DIR;
|
||||
process.env.CLINE_DIR = tempClineDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = previousClineDir;
|
||||
}
|
||||
await rm(tempClineDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function goalInstallDir(): string {
|
||||
const path = getOfficialPluginInstallPath("goal");
|
||||
if (!path) {
|
||||
throw new Error("expected an official install path for goal");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("official plugin install detection", () => {
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the CLI, so the UI flipped the
|
||||
// entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "install exploded",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
|
||||
).rejects.toThrow(/Plugin install failed/);
|
||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes --force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without --force the CLI refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
|
||||
});
|
||||
|
||||
it("does not pass --force for a clean first install", async () => {
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
|
||||
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
|
||||
});
|
||||
|
||||
it("still short-circuits when the directory contains a plugin module", async () => {
|
||||
const installDir = goalInstallDir();
|
||||
await mkdir(join(installDir, "package"), { recursive: true });
|
||||
await writeFile(
|
||||
join(installDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "goal",
|
||||
private: true,
|
||||
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Goal is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes partial install directories from the installed entries list", async () => {
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
|
||||
const empty = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
|
||||
plugins: [],
|
||||
} as JsonRecord);
|
||||
expect(empty.installedKeys).toEqual([]);
|
||||
|
||||
const installDir = goalInstallDir();
|
||||
await mkdir(join(installDir, "package"), { recursive: true });
|
||||
await writeFile(
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const populated = listMarketplaceInstalledEntries(
|
||||
{ entries: [GOAL_ENTRY] },
|
||||
{ plugins: [] } as JsonRecord,
|
||||
);
|
||||
expect(populated.installedKeys).toEqual(["plugin:goal"]);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import {
|
||||
discoverPluginModulePaths,
|
||||
resolveClineDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -584,7 +587,9 @@ function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
export function getOfficialPluginInstallPath(
|
||||
source: string,
|
||||
): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
@@ -597,12 +602,33 @@ function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
type OfficialPluginInstallState = "installed" | "partial" | "missing";
|
||||
|
||||
function getOfficialPluginInstallState(
|
||||
entry: MarketplaceInstallInput,
|
||||
): OfficialPluginInstallState {
|
||||
if (entry.type !== "plugin") return "missing";
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
if (!source) return "missing";
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
if (!installPath || !existsSync(installPath)) return "missing";
|
||||
// A bare directory is not an install: a failed or interrupted install can
|
||||
// leave the directory behind with no plugin inside, and treating that as
|
||||
// installed makes the next install attempt "succeed" silently ("already
|
||||
// installed") while nothing actually works. Require a loadable plugin
|
||||
// module before reporting the entry as installed; a directory without one
|
||||
// is "partial" so the installer can reclaim it with --force.
|
||||
try {
|
||||
return discoverPluginModulePaths(installPath).length > 0
|
||||
? "installed"
|
||||
: "partial";
|
||||
} catch {
|
||||
return "partial";
|
||||
}
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return getOfficialPluginInstallState(entry) === "installed";
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
@@ -805,7 +831,8 @@ async function installPlugin(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
const installState = getOfficialPluginInstallState(entry);
|
||||
if (installState === "installed") {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
@@ -819,6 +846,12 @@ async function installPlugin(
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
// Reclaim a leftover directory from a failed or interrupted install:
|
||||
// without --force the CLI refuses to replace the existing path and
|
||||
// every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
...(installState === "partial" ? ["--force"] : []),
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
AuthorizeMcpServerOAuthOptions,
|
||||
AuthorizeMcpServerOAuthResult,
|
||||
} from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cancelMcpOAuthAuthorization,
|
||||
cancelMcpOAuthAuthorizationForReason,
|
||||
cancelMcpOAuthAuthorizationsForOwner,
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
runCancellableMcpOAuthAuthorization,
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation,
|
||||
} from "./mcp-oauth";
|
||||
|
||||
function waitForAbort(
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> {
|
||||
return new Promise((_, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("desktop MCP OAuth authorization", () => {
|
||||
it("starts browser authorization only through the explicit runner", async () => {
|
||||
const authorize = vi.fn(
|
||||
async (
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> => ({
|
||||
serverName: options.serverName,
|
||||
authorized: true,
|
||||
message: "authorized",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCancellableMcpOAuthAuthorization({ serverName: "github" }, undefined, {
|
||||
authorize,
|
||||
}),
|
||||
).resolves.toMatchObject({ authorized: true });
|
||||
expect(authorize).toHaveBeenCalledOnce();
|
||||
expect(authorize.mock.calls[0]?.[0].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("cancels a pending callback when the server is turned off", async () => {
|
||||
const pending = runCancellableMcpOAuthAuthorization(
|
||||
{ serverName: "github" },
|
||||
undefined,
|
||||
{ authorize: waitForAbort },
|
||||
);
|
||||
|
||||
expect(
|
||||
cancelMcpOAuthAuthorizationForReason("github", "server-disabled"),
|
||||
).toBe(true);
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: "McpOAuthAuthorizationCancelledError",
|
||||
reason: "server-disabled",
|
||||
});
|
||||
expect(cancelMcpOAuthAuthorization("github")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a previously enabled server only for an explicit user cancel", () => {
|
||||
expect(shouldRestoreEnabledStateAfterOAuthCancellation(false, "user")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation(false, "server-disabled"),
|
||||
).toBe(false);
|
||||
expect(shouldRestoreEnabledStateAfterOAuthCancellation(true, "user")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an abandoned flow when its webview connection closes", async () => {
|
||||
const owner = {};
|
||||
const pending = runCancellableMcpOAuthAuthorization(
|
||||
{ serverName: "linear" },
|
||||
owner,
|
||||
{ authorize: waitForAbort },
|
||||
);
|
||||
|
||||
expect(cancelMcpOAuthAuthorizationsForOwner({})).toBe(0);
|
||||
expect(cancelMcpOAuthAuthorizationsForOwner(owner)).toBe(1);
|
||||
await expect(pending).rejects.toBeInstanceOf(
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
type AuthorizeMcpServerOAuthOptions,
|
||||
type AuthorizeMcpServerOAuthResult,
|
||||
authorizeMcpServerOAuth,
|
||||
} from "@cline/core";
|
||||
|
||||
export type McpOAuthCancellationReason =
|
||||
| "user"
|
||||
| "server-disabled"
|
||||
| "superseded"
|
||||
| "owner-closed";
|
||||
|
||||
export function shouldRestoreEnabledStateAfterOAuthCancellation(
|
||||
wasDisabled: boolean,
|
||||
reason: McpOAuthCancellationReason,
|
||||
): boolean {
|
||||
return !wasDisabled && reason === "user";
|
||||
}
|
||||
|
||||
type PendingMcpOAuthAuthorization = {
|
||||
controller: AbortController;
|
||||
owner?: object;
|
||||
cancellationReason?: McpOAuthCancellationReason;
|
||||
};
|
||||
|
||||
const pendingAuthorizations = new Map<string, PendingMcpOAuthAuthorization>();
|
||||
|
||||
export class McpOAuthAuthorizationCancelledError extends Error {
|
||||
constructor(
|
||||
serverName: string,
|
||||
readonly reason: McpOAuthCancellationReason,
|
||||
) {
|
||||
super(`MCP OAuth authorization was cancelled for "${serverName}".`);
|
||||
this.name = "McpOAuthAuthorizationCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export type McpOAuthAuthorizationDependencies = {
|
||||
authorize: (
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
) => Promise<AuthorizeMcpServerOAuthResult>;
|
||||
};
|
||||
|
||||
const defaultDependencies: McpOAuthAuthorizationDependencies = {
|
||||
authorize: authorizeMcpServerOAuth,
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the lifetime of a desktop MCP browser authorization. Only an explicit
|
||||
* call to this function can open the browser; ordinary server enable/connect
|
||||
* attempts only persist the SDK's `authorizationRequired` status.
|
||||
*/
|
||||
export async function runCancellableMcpOAuthAuthorization(
|
||||
options: Omit<AuthorizeMcpServerOAuthOptions, "signal">,
|
||||
owner?: object,
|
||||
dependencies: McpOAuthAuthorizationDependencies = defaultDependencies,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> {
|
||||
const serverName = options.serverName.trim();
|
||||
if (!serverName) {
|
||||
throw new Error("MCP server name cannot be empty.");
|
||||
}
|
||||
|
||||
cancelMcpOAuthAuthorizationForReason(serverName, "superseded");
|
||||
const entry: PendingMcpOAuthAuthorization = {
|
||||
controller: new AbortController(),
|
||||
owner,
|
||||
};
|
||||
pendingAuthorizations.set(serverName, entry);
|
||||
|
||||
try {
|
||||
try {
|
||||
return await dependencies.authorize({
|
||||
...options,
|
||||
serverName,
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) {
|
||||
throw new McpOAuthAuthorizationCancelledError(
|
||||
serverName,
|
||||
entry.cancellationReason ?? "superseded",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (pendingAuthorizations.get(serverName) === entry) {
|
||||
pendingAuthorizations.delete(serverName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels the pending browser callback flow for one MCP server, if present. */
|
||||
export function cancelMcpOAuthAuthorization(serverName: string): boolean {
|
||||
return cancelMcpOAuthAuthorizationForReason(serverName, "user");
|
||||
}
|
||||
|
||||
export function cancelMcpOAuthAuthorizationForReason(
|
||||
serverName: string,
|
||||
reason: McpOAuthCancellationReason,
|
||||
): boolean {
|
||||
const entry = pendingAuthorizations.get(serverName);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
entry.cancellationReason = reason;
|
||||
entry.controller.abort();
|
||||
pendingAuthorizations.delete(serverName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Cancels MCP OAuth flows started by a webview connection that went away. */
|
||||
export function cancelMcpOAuthAuthorizationsForOwner(owner: object): number {
|
||||
let cancelled = 0;
|
||||
for (const [serverName, entry] of pendingAuthorizations) {
|
||||
if (entry.owner === owner) {
|
||||
entry.cancellationReason = "owner-closed";
|
||||
entry.controller.abort();
|
||||
pendingAuthorizations.delete(serverName);
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import {
|
||||
buildMcpServersResponse,
|
||||
shouldProbeMcpServerAfterUpsert,
|
||||
} from "./mcp";
|
||||
import type { JsonRecord, SidecarContext } from "./types";
|
||||
|
||||
function createContext(workspaceRoot: string): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop MCP settings", () => {
|
||||
it("keeps valid and malformed entries visible independently", () => {
|
||||
const response = buildMcpServersResponse("/tmp/cline_mcp_settings.json", {
|
||||
mcpServers: {
|
||||
linear: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", "https://mcp.linear.app/mcp"],
|
||||
disabled: true,
|
||||
oauth: {
|
||||
authorizationRequired: true,
|
||||
lastError: "OAuth authorization required",
|
||||
},
|
||||
},
|
||||
broken: {},
|
||||
},
|
||||
});
|
||||
const servers = response.servers as JsonRecord[];
|
||||
|
||||
expect(servers).toHaveLength(2);
|
||||
expect(servers[0]).toMatchObject({
|
||||
name: "linear",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
disabled: true,
|
||||
oauthStatus: {
|
||||
authorizationRequired: true,
|
||||
lastError: "OAuth authorization required",
|
||||
},
|
||||
});
|
||||
expect(servers[1]).toMatchObject({
|
||||
name: "broken",
|
||||
transportType: "stdio",
|
||||
});
|
||||
expect(String(servers[1]?.configurationError)).toContain(
|
||||
'Invalid MCP server "broken"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not probe an unchanged enabled remote server after editing", () => {
|
||||
expect(
|
||||
shouldProbeMcpServerAfterUpsert({
|
||||
isRemote: true,
|
||||
requestedDisabled: false,
|
||||
existingWasEnabled: true,
|
||||
transportIdentityUnchanged: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeMcpServerAfterUpsert({
|
||||
isRemote: true,
|
||||
requestedDisabled: false,
|
||||
existingWasEnabled: true,
|
||||
transportIdentityUnchanged: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unchanged enabled remote server enabled when saving metadata", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "desktop-mcp-settings-"));
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
try {
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "http://127.0.0.1:1/mcp",
|
||||
},
|
||||
},
|
||||
broken: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const response = (await handleCommand(
|
||||
createContext(tempRoot),
|
||||
"upsert_mcp_server",
|
||||
{
|
||||
input: {
|
||||
name: "linear",
|
||||
previousName: "linear",
|
||||
transportType: "streamableHttp",
|
||||
url: "http://127.0.0.1:1/mcp",
|
||||
disabled: false,
|
||||
metadata: { source: "edited" },
|
||||
},
|
||||
},
|
||||
)) as JsonRecord;
|
||||
const servers = response.servers as JsonRecord[];
|
||||
expect(servers.find((server) => server.name === "linear")).toMatchObject({
|
||||
disabled: false,
|
||||
metadata: { source: "edited" },
|
||||
});
|
||||
expect(servers.find((server) => server.name === "broken")).toHaveProperty(
|
||||
"configurationError",
|
||||
);
|
||||
|
||||
const written = JSON.parse(await readFile(settingsPath, "utf8"));
|
||||
expect(written.mcpServers.linear.disabled).toBe(false);
|
||||
} finally {
|
||||
if (previousSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,148 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import {
|
||||
getMcpServerOAuthStatus,
|
||||
parseMcpServerRegistration,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function shouldProbeMcpServerAfterUpsert(options: {
|
||||
isRemote: boolean;
|
||||
requestedDisabled: boolean;
|
||||
existingWasEnabled: boolean;
|
||||
transportIdentityUnchanged: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
options.isRemote &&
|
||||
!options.requestedDisabled &&
|
||||
!(options.existingWasEnabled && options.transportIdentityUnchanged)
|
||||
);
|
||||
}
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
return buildMcpServersResponse(settingsPath, parsed);
|
||||
}
|
||||
|
||||
export function buildMcpServersResponse(
|
||||
settingsPath: string,
|
||||
parsed: JsonRecord,
|
||||
): JsonRecord {
|
||||
const serversValue = parsed.mcpServers;
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? (serversValue as JsonRecord)
|
||||
: {};
|
||||
const entries = Object.entries(servers).map(([name, body]) => {
|
||||
const record =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as JsonRecord)
|
||||
: {};
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
let registration: ReturnType<typeof parseMcpServerRegistration> | undefined;
|
||||
let configurationError: string | undefined;
|
||||
try {
|
||||
registration = parseMcpServerRegistration(name, body);
|
||||
} catch (error) {
|
||||
configurationError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const resolvedTransport = registration?.transport;
|
||||
const rawTransportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "",
|
||||
).trim();
|
||||
const hasRawUrl =
|
||||
typeof transport?.url === "string" || typeof record.url === "string";
|
||||
const fallbackTransportType =
|
||||
rawTransportType === "sse"
|
||||
? "sse"
|
||||
: rawTransportType === "streamableHttp" || rawTransportType === "http"
|
||||
? "streamableHttp"
|
||||
: hasRawUrl
|
||||
? "sse"
|
||||
: "stdio";
|
||||
const oauthStatus = registration
|
||||
? getMcpServerOAuthStatus(registration)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
transportType: resolvedTransport?.type ?? fallbackTransportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
command: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.command
|
||||
: undefined
|
||||
: typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
args: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.args
|
||||
: undefined
|
||||
: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.cwd
|
||||
: undefined
|
||||
: typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
env: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.env
|
||||
: undefined
|
||||
: transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
url: resolvedTransport
|
||||
? resolvedTransport.type !== "stdio"
|
||||
? resolvedTransport.url
|
||||
: undefined
|
||||
: typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
headers: resolvedTransport
|
||||
? resolvedTransport.type !== "stdio"
|
||||
? resolvedTransport.headers
|
||||
: undefined
|
||||
: transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
metadata: registration?.metadata ?? record.metadata,
|
||||
...(configurationError ? { configurationError } : {}),
|
||||
oauthStatus: oauthStatus
|
||||
? {
|
||||
supported: oauthStatus.oauthSupported,
|
||||
configured: oauthStatus.oauthConfigured,
|
||||
authorizationRequired: oauthStatus.authorizationRequired,
|
||||
lastError: oauthStatus.lastError,
|
||||
lastAuthenticatedAt: oauthStatus.lastAuthenticatedAt,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
|
||||
@@ -129,6 +129,91 @@ describe("desktop error telemetry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards bounded source attribution for uncaught webview errors", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
const response = await handler(
|
||||
new Request("http://127.0.0.1:3126/telemetry/error", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "webview.uncaught_error",
|
||||
errorMessage: "Unexpected token '<'",
|
||||
errorType: "SyntaxError",
|
||||
handled: false,
|
||||
transportState: "connecting",
|
||||
sourceUrl: `tauri://localhost/_vercel/insights/script.js?${"q".repeat(600)}`,
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
stack: `SyntaxError: Unexpected token '<'\n${"x".repeat(600)}`,
|
||||
}),
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(202);
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
event: "sdk.error",
|
||||
properties: expect.objectContaining({
|
||||
component: "desktop",
|
||||
operation: "webview.uncaught_error",
|
||||
error_type: "SyntaxError",
|
||||
error_message: "Unexpected token '<'",
|
||||
handled: false,
|
||||
transportState: "connecting",
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
}),
|
||||
});
|
||||
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(properties.sourceUrl).toHaveLength(500);
|
||||
expect(
|
||||
String(properties.sourceUrl).startsWith(
|
||||
"tauri://localhost/_vercel/insights/script.js",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(properties.stack).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("drops malformed source attribution fields", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
const response = await handler(
|
||||
new Request("http://127.0.0.1:3126/telemetry/error", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "webview.uncaught_error",
|
||||
errorMessage: "boom",
|
||||
sourceUrl: 42,
|
||||
lineno: "one",
|
||||
colno: null,
|
||||
stack: " ",
|
||||
}),
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(202);
|
||||
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect("sourceUrl" in properties).toBe(false);
|
||||
expect("lineno" in properties).toBe(false);
|
||||
expect("colno" in properties).toBe(false);
|
||||
expect("stack" in properties).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects error reports from untrusted origins", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
import {
|
||||
BunRuntime,
|
||||
@@ -113,8 +114,16 @@ type DesktopClientErrorReport = {
|
||||
command?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
transportState?: unknown;
|
||||
sourceUrl?: unknown;
|
||||
lineno?: unknown;
|
||||
colno?: unknown;
|
||||
stack?: unknown;
|
||||
};
|
||||
|
||||
// Bound for free-form attribution strings (source URLs, stack traces);
|
||||
// matches ERROR_REPORT_FIELD_LIMIT in webview/lib/desktop-client.ts.
|
||||
const ERROR_REPORT_FIELD_LIMIT = 500;
|
||||
|
||||
function captureDesktopError(
|
||||
ctx: SidecarContext,
|
||||
operation: string,
|
||||
@@ -254,6 +263,24 @@ export function createFetchHandler(
|
||||
if (typeof report.transportState === "string") {
|
||||
context.transportState = report.transportState.slice(0, 30);
|
||||
}
|
||||
if (typeof report.sourceUrl === "string" && report.sourceUrl.trim()) {
|
||||
context.sourceUrl = report.sourceUrl.slice(
|
||||
0,
|
||||
ERROR_REPORT_FIELD_LIMIT,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof report.lineno === "number" &&
|
||||
Number.isFinite(report.lineno)
|
||||
) {
|
||||
context.lineno = report.lineno;
|
||||
}
|
||||
if (typeof report.colno === "number" && Number.isFinite(report.colno)) {
|
||||
context.colno = report.colno;
|
||||
}
|
||||
if (typeof report.stack === "string" && report.stack.trim()) {
|
||||
context.stack = report.stack.slice(0, ERROR_REPORT_FIELD_LIMIT);
|
||||
}
|
||||
captureDesktopError(
|
||||
ctx,
|
||||
operation,
|
||||
@@ -331,10 +358,11 @@ function createWebSocketHandler(ctx: SidecarContext) {
|
||||
},
|
||||
close(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.delete(ws);
|
||||
// OAuth logins are interactive: if the connection that started one
|
||||
// goes away (webview reload, transport drop), cancel it so the
|
||||
// abandoned browser flow can never persist credentials later.
|
||||
// Browser OAuth flows are interactive: if the connection that started
|
||||
// one goes away (webview reload, transport drop), cancel its callback
|
||||
// wait so the sidecar cannot retain an abandoned authorization attempt.
|
||||
cancelProviderOAuthLoginsForOwner(ws);
|
||||
cancelMcpOAuthAuthorizationsForOwner(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export type LiveSession = {
|
||||
attachedViaHub?: boolean;
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
|
||||
lastQueuedPromptStartId?: string;
|
||||
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
|
||||
consumedAttachmentFiles?: Map<string, string[]>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
normalizePickedDirectory,
|
||||
type PickerExec,
|
||||
pickWorkspaceDirectory,
|
||||
} from "./workspace-picker";
|
||||
|
||||
/** Builds an error shaped like execFile's rejection values. */
|
||||
function execError(props: { code?: unknown; signal?: unknown }): Error {
|
||||
return Object.assign(new Error("Command failed"), props);
|
||||
}
|
||||
|
||||
const CANCEL = execError({ code: 1 });
|
||||
const MISSING = execError({ code: "ENOENT" });
|
||||
const LAUNCH_FAILURE = execError({ code: "EACCES" });
|
||||
const CRASH = execError({ code: null, signal: "SIGSEGV" });
|
||||
|
||||
/**
|
||||
* Fake exec that responds per backend name and records the invocation order.
|
||||
* A function outcome resolves with its return value as stdout; an Error
|
||||
* outcome rejects.
|
||||
*/
|
||||
function fakeExec(outcomes: Record<string, string | Error>): {
|
||||
exec: PickerExec;
|
||||
calls: string[];
|
||||
} {
|
||||
const calls: string[] = [];
|
||||
const exec: PickerExec = (command) => {
|
||||
calls.push(command);
|
||||
const outcome = outcomes[command];
|
||||
if (outcome === undefined)
|
||||
return Promise.reject(execError({ code: "ENOENT" }));
|
||||
if (outcome instanceof Error) return Promise.reject(outcome);
|
||||
return Promise.resolve({ stdout: outcome });
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
describe("pickWorkspaceDirectory (linux)", () => {
|
||||
it("returns the zenity selection", async () => {
|
||||
const { exec } = fakeExec({ zenity: "/home/user/projects/app\n" });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the user cancels the zenity dialog", async () => {
|
||||
const { exec, calls } = fakeExec({ zenity: CANCEL });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBeNull();
|
||||
expect(calls).toEqual(["zenity"]);
|
||||
});
|
||||
|
||||
it("falls back to kdialog when zenity is missing", async () => {
|
||||
const { exec, calls } = fakeExec({
|
||||
zenity: MISSING,
|
||||
kdialog: "/home/user/projects/app\n",
|
||||
});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
expect(calls).toEqual(["zenity", "kdialog"]);
|
||||
});
|
||||
|
||||
// Regression: launch failures (EACCES, EMFILE, ENOMEM, ...) used to be
|
||||
// classified as user cancellation, which skipped the kdialog fallback and
|
||||
// silently swallowed the error.
|
||||
it("falls back to kdialog when zenity fails to launch", async () => {
|
||||
const { exec, calls } = fakeExec({
|
||||
zenity: LAUNCH_FAILURE,
|
||||
kdialog: "/home/user/projects/app\n",
|
||||
});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
expect(calls).toEqual(["zenity", "kdialog"]);
|
||||
});
|
||||
|
||||
it("throws a descriptive error when every backend fails to launch", async () => {
|
||||
const { exec } = fakeExec({ zenity: LAUNCH_FAILURE, kdialog: CRASH });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
|
||||
"zenity failed to launch (EACCES); kdialog was terminated by signal SIGSEGV",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws the unavailable message when no backend is installed", async () => {
|
||||
const { exec } = fakeExec({});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
|
||||
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickWorkspaceDirectory (darwin)", () => {
|
||||
it("returns the chosen folder", async () => {
|
||||
const { exec } = fakeExec({ osascript: "/Users/user/projects/app/\n" });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBe(
|
||||
"/Users/user/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the user cancels", async () => {
|
||||
const { exec } = fakeExec({ osascript: CANCEL });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("throws when osascript fails for a reason other than cancel", async () => {
|
||||
const { exec } = fakeExec({ osascript: execError({ code: "EMFILE" }) });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).rejects.toThrow(
|
||||
"osascript failed to launch (EMFILE)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePickedDirectory", () => {
|
||||
it("strips trailing whitespace and separators", () => {
|
||||
expect(normalizePickedDirectory("/home/user/app/\n")).toBe(
|
||||
"/home/user/app",
|
||||
);
|
||||
expect(normalizePickedDirectory("C:\\Users\\me\\app\\\\")).toBe(
|
||||
"C:\\Users\\me\\app",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the filesystem root intact", () => {
|
||||
expect(normalizePickedDirectory("/\n")).toBe("/");
|
||||
});
|
||||
|
||||
it("returns null for empty output", () => {
|
||||
expect(normalizePickedDirectory(" \n")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FOLDER_PICKER_UNAVAILABLE_MESSAGE =
|
||||
"No system folder picker found (zenity or kdialog). Type or paste a folder path in the workspace selector instead.";
|
||||
|
||||
/** Minimal exec surface so tests can simulate picker backends failing. */
|
||||
export type PickerExec = (
|
||||
command: string,
|
||||
args: string[],
|
||||
) => Promise<{ stdout: string }>;
|
||||
|
||||
const defaultExec: PickerExec = (command, args) =>
|
||||
execFileAsync(command, args, { encoding: "utf8" });
|
||||
|
||||
interface ExecErrorShape {
|
||||
code?: unknown;
|
||||
signal?: unknown;
|
||||
}
|
||||
|
||||
function execErrorShape(error: unknown): ExecErrorShape {
|
||||
return typeof error === "object" && error !== null
|
||||
? (error as ExecErrorShape)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** A binary that isn't installed rejects at spawn time with code "ENOENT". */
|
||||
export function isPickerCommandMissing(error: unknown): boolean {
|
||||
return execErrorShape(error).code === "ENOENT";
|
||||
}
|
||||
|
||||
// zenity, kdialog, and osascript all exit with status 1 when the user
|
||||
// dismisses the dialog, which execFile reports as an error with a numeric
|
||||
// `code` and no `signal`. Launch failures (EACCES, EMFILE, ENOMEM, ...)
|
||||
// carry a string code instead, and crashes carry a `signal` — neither of
|
||||
// those means the user cancelled, so they must not be swallowed.
|
||||
export function isPickerCancellation(error: unknown): boolean {
|
||||
const { code, signal } = execErrorShape(error);
|
||||
return !signal && code === 1;
|
||||
}
|
||||
|
||||
export function describePickerFailure(name: string, error: unknown): string {
|
||||
const { code, signal } = execErrorShape(error);
|
||||
if (signal) return `${name} was terminated by signal ${String(signal)}`;
|
||||
if (typeof code === "number") return `${name} exited with code ${code}`;
|
||||
if (typeof code === "string") return `${name} failed to launch (${code})`;
|
||||
return `${name} failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
export function folderPickerFailedMessage(failures: string[]): string {
|
||||
return `The folder picker could not be opened (${failures.join("; ")}). Type or paste a folder path in the workspace selector instead.`;
|
||||
}
|
||||
|
||||
export function normalizePickedDirectory(stdout: string): string | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
// Dialogs occasionally return trailing separators (typed paths, GTK
|
||||
// location bar); strip them so downstream normalization matches catalog
|
||||
// entries.
|
||||
const withoutTrailing = trimmed.replace(/(?<=.)[\\/]+$/, "");
|
||||
return withoutTrailing || null;
|
||||
}
|
||||
|
||||
// Async is load-bearing here: the native picker blocks until the user chooses
|
||||
// a folder, and a synchronous exec would freeze every other sidecar command
|
||||
// (chat streams, history, settings) for however long the dialog stays open.
|
||||
//
|
||||
// Contract: resolves to a path, resolves to null only when the user cancels,
|
||||
// and throws when no picker backend could be opened — whether the binaries
|
||||
// are missing or they failed to launch — so the UI can surface a manual
|
||||
// path-entry fallback instead of a silent no-op.
|
||||
export async function pickWorkspaceDirectory(
|
||||
exec: PickerExec = defaultExec,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | null> {
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const { stdout } = await exec("osascript", [
|
||||
"-e",
|
||||
'set theFolder to choose folder with prompt "Select workspace directory"',
|
||||
"-e",
|
||||
"return POSIX path of theFolder",
|
||||
]);
|
||||
return normalizePickedDirectory(stdout);
|
||||
} catch (error) {
|
||||
if (isPickerCancellation(error)) return null;
|
||||
// osascript ships with macOS, so anything else is a real failure.
|
||||
throw new Error(
|
||||
folderPickerFailedMessage([describePickerFailure("osascript", error)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Linux — try zenity, then kdialog. A backend that is missing or fails to
|
||||
// launch falls through to the next candidate; only a clean cancel exit
|
||||
// from a dialog that actually opened is treated as user cancellation.
|
||||
const backends: { name: string; args: string[] }[] = [
|
||||
{
|
||||
name: "zenity",
|
||||
args: [
|
||||
"--file-selection",
|
||||
"--directory",
|
||||
"--title=Select workspace directory",
|
||||
],
|
||||
},
|
||||
{ name: "kdialog", args: ["--getexistingdirectory", homedir()] },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const backend of backends) {
|
||||
try {
|
||||
const { stdout } = await exec(backend.name, backend.args);
|
||||
return normalizePickedDirectory(stdout);
|
||||
} catch (error) {
|
||||
if (isPickerCancellation(error)) return null;
|
||||
if (!isPickerCommandMissing(error)) {
|
||||
failures.push(describePickerFailure(backend.name, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
failures.length > 0
|
||||
? folderPickerFailedMessage(failures)
|
||||
: FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.11",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -14,11 +14,42 @@
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
/* Keep scroll momentum inside the app: without this, hitting the end
|
||||
* of an inner scroller rubber-bands the whole window like a web page. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native-app chrome: labels, buttons, and panel text must not highlight
|
||||
* when the user drags across them. Content the user genuinely reads and
|
||||
* copies — chat messages, code, diffs, form fields — opts back in below.
|
||||
*/
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Selectable content surfaces: chat message bodies (incl. markdown and
|
||||
* code blocks), reasoning/tool panels, and diff text. */
|
||||
.cline-chat-message-content,
|
||||
.cline-markdown,
|
||||
.cline-chat-selectable,
|
||||
pre,
|
||||
code {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -103,21 +134,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Softens the welcome <-> conversation swap: the hero and the message grid
|
||||
* replace each other in a single commit, which otherwise reads as a hard
|
||||
* white flash. Plays whenever the element (re)becomes visible — display:none
|
||||
* resets animations, so toggling Tailwind's `hidden` re-triggers it. */
|
||||
@keyframes cline-view-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: cline-view-enter 180ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-streaming-title {
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool panels scroll on both axes, but a horizontal thumb inside a short
|
||||
* disclosure eats a whole detail row. Keep the axis scrollable (wheel, trackpad,
|
||||
* keyboard, drag-select) and hide only its bar; the vertical one stays visible.
|
||||
* `:horizontal` is the one selector that separates the two axes — the standard
|
||||
* `scrollbar-width: none` would take out both. */
|
||||
.cline-chat-scroll-x-bare::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
.cline-view-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import { DesktopErrorTelemetry } from "@/components/desktop-error-telemetry";
|
||||
import { NativeShell } from "@/components/native-shell";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { HUB_THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
@@ -48,9 +48,9 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className="h-full min-h-screen font-sans antialiased">
|
||||
<DesktopErrorTelemetry />
|
||||
<NativeShell />
|
||||
{children}
|
||||
<Toaster />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import { ImagePlus, Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -30,14 +31,10 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { OnboardingView } from "@/components/views/onboarding/onboarding-view";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
|
||||
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
|
||||
import type { SettingsSection } from "@/components/views/settings/sections";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { useAppUpdate } from "@/hooks/use-app-update";
|
||||
@@ -65,7 +62,13 @@ import {
|
||||
markOnboardingCompleted,
|
||||
ONBOARDING_RESET_EVENT,
|
||||
} from "@/lib/onboarding";
|
||||
import { fetchProviderCatalog } from "@/lib/provider-model-catalog";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
readProviderCatalogSnapshot,
|
||||
subscribeToProviderCatalogInvalidation,
|
||||
writeProviderCatalogSnapshot,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import {
|
||||
buildSessionAgentActivity,
|
||||
mergeAgentActivity,
|
||||
@@ -85,6 +88,51 @@ import {
|
||||
writeWorkspaceSelectionToWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
// Lazily loaded views: none of these are needed for the first paint of the
|
||||
// chat shell, so keeping them out of the entry chunk shortens app startup.
|
||||
// Each fallback paints the same background as the loaded view so switching
|
||||
// never flashes.
|
||||
const viewLoading = () => (
|
||||
<div className="flex h-full flex-1 items-center justify-center bg-background">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/settings/settings-view").then(
|
||||
(module) => module.SettingsView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
const SessionsView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/sessions/sessions-view").then(
|
||||
(module) => module.SessionsView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
const OnboardingView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/onboarding/onboarding-view").then(
|
||||
(module) => module.OnboardingView,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="h-full w-full bg-background" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const DiffView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/chat/diff-view").then(
|
||||
(module) => module.DiffView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
@@ -113,6 +161,11 @@ export default function Home() {
|
||||
// Starts false on both server and first client render (hydration-safe);
|
||||
// the effect below reads the persisted state right after mount.
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
// "welcome" for the full first-run flow; "connect" when re-entered from
|
||||
// the in-app "connect a model" notice, which should land directly on the
|
||||
// provider setup step.
|
||||
const [onboardingInitialStep, setOnboardingInitialStep] =
|
||||
useState<OnboardingStep>("welcome");
|
||||
const { navigation, threads } = appState;
|
||||
const { activeThreadId, settingsSection, view } = navigation.current;
|
||||
|
||||
@@ -167,11 +220,17 @@ export default function Home() {
|
||||
const completeOnboarding = useCallback(() => {
|
||||
markOnboardingCompleted();
|
||||
setShowOnboarding(false);
|
||||
setOnboardingInitialStep("welcome");
|
||||
// A fresh thread remounts the chat pane so it picks up credentials and
|
||||
// the provider/model selection configured during onboarding.
|
||||
handleNewThread();
|
||||
}, [handleNewThread]);
|
||||
|
||||
const handleOpenSetup = useCallback(() => {
|
||||
setOnboardingInitialStep("connect");
|
||||
setShowOnboarding(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenSession = useCallback(
|
||||
(session: SessionHistoryItem, initialPromptDraft?: string) => {
|
||||
dispatchApp({ type: "open-session", session, initialPromptDraft });
|
||||
@@ -252,6 +311,27 @@ export default function Home() {
|
||||
}),
|
||||
[handleNewThread, handleViewChange],
|
||||
);
|
||||
// Standard app shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for
|
||||
// settings — matching the tray menu actions.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (showOnboarding) {
|
||||
return;
|
||||
}
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "n" || event.key === "N") {
|
||||
event.preventDefault();
|
||||
handleNewThread();
|
||||
} else if (event.key === ",") {
|
||||
event.preventDefault();
|
||||
handleViewChange("settings");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleNewThread, handleViewChange, showOnboarding]);
|
||||
const handleThreadStarted = useCallback((threadId: string) => {
|
||||
dispatchApp({ type: "thread-started", threadId });
|
||||
}, []);
|
||||
@@ -372,6 +452,10 @@ export default function Home() {
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onOpenSetup={handleOpenSetup}
|
||||
onOpenModelSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
@@ -391,13 +475,23 @@ export default function Home() {
|
||||
</SidebarProvider>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<OnboardingView onComplete={completeOnboarding} />
|
||||
<OnboardingView
|
||||
initialStep={onboardingInitialStep}
|
||||
onComplete={completeOnboarding}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// "+ new chat" remounts ChatThreadPane with a fresh thread id, and the pane
|
||||
// blocks on the provider catalog (a large fetch) before rendering anything.
|
||||
// Seed remounts from the last successful load (kept in the catalog module,
|
||||
// where credential changes invalidate it) so only the first-ever mount shows
|
||||
// the boot spinner; the effect still refreshes in the background.
|
||||
let workspacesLoadedOnce = false;
|
||||
|
||||
function ChatThreadPane({
|
||||
threadId,
|
||||
historySession,
|
||||
@@ -409,6 +503,8 @@ function ChatThreadPane({
|
||||
onNewThread,
|
||||
onOpenSession,
|
||||
onOpenSessionById,
|
||||
onOpenSetup,
|
||||
onOpenModelSettings,
|
||||
parentSession,
|
||||
onThreadStarted,
|
||||
}: {
|
||||
@@ -428,6 +524,8 @@ function ChatThreadPane({
|
||||
initialPromptDraft?: string,
|
||||
) => void;
|
||||
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
|
||||
onOpenSetup?: () => void;
|
||||
onOpenModelSettings?: () => void;
|
||||
parentSession?: { sessionId: string; title?: string };
|
||||
onThreadStarted?: (threadId: string) => void;
|
||||
}) {
|
||||
@@ -484,13 +582,23 @@ function ChatThreadPane({
|
||||
const [dismissedHistorySessionId, setDismissedHistorySessionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [gitBranch, setGitBranch] = useState("no-git");
|
||||
// Branch name, "no-git" once the folder is confirmed to not be a git
|
||||
// repository, or null while branch discovery is pending.
|
||||
const [gitBranch, setGitBranch] = useState<string | null>(null);
|
||||
const [providerCredentials, setProviderCredentials] = useState<
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
>(() => readProviderCatalogSnapshot()?.credentials ?? {});
|
||||
const [providerModelContextWindows, setProviderModelContextWindows] =
|
||||
useState<Record<string, Record<string, number>>>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
useState<Record<string, Record<string, number>>>(
|
||||
() => readProviderCatalogSnapshot()?.contextWindows ?? {},
|
||||
);
|
||||
const [providersLoaded, setProvidersLoaded] = useState(
|
||||
() => readProviderCatalogSnapshot() !== null,
|
||||
);
|
||||
// null = unknown (catalog unavailable): never nag in that case.
|
||||
const [hasConnectedProvider, setHasConnectedProvider] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
// History paths lead each merge: they are ordered by session recency, so
|
||||
// stored or stale entries only append after them.
|
||||
const [workspaces, setWorkspaces] = useState<string[]>(() =>
|
||||
@@ -501,7 +609,9 @@ function ChatThreadPane({
|
||||
),
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(
|
||||
() => workspacesLoadedOnce,
|
||||
);
|
||||
const hydratedSessionRef = useRef<string | null>(null);
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
const manualTitleSessionRef = useRef<string | null>(null);
|
||||
@@ -537,53 +647,67 @@ function ChatThreadPane({
|
||||
});
|
||||
}, [config.cwd, config.workspaceRoot, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadProviderCredentials() {
|
||||
try {
|
||||
const payload = await fetchProviderCatalog();
|
||||
if (cancelled) {
|
||||
return;
|
||||
const providerCredentialsRequestRef = useRef(0);
|
||||
const loadProviderCredentials = useCallback(async () => {
|
||||
const requestId = ++providerCredentialsRequestRef.current;
|
||||
try {
|
||||
const payload = await fetchProviderCatalog();
|
||||
if (providerCredentialsRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
const next: Record<string, { apiKey: string }> = {};
|
||||
const nextContextWindows: Record<string, Record<string, number>> = {};
|
||||
let anyConnected = false;
|
||||
for (const provider of payload.providers ?? []) {
|
||||
const id = provider.id?.trim();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const next: Record<string, { apiKey: string }> = {};
|
||||
const nextContextWindows: Record<string, Record<string, number>> = {};
|
||||
for (const provider of payload.providers ?? []) {
|
||||
const id = provider.id?.trim();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
next[id] = {
|
||||
apiKey: provider.apiKey?.trim() ?? "",
|
||||
};
|
||||
const contextWindows: Record<string, number> = {};
|
||||
for (const model of provider.modelList ?? []) {
|
||||
if (
|
||||
model.id &&
|
||||
typeof model.contextWindow === "number" &&
|
||||
Number.isFinite(model.contextWindow) &&
|
||||
model.contextWindow > 0
|
||||
) {
|
||||
contextWindows[model.id] = model.contextWindow;
|
||||
}
|
||||
}
|
||||
nextContextWindows[id] = contextWindows;
|
||||
next[id] = {
|
||||
apiKey: provider.apiKey?.trim() ?? "",
|
||||
};
|
||||
if (isProviderConnected(provider)) {
|
||||
anyConnected = true;
|
||||
}
|
||||
setProviderCredentials(next);
|
||||
setProviderModelContextWindows(nextContextWindows);
|
||||
} catch {
|
||||
// Keep current config if provider catalog cannot be read.
|
||||
} finally {
|
||||
if (!cancelled) setProvidersLoaded(true);
|
||||
const contextWindows: Record<string, number> = {};
|
||||
for (const model of provider.modelList ?? []) {
|
||||
if (
|
||||
model.id &&
|
||||
typeof model.contextWindow === "number" &&
|
||||
Number.isFinite(model.contextWindow) &&
|
||||
model.contextWindow > 0
|
||||
) {
|
||||
contextWindows[model.id] = model.contextWindow;
|
||||
}
|
||||
}
|
||||
nextContextWindows[id] = contextWindows;
|
||||
}
|
||||
writeProviderCatalogSnapshot({
|
||||
credentials: next,
|
||||
contextWindows: nextContextWindows,
|
||||
});
|
||||
setProviderCredentials(next);
|
||||
setProviderModelContextWindows(nextContextWindows);
|
||||
setHasConnectedProvider(anyConnected);
|
||||
} catch {
|
||||
// Keep current config if provider catalog cannot be read.
|
||||
} finally {
|
||||
if (providerCredentialsRequestRef.current === requestId) {
|
||||
setProvidersLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
void loadProviderCredentials();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviderCredentials();
|
||||
// Credentials saved elsewhere (settings, onboarding, OAuth) invalidate
|
||||
// the catalog cache; reload so the setup notice reflects reality
|
||||
// without waiting for a pane remount.
|
||||
return subscribeToProviderCatalogInvalidation(() => {
|
||||
void loadProviderCredentials();
|
||||
});
|
||||
}, [loadProviderCredentials]);
|
||||
|
||||
const modelContextWindow =
|
||||
providerModelContextWindows[config.provider.trim()]?.[config.model.trim()];
|
||||
|
||||
@@ -636,7 +760,9 @@ function ChatThreadPane({
|
||||
|
||||
const invalidateGitBranch = useCallback(() => {
|
||||
gitBranchRequestGateRef.current.invalidate();
|
||||
setGitBranch("no-git");
|
||||
// Back to pending: the next workspace hasn't been classified yet, so
|
||||
// don't report it as a confirmed non-repo in the meantime.
|
||||
setGitBranch(null);
|
||||
}, []);
|
||||
|
||||
const listGitBranches = useCallback(async (): Promise<{
|
||||
@@ -713,6 +839,7 @@ function ChatThreadPane({
|
||||
: merged;
|
||||
});
|
||||
} finally {
|
||||
workspacesLoadedOnce = true;
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
},
|
||||
@@ -738,10 +865,13 @@ function ChatThreadPane({
|
||||
return true;
|
||||
}
|
||||
const validation = await desktopClient
|
||||
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
|
||||
.invoke<{
|
||||
valid?: boolean;
|
||||
path?: string;
|
||||
}>("validate_workspace_directory", {
|
||||
path: nextWorkspace,
|
||||
})
|
||||
.catch(() => ({ valid: false }));
|
||||
.catch(() => ({ valid: false, path: undefined }));
|
||||
if (validation.valid !== true) {
|
||||
return false;
|
||||
}
|
||||
@@ -749,14 +879,21 @@ function ChatThreadPane({
|
||||
return false;
|
||||
}
|
||||
|
||||
// The sidecar may resolve shorthand input (e.g. "~/projects/app")
|
||||
// into an absolute path; adopt the resolved form.
|
||||
const resolvedWorkspace =
|
||||
typeof validation.path === "string" && validation.path.trim()
|
||||
? validation.path.trim()
|
||||
: nextWorkspace;
|
||||
|
||||
invalidateGitBranch();
|
||||
setWorkspacePath(nextWorkspace);
|
||||
setWorkspacePath(resolvedWorkspace);
|
||||
setWorkspaces((prev) =>
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [resolvedWorkspace])),
|
||||
);
|
||||
|
||||
// Refresh the merged history, stored, and current workspace catalog.
|
||||
void refreshWorkspaces(nextWorkspace);
|
||||
void refreshWorkspaces(resolvedWorkspace);
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -772,6 +909,9 @@ function ChatThreadPane({
|
||||
|
||||
const pickWorkspaceDirectory = useCallback(
|
||||
async (initialPath?: string): Promise<string | null> => {
|
||||
// Resolves to null when the user cancels; rethrows picker failures
|
||||
// (e.g. no zenity/kdialog on Linux) so callers can surface an error
|
||||
// and offer manual path entry instead of a silent no-op.
|
||||
try {
|
||||
const selected = await desktopClient.invoke<string | null>(
|
||||
"pick_workspace_directory",
|
||||
@@ -784,8 +924,12 @@ function ChatThreadPane({
|
||||
}
|
||||
const trimmed = selected.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "The folder picker could not be opened.",
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -1009,18 +1153,12 @@ function ChatThreadPane({
|
||||
}
|
||||
setDeletingSession(true);
|
||||
try {
|
||||
console.error(
|
||||
`[webview:delete] invoke delete_chat_session sessionId=${activeSessionToDelete}`,
|
||||
);
|
||||
const deleted = await desktopClient.invoke<boolean>(
|
||||
"delete_chat_session",
|
||||
{
|
||||
sessionId: activeSessionToDelete,
|
||||
},
|
||||
);
|
||||
console.error(
|
||||
`[webview:delete] invoke result sessionId=${activeSessionToDelete} deleted=${deleted}`,
|
||||
);
|
||||
if (!deleted) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -1046,9 +1184,6 @@ function ChatThreadPane({
|
||||
setShowDiffView(false);
|
||||
void reset();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[webview:delete] invoke error sessionId=${activeSessionToDelete} error=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
const description =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -1134,11 +1269,61 @@ function ChatThreadPane({
|
||||
[handleAttachFiles],
|
||||
);
|
||||
|
||||
const attachmentList = pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
isImage: file.type.startsWith("image/"),
|
||||
}));
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
isImage: file.type.startsWith("image/"),
|
||||
})),
|
||||
[pendingAttachments],
|
||||
);
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
const handleAbort = useCallback(() => {
|
||||
void abort();
|
||||
}, [abort]);
|
||||
const handleModelChange = useCallback(
|
||||
(nextModel: string) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
),
|
||||
[setConfig],
|
||||
);
|
||||
const handleModeToggle = useCallback(
|
||||
() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
})),
|
||||
[setConfig],
|
||||
);
|
||||
const handleProviderChange = useCallback(
|
||||
(nextProvider: string) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
}),
|
||||
[providerCredentials, setConfig],
|
||||
);
|
||||
const handleSendPrompt = useCallback(
|
||||
(prompt: string) => void handleSend(prompt),
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const firstUserMessage = messages.find(
|
||||
(message) => message.role === "user",
|
||||
@@ -1152,6 +1337,18 @@ function ChatThreadPane({
|
||||
: (visibleHistorySession?.prompt ?? firstUserMessage),
|
||||
});
|
||||
const hasDiffChanges = summary.additions + summary.deletions > 0;
|
||||
const headerDiff = useMemo(
|
||||
() => ({
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}),
|
||||
[summary.additions, summary.deletions],
|
||||
);
|
||||
const handleOpenDiff = useCallback(() => {
|
||||
if (summary.additions + summary.deletions > 0) {
|
||||
setShowDiffView(true);
|
||||
}
|
||||
}, [summary.additions, summary.deletions]);
|
||||
|
||||
const activeSessionForTitle = hideDeletedSessionUi
|
||||
? null
|
||||
@@ -1295,49 +1492,20 @@ function ChatThreadPane({
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAbort={handleAbort}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onModelChange={handleModelChange}
|
||||
onModeToggle={handleModeToggle}
|
||||
onPromptInputChange={handlePromptInputChange}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={steerPromptInQueue}
|
||||
onEditPromptInQueue={updatePromptInQueue}
|
||||
onRemovePromptInQueue={handleRemoveQueuedPrompt}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onSend={(prompt) => void handleSend(prompt)}
|
||||
onProviderChange={handleProviderChange}
|
||||
onSend={handleSendPrompt}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
modelContextWindow={modelContextWindow}
|
||||
@@ -1381,7 +1549,7 @@ function ChatThreadPane({
|
||||
</div>
|
||||
) : null}
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
@@ -1394,15 +1562,10 @@ function ChatThreadPane({
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
@@ -1441,6 +1604,17 @@ function ChatThreadPane({
|
||||
}
|
||||
composer={composer}
|
||||
gitBranch={gitBranch}
|
||||
notice={
|
||||
providersLoaded &&
|
||||
hasConnectedProvider === false &&
|
||||
onOpenSetup &&
|
||||
onOpenModelSettings ? (
|
||||
<WelcomeSetupNotice
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onOpenSetup={onOpenSetup}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onListGitBranches={listGitBranches}
|
||||
onStartChat={setPromptInput}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
@@ -1453,11 +1627,6 @@ function ChatThreadPane({
|
||||
if (deletingSession) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
console.error(
|
||||
`[webview:delete] cancelled sessionId=${activeSessionToDelete ?? "null"}`,
|
||||
);
|
||||
}
|
||||
setDeleteConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import { type CSSProperties, memo, useEffect, useMemo, useState } from "react";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
agentEntryState,
|
||||
@@ -62,7 +62,7 @@ type AgentHeaderProps = {
|
||||
onOpenParentSession?: (parentSessionId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AgentHeader({
|
||||
function AgentHeaderImpl({
|
||||
title,
|
||||
canEditTitle,
|
||||
renamingTitle,
|
||||
@@ -179,7 +179,7 @@ export function AgentHeader({
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
canEditTitle &&
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-surface-hover",
|
||||
)}
|
||||
disabled={renamingTitle}
|
||||
onClick={(event) => {
|
||||
@@ -265,7 +265,7 @@ export function AgentHeader({
|
||||
) : (
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@@ -279,6 +279,11 @@ export function AgentHeader({
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: the header sits above the streaming conversation and would
|
||||
// otherwise re-render on every stream flush; its props are kept
|
||||
// referentially stable by the chat pane.
|
||||
export const AgentHeader = memo(AgentHeaderImpl);
|
||||
|
||||
/**
|
||||
* Route from a child agent run back to the session that spawned it, in the
|
||||
* header slot the "new session" button occupies elsewhere.
|
||||
@@ -304,7 +309,7 @@ function SubagentSessionBadge({
|
||||
return (
|
||||
<Button
|
||||
aria-label={hint}
|
||||
className="h-7 shrink-0 gap-1 rounded-md text-xs font-normal text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="h-7 shrink-0 gap-1 rounded-md text-xs font-normal text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
disabled={!onOpenParentSession}
|
||||
id="subagent-session-badge"
|
||||
onClick={() => void onOpenParentSession?.(parentSession.sessionId)}
|
||||
@@ -529,7 +534,7 @@ function AgentRosterRow({
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/60"
|
||||
className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
|
||||
onClick={onSelect}
|
||||
title="Open this agent's session"
|
||||
type="button"
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
} from "@/components/views/settings/sections";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
@@ -163,7 +163,7 @@ function SettingsSectionNavigation({
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
collapsed && "size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
@@ -182,7 +182,7 @@ function SettingsSectionNavigation({
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
|
||||
"flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden",
|
||||
collapsed ? "w-full items-start" : "w-full",
|
||||
)}
|
||||
>
|
||||
@@ -479,7 +479,7 @@ export function AgentSidebar({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Filter sessions"
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
@@ -533,7 +533,7 @@ export function AgentSidebar({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground"
|
||||
size="icon"
|
||||
title={sortMode === "time" ? "Sort by time" : "Sort by project"}
|
||||
variant="ghost"
|
||||
@@ -589,7 +589,7 @@ export function AgentSidebar({
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-[4.75rem]",
|
||||
"flex h-12 shrink-0 items-center justify-end gap-0.5 pr-2 pl-19",
|
||||
isCollapsed && "px-0",
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
@@ -598,7 +598,7 @@ export function AgentSidebar({
|
||||
<>
|
||||
<Button
|
||||
aria-label="Previous page"
|
||||
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||
className="size-7 text-muted-foreground hover:bg-surface-hover"
|
||||
disabled={!canNavigateBack}
|
||||
onClick={navigateBack}
|
||||
size="icon"
|
||||
@@ -626,7 +626,7 @@ export function AgentSidebar({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-between px-3",
|
||||
"flex h-10 shrink-0 items-center justify-between px-2",
|
||||
isCollapsed && "px-1.5",
|
||||
)}
|
||||
>
|
||||
@@ -644,7 +644,7 @@ export function AgentSidebar({
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
isCollapsed && "size-9",
|
||||
)}
|
||||
onClick={openHome}
|
||||
@@ -733,11 +733,11 @@ export function AgentSidebar({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 shrink-0 px-3">
|
||||
<div className="mt-5 shrink-0 pl-4 pr-2">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-muted-foreground transition-colors hover:text-sidebar-foreground",
|
||||
"min-w-0 truncate text-sm font-medium text-muted-foreground",
|
||||
view === "sessions" && "text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openSessions}
|
||||
@@ -748,7 +748,7 @@ export function AgentSidebar({
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
aria-label="Search sessions"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:bg-surface-hover"
|
||||
onClick={() => setSearchOpen((current) => !current)}
|
||||
size="icon"
|
||||
title="Search sessions"
|
||||
@@ -777,7 +777,7 @@ export function AgentSidebar({
|
||||
|
||||
<div className="mt-1 min-h-0 w-full flex-1">
|
||||
<ScrollArea className="h-full min-h-0 w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-2">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
@@ -899,16 +899,16 @@ export function AgentSidebar({
|
||||
<button
|
||||
aria-label="Account settings"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 rounded-md p-2 text-left text-sidebar-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
view === "settings" &&
|
||||
settingsSection === "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Account")}
|
||||
title={user.email || undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
||||
{accountInitial}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col leading-tight">
|
||||
@@ -928,7 +928,7 @@ export function AgentSidebar({
|
||||
"size-9 shrink-0 justify-center px-0",
|
||||
view === "settings" &&
|
||||
settingsSection !== "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
@@ -945,7 +945,7 @@ export function AgentSidebar({
|
||||
"min-w-0 justify-start",
|
||||
isCollapsed && "size-9 justify-center px-0",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
@@ -1022,7 +1022,7 @@ function ProjectSection({
|
||||
<div className="mb-1 min-w-0">
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={onToggle}
|
||||
title={label}
|
||||
type="button"
|
||||
@@ -1089,7 +1089,7 @@ function ThreadItem({
|
||||
className={cn(
|
||||
"grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2",
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
@@ -1114,10 +1114,10 @@ function ThreadItem({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-surface-hover",
|
||||
)}
|
||||
disabled={pending}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @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 { DesktopErrorTelemetry } from "./desktop-error-telemetry";
|
||||
|
||||
const { reportError } = vi.hoisted(() => ({ reportError: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { reportError },
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
reportError.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function renderTelemetry() {
|
||||
await act(async () => {
|
||||
root.render(<DesktopErrorTelemetry />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("DesktopErrorTelemetry", () => {
|
||||
it("attributes uncaught errors to their source URL and position", async () => {
|
||||
await renderTelemetry();
|
||||
|
||||
// A script URL answered with HTML surfaces exactly like this: a parse
|
||||
// SyntaxError whose only pointer to the failing resource is filename.
|
||||
window.dispatchEvent(
|
||||
new ErrorEvent("error", {
|
||||
message: "Uncaught SyntaxError: Unexpected token '<'",
|
||||
filename: "tauri://localhost/_vercel/insights/script.js",
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: "webview.uncaught_error",
|
||||
handled: false,
|
||||
sourceUrl: "tauri://localhost/_vercel/insights/script.js",
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits attribution fields when the ErrorEvent carries none", async () => {
|
||||
await renderTelemetry();
|
||||
|
||||
window.dispatchEvent(
|
||||
new ErrorEvent("error", {
|
||||
message: "boom",
|
||||
error: new Error("boom"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
const report = reportError.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(report.operation).toBe("webview.uncaught_error");
|
||||
expect(report.sourceUrl).toBeUndefined();
|
||||
expect(report.lineno).toBeUndefined();
|
||||
expect(report.colno).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ export function DesktopErrorTelemetry() {
|
||||
error:
|
||||
event.error ?? new Error(event.message || "Unknown webview error"),
|
||||
handled: false,
|
||||
// For script-load failures (e.g. a chunk URL answered with HTML)
|
||||
// the ErrorEvent's filename is the only pointer to the failing
|
||||
// resource — the error object has no stack in that case.
|
||||
sourceUrl: event.filename || undefined,
|
||||
lineno: event.lineno || undefined,
|
||||
colno: event.colno || undefined,
|
||||
});
|
||||
};
|
||||
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
function isEditable(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest("input, textarea, [contenteditable='true']"));
|
||||
}
|
||||
|
||||
function hasTextSelection(): boolean {
|
||||
const selection = window.getSelection();
|
||||
return Boolean(selection && !selection.isCollapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppresses the WebView's built-in browser context menu (Back / Forward /
|
||||
* Reload / Inspect Element) so right-clicking app chrome behaves like a
|
||||
* native app instead of a web page.
|
||||
*
|
||||
* Radix context menus (e.g. on sidebar sessions) attach their own
|
||||
* `contextmenu` handlers on their triggers and call `preventDefault`
|
||||
* themselves, so they keep working. Editable fields and active text
|
||||
* selections keep the default menu for spellcheck / copy / paste.
|
||||
*/
|
||||
export function NativeShell() {
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (isEditable(event.target) || hasTextSelection()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
// Non-capture: runs after component-level handlers, so custom menus
|
||||
// that already prevented default are unaffected either way.
|
||||
window.addEventListener("contextmenu", handleContextMenu);
|
||||
return () => window.removeEventListener("contextmenu", handleContextMenu);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"text-foreground [a&]:hover:bg-surface-hover [a&]:hover:text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3 shrink-0 [&_svg]:shrink-0 outline-none 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",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3 shrink-0 [&_svg]:shrink-0 outline-none 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -13,25 +13,25 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background shadow-xs hover:bg-surface-hover hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-surface-hover",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"hover:bg-surface-hover hover:text-foreground dark:hover:bg-surface-hover",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
sidebar:
|
||||
"w-full text-left gap-2 rounded-none text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground self-start",
|
||||
"w-full text-left gap-2 rounded-none text-sm font-medium text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground self-start",
|
||||
sidebarItem:
|
||||
"!h-auto w-full justify-start text-left gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
|
||||
"!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 transition-colors hover:text-sidebar-foreground",
|
||||
text: "bg-transparent text-sm font-medium text-muted-foreground hover:text-accent-foreground",
|
||||
"!h-auto justify-start gap-1 px-3 py-1.5 text-xs font-medium 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]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
xs: "h-3 gap-1.5 px-3 has-[>svg]:size-3",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
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",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4 text-lg",
|
||||
icon: "size-5",
|
||||
"icon-sm": "size-3 p-1",
|
||||
"icon-lg": "size-10",
|
||||
|
||||
@@ -107,13 +107,16 @@ function Calendar({
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
"rounded-l-md bg-surface-hover",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
range_end: cn(
|
||||
"rounded-r-md bg-surface-hover",
|
||||
defaultClassNames.range_end,
|
||||
),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
"bg-surface-hover text-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
@@ -204,7 +207,7 @@ function CalendarDayButton({
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-surface-hover data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"section"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -1,349 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cssText = Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return <style>{cssText}</style>;
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -146,7 +146,7 @@ function ComboboxItem({
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-highlighted:bg-surface-hover data-highlighted:text-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
data-slot="combobox-item"
|
||||
@@ -182,7 +182,7 @@ function ComboboxLabel({
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||
"text-muted-foreground px-2 py-1.5 text-sm pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="combobox-label"
|
||||
@@ -251,7 +251,7 @@ function ComboboxChip({
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
className={cn(
|
||||
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-sm font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="combobox-chip"
|
||||
|
||||
@@ -146,7 +146,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[selected=true]:bg-surface-hover data-[selected=true]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -66,7 +66,7 @@ function ContextMenuSubTrigger({
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -126,7 +126,7 @@ function ContextMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -144,7 +144,7 @@ function ContextMenuCheckboxItem({
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -169,7 +169,7 @@ function ContextMenuRadioItem({
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -69,7 +69,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-surface-hover data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,7 @@ function ItemSeparator({
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-surface-hover-lighter [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -55,7 +55,7 @@ function MenubarTrigger({
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="menubar-trigger"
|
||||
@@ -100,7 +100,7 @@ function MenubarItem({
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
@@ -121,7 +121,7 @@ function MenubarCheckboxItem({
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
data-slot="menubar-checkbox-item"
|
||||
@@ -145,7 +145,7 @@ function MenubarRadioItem({
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
data-slot="menubar-radio-item"
|
||||
@@ -227,7 +227,7 @@ function MenubarSubTrigger({
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
||||
@@ -59,7 +59,7 @@ function NavigationMenuItem({
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-surface-hover hover:text-foreground focus:bg-surface-hover focus:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-surface-hover data-[state=open]:text-foreground data-[state=open]:focus:bg-surface-hover data-[state=open]:bg-surface-hover focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
@@ -108,7 +108,7 @@ function NavigationMenuViewport({
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border shadow md:w-(--radix-navigation-menu-viewport-width)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -125,7 +125,7 @@ function NavigationMenuLink({
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[active=true]:focus:bg-surface-hover data-[active=true]:hover:bg-surface-hover data-[active=true]:bg-surface-hover data-[active=true]:text-foreground hover:bg-surface-hover hover:text-foreground focus:bg-surface-hover focus:text-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -141,7 +141,7 @@ function NavigationMenuIndicator({
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ function ScrollArea({
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
data-slot="scroll-area-viewport"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -37,7 +37,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
@@ -74,7 +74,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -107,7 +107,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-surface-hover focus:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -303,7 +303,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-120 group-data-[state=expanded]:ease-out group-data-[state=collapsed]:ease-in",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
@@ -314,7 +314,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-120 group-data-[state=expanded]:ease-out group-data-[state=collapsed]:ease-in md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
|
||||
@@ -430,7 +430,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
onPointerDown={handlePointerDown}
|
||||
title="Drag to resize or click to toggle sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
@@ -565,7 +565,7 @@ function SidebarGroupAction({
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@@ -613,13 +613,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-surface-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-surface-hover active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-surface-hover data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-surface-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
default: "hover:bg-surface-hover hover:text-sidebar-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-surface-hover hover:text-sidebar-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-border))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
@@ -700,7 +700,7 @@ function SidebarMenuAction({
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground peer-hover/menu-button:text-sidebar-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
@@ -726,7 +726,7 @@ function SidebarMenuBadge({
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
@@ -825,8 +825,8 @@ function SidebarMenuSubButton({
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-surface-hover hover:text-sidebar-foreground active:bg-surface-hover active:text-sidebar-foreground [&>svg]:text-sidebar-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-surface-hover data-[active=true]:text-sidebar-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
|
||||
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
className={cn("bg-surface-hover animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap has-[[role=checkbox]]:pr-0 *:[[role=checkbox]]:translate-y-0.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"p-2 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0 *:[[role=checkbox]]:translate-y-0.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-105",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,13 +7,13 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center cursor-pointer justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
"inline-flex items-center cursor-pointer justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:opacity-50 data-[state=on]:bg-surface-hover data-[state=on]:text-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-transparent shadow-xs hover:bg-surface-hover hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
|
||||
@@ -5,6 +5,10 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
parseModelSelectionStorage,
|
||||
} from "@/lib/model-selection";
|
||||
import {
|
||||
buildUserInstructionSlashCommands,
|
||||
ChatInputBar,
|
||||
@@ -479,6 +483,282 @@ describe("ChatInputBar", () => {
|
||||
|
||||
expect(onRemovePromptInQueue).toHaveBeenCalledWith("queued-prompt-1");
|
||||
});
|
||||
|
||||
it("displays a queued team command as its slash form, not the runtime envelope", async () => {
|
||||
const onEditPromptInQueue = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={onEditPromptInQueue}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[
|
||||
{
|
||||
id: "queued-team",
|
||||
prompt:
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
|
||||
steer: false,
|
||||
},
|
||||
]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="running"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const queueToggle = [
|
||||
...container.querySelectorAll<HTMLButtonElement>(
|
||||
"button[aria-controls][aria-expanded]",
|
||||
),
|
||||
].find((button) => button.textContent?.includes("prompt queued"));
|
||||
await act(async () => queueToggle?.click());
|
||||
|
||||
const queuedPrompts = document.getElementById(
|
||||
queueToggle?.getAttribute("aria-controls") ?? "",
|
||||
);
|
||||
expect(queuedPrompts?.textContent).toContain("/team inspect the app");
|
||||
expect(queuedPrompts?.textContent).not.toContain("<user_command");
|
||||
|
||||
// Editing prefills the slash form; the sidecar re-resolves it on save.
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Edit queued prompt"]')
|
||||
?.click();
|
||||
});
|
||||
const editor = container.querySelector<HTMLTextAreaElement>(
|
||||
'[aria-label="Edit queued prompt"]',
|
||||
);
|
||||
expect(editor?.value).toBe("/team inspect the app");
|
||||
});
|
||||
|
||||
it("does not overwrite the remembered model when rendering a session's provider/model", async () => {
|
||||
// Opening an existing session drives the composer's provider/model
|
||||
// props to that session's config. That passive change must not
|
||||
// replace the user's explicitly picked default for new sessions.
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model" },
|
||||
}),
|
||||
);
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["openrouter"],
|
||||
providerModels: {
|
||||
openrouter: ["old-session-model", "user-picked-model"],
|
||||
},
|
||||
providerReasoningModels: { openrouter: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="old-session-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
provider="openrouter"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking={false}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("openrouter");
|
||||
});
|
||||
// The composer displays the session's model...
|
||||
const modelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
expect(modelTrigger?.textContent).toContain("old-session-model");
|
||||
// ...but the remembered selection for new sessions stays intact.
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
),
|
||||
).toEqual({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model" },
|
||||
});
|
||||
|
||||
// An explicit pick in the model dropdown DOES update the remembered
|
||||
// selection.
|
||||
await act(async () => modelTrigger?.click());
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
const option = [
|
||||
...(panel?.querySelectorAll<HTMLButtonElement>("button") ?? []),
|
||||
].find((entry) => entry.textContent?.includes("user-picked-model"));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
),
|
||||
).toEqual({
|
||||
lastProvider: "openrouter",
|
||||
lastModelByProvider: {
|
||||
cline: "test-model",
|
||||
openrouter: "user-picked-model",
|
||||
},
|
||||
});
|
||||
|
||||
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("attaches clipboard images on paste instead of inserting text", async () => {
|
||||
const onAttachFiles = vi.fn();
|
||||
const onPromptInputChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={onAttachFiles}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={onPromptInputChange}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const promptInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[role="combobox"]',
|
||||
);
|
||||
expect(promptInput).not.toBeNull();
|
||||
|
||||
const pasteWithClipboard = async (items: unknown[]) => {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: { items, getData: () => "" },
|
||||
});
|
||||
await act(async () => {
|
||||
promptInput?.dispatchEvent(event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const png = new File(["fake"], "image.png", { type: "image/png" });
|
||||
const imagePaste = await pasteWithClipboard([
|
||||
{ kind: "file", type: "image/png", getAsFile: () => png },
|
||||
]);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(1);
|
||||
const attached = onAttachFiles.mock.calls[0][0] as File[];
|
||||
expect(attached).toHaveLength(1);
|
||||
expect(attached[0].name).toMatch(/^pasted-image-.+\.png$/);
|
||||
expect(imagePaste.defaultPrevented).toBe(true);
|
||||
|
||||
// Plain-text pastes stay untouched so normal text pasting keeps working.
|
||||
const textPaste = await pasteWithClipboard([
|
||||
{ kind: "string", type: "text/plain", getAsFile: () => null },
|
||||
]);
|
||||
expect(onAttachFiles).toHaveBeenCalledTimes(1);
|
||||
expect(textPaste.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInputBar token ring", () => {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleStop,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
formatDisplayUserInput,
|
||||
} from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -29,6 +24,7 @@ import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { formatCostUsd } from "@/hooks/use-session-history";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
@@ -82,6 +78,11 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ name: "team", description: "Start the task with an agent team" },
|
||||
];
|
||||
|
||||
// Last known user commands, kept across composer instances so reopening the
|
||||
// slash menu paints instantly (stale-while-revalidate); the fetch that
|
||||
// follows still picks up newly installed skills and workflows.
|
||||
let cachedSlashCommands: SlashCommand[] | null = null;
|
||||
|
||||
export function buildUserInstructionSlashCommands(
|
||||
response: UserInstructionConfigResponse,
|
||||
): SlashCommand[] {
|
||||
@@ -251,7 +252,8 @@ type ChatInputBarProps = {
|
||||
mode: "act" | "plan";
|
||||
thinking: ChatSessionConfig["thinking"];
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"];
|
||||
gitBranch: string;
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
gitBranch: string | null;
|
||||
promptDraft: PromptDraft;
|
||||
onPromptInputChange: (value: string) => void;
|
||||
onProviderChange: (provider: string) => void;
|
||||
@@ -283,7 +285,7 @@ type ChatInputBarProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
function ChatInputBarImpl({
|
||||
variant = "conversation",
|
||||
status,
|
||||
provider,
|
||||
@@ -410,7 +412,7 @@ export function ChatInputBar({
|
||||
: null;
|
||||
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
|
||||
BUILTIN_SLASH_COMMANDS,
|
||||
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
|
||||
);
|
||||
const [slashLoading, setSlashLoading] = useState(false);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
@@ -454,18 +456,22 @@ export function ChatInputBar({
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
// Focus the composer on mount/variant change and when text is injected
|
||||
// from outside (quick actions, queue undo). Deliberately NOT on every
|
||||
// keystroke: refocusing an already-focused textarea per keypress causes
|
||||
// caret flicker and forced layout while typing.
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) return;
|
||||
if (!input || document.activeElement === input) return;
|
||||
// The textarea is controlled, so its live value mirrors promptInput;
|
||||
// reading it here keeps keystrokes out of this effect's dependencies.
|
||||
if (
|
||||
variant === "conversation" ||
|
||||
(variant === "welcome" &&
|
||||
promptInput.trim().length > 0 &&
|
||||
document.activeElement !== input)
|
||||
(variant === "welcome" && input.value.trim().length > 0)
|
||||
) {
|
||||
input.focus();
|
||||
}
|
||||
}, [promptInput, variant]);
|
||||
}, [variant]);
|
||||
|
||||
useEffect(() => {
|
||||
setCursorIndex((prev) => Math.min(prev, promptInput.length));
|
||||
@@ -574,15 +580,18 @@ export function ChatInputBar({
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSlashLoading(true);
|
||||
// Only show the loading row when there is nothing cached to show.
|
||||
setSlashLoading(cachedSlashCommands === null);
|
||||
desktopClient
|
||||
.invoke<UserInstructionConfigResponse>("list_user_instruction_configs")
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setSlashCommands([
|
||||
const next = [
|
||||
...BUILTIN_SLASH_COMMANDS,
|
||||
...buildUserInstructionSlashCommands(response),
|
||||
]);
|
||||
];
|
||||
cachedSlashCommands = next;
|
||||
setSlashCommands(next);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep built-in commands on error.
|
||||
@@ -632,6 +641,19 @@ export function ChatInputBar({
|
||||
[activeSlash, promptInput, setPromptInput],
|
||||
);
|
||||
|
||||
// Queued prompts are stored in their runtime form (a /team command is
|
||||
// persisted as its <user_command> envelope), so fold them back to the
|
||||
// slash form for display and editing. Saving an edit re-resolves the
|
||||
// slash form through the sidecar, so the round trip is lossless.
|
||||
const displayPromptsInQueue = useMemo(
|
||||
() =>
|
||||
promptsInQueue.map((item) => ({
|
||||
...item,
|
||||
prompt: formatDisplayUserInput(item.prompt),
|
||||
})),
|
||||
[promptsInQueue],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -644,7 +666,7 @@ export function ChatInputBar({
|
||||
{/* Input area */}
|
||||
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
|
||||
<AgentPromptQueue
|
||||
items={promptsInQueue}
|
||||
items={displayPromptsInQueue}
|
||||
onEdit={onEditPromptInQueue}
|
||||
onRemove={onRemovePromptInQueue}
|
||||
onSteer={onSteerPromptInQueue}
|
||||
@@ -657,7 +679,7 @@ export function ChatInputBar({
|
||||
role="listbox"
|
||||
>
|
||||
{filteredSlashCommands.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{slashLoading
|
||||
? "Loading commands..."
|
||||
: "No matching commands"}
|
||||
@@ -668,10 +690,10 @@ export function ChatInputBar({
|
||||
<button
|
||||
aria-selected={index === slashSelectedIndex}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
"flex w-full flex-col rounded-md px-3 py-2 text-left text-sm ",
|
||||
index === slashSelectedIndex
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
? "bg-surface-hover text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
key={cmd.name}
|
||||
id={`slash-command-option-${index}`}
|
||||
@@ -703,7 +725,7 @@ export function ChatInputBar({
|
||||
role="listbox"
|
||||
>
|
||||
{mentionFiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{mentionLoading ? "Searching files..." : "No matching files"}
|
||||
</div>
|
||||
) : (
|
||||
@@ -712,10 +734,10 @@ export function ChatInputBar({
|
||||
<button
|
||||
aria-selected={index === mentionSelectedIndex}
|
||||
className={cn(
|
||||
"block w-full rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
"block w-full rounded-md px-3 py-2 text-left text-sm ",
|
||||
index === mentionSelectedIndex
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
? "bg-surface-hover text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
key={filePath}
|
||||
id={`mention-file-option-${index}`}
|
||||
@@ -737,7 +759,7 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-[border-color,box-shadow] focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome" &&
|
||||
"min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
|
||||
)}
|
||||
@@ -776,6 +798,15 @@ export function ChatInputBar({
|
||||
}
|
||||
onBlur={() => setPromptInputFocused(false)}
|
||||
onFocus={() => setPromptInputFocused(true)}
|
||||
onPaste={(e) => {
|
||||
const images = imageFilesFromClipboard(e.clipboardData);
|
||||
if (images.length > 0) {
|
||||
// Attach the image instead of pasting its fallback
|
||||
// text representation (e.g. a file path or URL).
|
||||
e.preventDefault();
|
||||
onAttachFiles(images);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Slash command menu takes priority when open.
|
||||
if (slashOpen && filteredSlashCommands.length > 0) {
|
||||
@@ -836,6 +867,11 @@ export function ChatInputBar({
|
||||
setDismissedMentionKey(mentionKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && canAbort) {
|
||||
e.preventDefault();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) {
|
||||
@@ -869,29 +905,31 @@ export function ChatInputBar({
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
className={cn(
|
||||
"bg-foreground p-0 text-background transition-colors hover:bg-primary/80",
|
||||
"bg-foreground p-1.5 text-background hover:bg-destructive",
|
||||
variant === "welcome" ? "rounded-md" : "rounded-full",
|
||||
)}
|
||||
onClick={onAbort}
|
||||
title="Stop the agent (Esc)"
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="size-2" />
|
||||
<CircleStop className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"p-1.5 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "welcome"
|
||||
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
|
||||
: "rounded-full bg-primary text-background hover:bg-primary/80",
|
||||
)}
|
||||
disabled={!canSend}
|
||||
onClick={handleSend}
|
||||
title="Send (Enter)"
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="size-2" />
|
||||
<ArrowUp className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -901,13 +939,13 @@ export function ChatInputBar({
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-1 text-xs text-foreground"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-1 text-sm text-foreground"
|
||||
key={attachment.id}
|
||||
>
|
||||
{attachment.isImage ? "image:" : "file:"} {attachment.name}
|
||||
<button
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
onClick={() => onRemoveAttachment(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
@@ -920,11 +958,11 @@ export function ChatInputBar({
|
||||
</div>
|
||||
|
||||
{/* Composer settings */}
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-2 py-2 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
|
||||
<button
|
||||
aria-label="Attach files"
|
||||
className="rounded-md p-0 pl-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-surface-hover"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
@@ -946,7 +984,7 @@ export function ChatInputBar({
|
||||
<button
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
"rounded px-2 py-1 ",
|
||||
mode === "plan"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
@@ -961,7 +999,7 @@ export function ChatInputBar({
|
||||
<button
|
||||
aria-pressed={mode === "act"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
"rounded px-2 py-1 ",
|
||||
mode === "act"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
@@ -993,7 +1031,7 @@ export function ChatInputBar({
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
|
||||
className="gap-1.5 border-0 px-2 text-sm shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 bg-transparent! hover:bg-surface-hover!"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
@@ -1049,6 +1087,11 @@ export function ChatInputBar({
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: the chat pane re-renders on every stream flush (message deltas,
|
||||
// status, usage); the composer only cares about the props it receives, which
|
||||
// the pane keeps referentially stable.
|
||||
export const ChatInputBar = memo(ChatInputBarImpl);
|
||||
|
||||
// Memoized: the selectors load/hold the full provider-model catalog, so they
|
||||
// should not re-render for every keystroke in the composer textarea.
|
||||
const ModelSelector = memo(function ModelSelector({
|
||||
@@ -1215,26 +1258,38 @@ const ModelSelector = memo(function ModelSelector({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLastSelection((prev) => {
|
||||
if (!normalizedProvider || !model) {
|
||||
return prev;
|
||||
// The remembered selection (what new sessions default to) is only written
|
||||
// from the explicit picker handlers below. Mirroring every provider/model
|
||||
// prop change here would also capture passive changes — most notably
|
||||
// opening an existing session, whose config drives these props — silently
|
||||
// replacing the user's chosen default with whatever model that session
|
||||
// happened to use.
|
||||
const rememberSelection = useCallback(
|
||||
(providerId: string, modelId: string | undefined) => {
|
||||
const normalizedId = normalizeProviderId(providerId);
|
||||
if (!normalizedId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
prev.lastProvider === normalizedProvider &&
|
||||
prev.lastModelByProvider[normalizedProvider] === model
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
lastProvider: normalizedProvider,
|
||||
lastModelByProvider: {
|
||||
...prev.lastModelByProvider,
|
||||
[normalizedProvider]: model,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [model, normalizedProvider]);
|
||||
setLastSelection((prev) => {
|
||||
if (
|
||||
prev.lastProvider === normalizedId &&
|
||||
(!modelId || prev.lastModelByProvider[normalizedId] === modelId)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
lastProvider: normalizedId,
|
||||
lastModelByProvider: modelId
|
||||
? {
|
||||
...prev.lastModelByProvider,
|
||||
[normalizedId]: modelId,
|
||||
}
|
||||
: prev.lastModelByProvider,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -1295,17 +1350,13 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
if (
|
||||
rememberedModel &&
|
||||
providerModelIds.includes(rememberedModel) &&
|
||||
rememberedModel !== model
|
||||
) {
|
||||
onModelChange(rememberedModel);
|
||||
return;
|
||||
}
|
||||
const firstModel = providerModelIds[0];
|
||||
if (firstModel && firstModel !== model) {
|
||||
onModelChange(firstModel);
|
||||
const nextModel =
|
||||
rememberedModel && providerModelIds.includes(rememberedModel)
|
||||
? rememberedModel
|
||||
: providerModelIds[0];
|
||||
rememberSelection(value, nextModel);
|
||||
if (nextModel && nextModel !== model) {
|
||||
onModelChange(nextModel);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1313,9 +1364,17 @@ const ModelSelector = memo(function ModelSelector({
|
||||
model,
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
rememberSelection,
|
||||
visibleProviderModels,
|
||||
],
|
||||
);
|
||||
const handleModelSelect = useCallback(
|
||||
(value: string) => {
|
||||
rememberSelection(resolvedProvider, value);
|
||||
onModelChange(value);
|
||||
},
|
||||
[onModelChange, rememberSelection, resolvedProvider],
|
||||
);
|
||||
const renderProviderSelect = (triggerClassName: string) => (
|
||||
<SearchCombobox
|
||||
ariaLabel="Provider"
|
||||
@@ -1340,7 +1399,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyText="No models found."
|
||||
onValueChange={(value) => {
|
||||
onModelChange(value);
|
||||
handleModelSelect(value);
|
||||
if (closeMobileMenu) setMobileOpen(false);
|
||||
}}
|
||||
options={modelsForProvider.map((value) => ({ label: value, value }))}
|
||||
@@ -1352,12 +1411,12 @@ const ModelSelector = memo(function ModelSelector({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 shrink-0 text-[11px]">
|
||||
<div className="relative min-w-0 shrink-0 text-sm">
|
||||
<button
|
||||
aria-expanded={mobileOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Model and provider"
|
||||
className="hidden size-7 items-center justify-center rounded-md text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 max-[560px]:inline-flex"
|
||||
className="hidden size-7 items-center justify-center rounded-md text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 max-[560px]:inline-flex"
|
||||
disabled={isBusy || providers.length === 0}
|
||||
onClick={() => setMobileOpen((current) => !current)}
|
||||
title={`${resolvedProvider || "Provider"} / ${resolvedModel || "Model"}`}
|
||||
@@ -1376,19 +1435,19 @@ const ModelSelector = memo(function ModelSelector({
|
||||
/>
|
||||
<div className="absolute bottom-full left-0 z-50 mb-2 hidden w-64 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-border bg-popover p-3 shadow-xl max-[560px]:block">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Provider
|
||||
</div>
|
||||
{renderProviderSelect(
|
||||
"w-full max-w-none justify-between text-xs",
|
||||
"w-full max-w-none justify-between text-sm",
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Model
|
||||
</div>
|
||||
{renderModelSelect(
|
||||
"w-full max-w-none justify-between text-xs",
|
||||
"w-full max-w-none justify-between text-sm",
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
@@ -1397,9 +1456,9 @@ const ModelSelector = memo(function ModelSelector({
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
|
||||
{renderProviderSelect("max-w-28 text-[11px]")}
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
{renderModelSelect("max-w-52 text-[11px]")}
|
||||
{renderProviderSelect("max-w-28")}
|
||||
<div className="bg-border-2 h-4 w-[0.1rem]"/>
|
||||
{renderModelSelect("max-w-52")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1448,7 +1507,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Context window: ${totalTokens.toLocaleString()} of ${contextWindow.toLocaleString()} tokens used (${percent}%)`}
|
||||
className="size-7 shrink-0 p-0 text-muted-foreground data-[state=open]:bg-accent opacity-65 hover:opacity-100"
|
||||
className="size-7 shrink-0 p-0 text-muted-foreground data-[state=open]:bg-surface-hover opacity-65 hover:opacity-100"
|
||||
id="token-usage"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
@@ -1496,7 +1555,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
<div className="px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="text-muted-foreground">Context window</span>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{contextUsageLabel}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1528,7 +1587,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Input tokens</span>
|
||||
<span className="font-mono text-foreground">
|
||||
|
||||
@@ -219,6 +219,131 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(container.textContent?.match(/Read 2 files/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("summarizes spawned teammates and expands their agent IDs", async () => {
|
||||
await renderMessages(
|
||||
["reviewer", "tester", "writer"].map(
|
||||
(agentId, index): ChatMessage => ({
|
||||
id: `spawn-${agentId}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "team_spawn_teammate",
|
||||
input: { agentId, rolePrompt: "Help the team" },
|
||||
result: { agentId, status: "spawned" },
|
||||
}),
|
||||
createdAt: index + 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Spawned 3 teammates"),
|
||||
);
|
||||
expect(trigger).toBeDefined();
|
||||
await act(async () => trigger?.click());
|
||||
expect(container.textContent).toContain("reviewer");
|
||||
expect(container.textContent).toContain("tester");
|
||||
expect(container.textContent).toContain("writer");
|
||||
});
|
||||
|
||||
it("summarizes assigned team tasks with mode, agent, and status", async () => {
|
||||
await renderMessages(
|
||||
["reviewer", "tester"].map(
|
||||
(agentId, index): ChatMessage => ({
|
||||
id: `run-${agentId}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "team_run_task",
|
||||
input: { agentId, runMode: "async", task: "Investigate" },
|
||||
result: { agentId, mode: "async", status: "queued" },
|
||||
}),
|
||||
createdAt: index + 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Assigned 2 team tasks"),
|
||||
);
|
||||
expect(trigger).toBeDefined();
|
||||
await act(async () => trigger?.click());
|
||||
expect(container.textContent).toContain("async reviewer queued");
|
||||
expect(container.textContent).toContain("async tester queued");
|
||||
});
|
||||
|
||||
it("summarizes awaited teammate reports with their statuses", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "await-runs",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "team_await_runs",
|
||||
input: {},
|
||||
result: [
|
||||
{ id: "run-1", agentId: "reviewer", status: "completed" },
|
||||
{ id: "run-2", agentId: "tester", status: "failed" },
|
||||
],
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Waited for teammates"),
|
||||
);
|
||||
expect(trigger).toBeDefined();
|
||||
await act(async () => trigger?.click());
|
||||
expect(container.textContent).toContain("reviewer completed");
|
||||
expect(container.textContent).toContain("tester failed");
|
||||
});
|
||||
|
||||
it("counts every returned task in team task list summaries", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "list-team-tasks",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "team_task",
|
||||
input: { action: "list" },
|
||||
result: {
|
||||
action: "list",
|
||||
tasks: [
|
||||
{ id: "task-1", title: "Review", status: "pending" },
|
||||
{ id: "task-2", title: "Test", status: "in_progress" },
|
||||
{ id: "task-3", title: "Document", status: "completed" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Listed 3 team tasks");
|
||||
});
|
||||
|
||||
it("uses failure-oriented labels for failed team tools", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "failed-spawn",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "team_spawn_teammate",
|
||||
input: { agentId: "reviewer", rolePrompt: "Review" },
|
||||
result: { error: "already exists" },
|
||||
isError: true,
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Failed to spawn teammate");
|
||||
expect(container.textContent).not.toContain("Spawned 1 teammate");
|
||||
});
|
||||
|
||||
it("preserves interleaved tool activity order", async () => {
|
||||
const read = (
|
||||
id: string,
|
||||
@@ -894,39 +1019,41 @@ describe("ChatMessages reasoning disclosure", () => {
|
||||
const railClasses = (element: Element | null) =>
|
||||
[...(element?.classList ?? [])]
|
||||
.filter((name) =>
|
||||
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max)-/.test(name),
|
||||
/^(-?m[a-z]?|p[a-z]?|border|rounded|bg|max-w)-/.test(name),
|
||||
)
|
||||
.sort();
|
||||
expect(railClasses(reasoningContent).length).toBeGreaterThan(0);
|
||||
expect(railClasses(toolContent)).toEqual(railClasses(reasoningContent));
|
||||
|
||||
// Both panels are capped on both axes so neither can stretch the column.
|
||||
// Reasoning remains capped; tool output grows into the conversation scroller.
|
||||
expect(
|
||||
[...(reasoningContent?.classList ?? [])].some((name) =>
|
||||
name.startsWith("max-h-"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
[...(toolContent?.classList ?? [])].some((name) =>
|
||||
name.startsWith("max-h-"),
|
||||
),
|
||||
).toBe(false);
|
||||
for (const panel of [reasoningContent, toolContent]) {
|
||||
const classes = [...(panel?.classList ?? [])];
|
||||
expect(classes.some((name) => name.startsWith("max-h-"))).toBe(true);
|
||||
expect(classes.some((name) => name.startsWith("max-w-"))).toBe(true);
|
||||
expect(
|
||||
[...(panel?.classList ?? [])].some((name) => name.startsWith("max-w-")),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Reasoning wraps, so it scrolls Y only; tool output scrolls both axes.
|
||||
// Reasoning scrolls internally; tool output leaves scrolling to the conversation.
|
||||
expect(reasoningContent?.classList.contains("overflow-y-auto")).toBe(true);
|
||||
expect(reasoningContent?.classList.contains("overflow-x-hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reasoningContent?.classList.contains("overflow-auto")).toBe(false);
|
||||
expect(toolContent?.classList.contains("overflow-auto")).toBe(true);
|
||||
expect(toolContent?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
expect(toolContent?.classList.contains("overflow-auto")).toBe(false);
|
||||
|
||||
// The X axis is only reachable if the detail rows keep their lines intact.
|
||||
// Detail rows use the shared wrapping behavior instead of horizontal scrolling.
|
||||
const details = toolContent?.querySelector(".cline-chat-tool-details");
|
||||
expect(details?.classList.contains("whitespace-pre")).toBe(true);
|
||||
|
||||
// The X axis stays live but loses its bar; reasoning has no X bar to hide.
|
||||
expect(toolContent?.classList.contains("cline-chat-scroll-x-bare")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
reasoningContent?.classList.contains("cline-chat-scroll-x-bare"),
|
||||
).toBe(false);
|
||||
expect(details?.classList.contains("whitespace-pre")).toBe(false);
|
||||
expect(details?.classList.contains("whitespace-pre-wrap")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the reasoning panel inside the shape the hover-suppress rule targets", async () => {
|
||||
|
||||
@@ -275,8 +275,6 @@ function groupChatMessages(messages: ChatMessage[]): ChatRenderItem[] {
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STREAMING_TITLE_CLASS = "cline-chat-streaming-title";
|
||||
/** Keeps a scroller's X axis live while hiding its horizontal bar (globals.css). */
|
||||
const SCROLL_X_BARE_CLASS = "cline-chat-scroll-x-bare";
|
||||
|
||||
/**
|
||||
* Expanded reasoning and tool panels hang off a shared left rail: the border
|
||||
@@ -285,12 +283,11 @@ const SCROLL_X_BARE_CLASS = "cline-chat-scroll-x-bare";
|
||||
* use this verbatim, and it overrides the panel chrome (border box, radius,
|
||||
* background, inset) that `agent-chat.css` gives each of them by default.
|
||||
*
|
||||
* Both panels are capped on both axes so a long thought or a wide command list
|
||||
* can never stretch the conversation column; each panel then picks which axes
|
||||
* scroll (reasoning wraps and scrolls Y only, tools scroll both).
|
||||
* Reasoning stays capped and scrollable, while tool output grows into the
|
||||
* conversation scroller and wraps to avoid a nested scrolling region.
|
||||
*/
|
||||
const EXPANDED_PANEL_RAIL_CLASS =
|
||||
"ml-1 mt-0 max-h-48 max-w-full rounded-none border-0 border-l border-border bg-transparent py-1 px-2 text-sm opacity-70 hover:opacity-100 focus-within:opacity-100";
|
||||
"ml-1 mt-0 max-w-full rounded-none border-0 border-l border-border bg-transparent py-1 px-2 text-sm opacity-70 hover:opacity-100 focus-within:opacity-100";
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId,
|
||||
@@ -310,17 +307,34 @@ function ChatMessagesImpl({
|
||||
onForkSession,
|
||||
}: ChatMessagesProps) {
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "error");
|
||||
// Scanned from the tail without copying: this component re-renders on
|
||||
// every stream flush, so a reversed array clone per render would churn
|
||||
// with transcript length.
|
||||
const { lastConversationMessage, lastErrorMessage } = useMemo(() => {
|
||||
let conversationMessage: ChatMessage | undefined;
|
||||
let errorMessage: ChatMessage | undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (!conversationMessage && message.role !== "status") {
|
||||
conversationMessage = message;
|
||||
}
|
||||
if (!errorMessage && message.role === "error") {
|
||||
errorMessage = message;
|
||||
}
|
||||
if (conversationMessage && errorMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
lastConversationMessage: conversationMessage,
|
||||
lastErrorMessage: errorMessage,
|
||||
};
|
||||
}, [messages]);
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
// Core reports "running" as soon as the turn is dispatched, well before the
|
||||
// first streamed chunk arrives, so keep the thinking indicator up until the
|
||||
// model produces output (or something else needs the user's attention).
|
||||
const lastConversationMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== "status");
|
||||
const isAwaitingFirstOutput =
|
||||
status === "running" &&
|
||||
!streamingMessageId &&
|
||||
@@ -373,6 +387,31 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
|
||||
// Built once per pendingAskQuestions change instead of per render: the
|
||||
// list re-renders on every stream flush and these rows carry JSX.
|
||||
const askQuestionItems = useMemo(
|
||||
() =>
|
||||
pendingAskQuestions.map((item) => ({
|
||||
description: (
|
||||
<>
|
||||
Request {item.requestId}
|
||||
{item.context?.iteration != null
|
||||
? ` · Iteration ${item.context.iteration}`
|
||||
: ""}
|
||||
</>
|
||||
),
|
||||
id: item.requestId,
|
||||
meta: (
|
||||
<>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{formatApprovalTimestamp(item.createdAt)}
|
||||
</>
|
||||
),
|
||||
options: item.options,
|
||||
question: item.question,
|
||||
})),
|
||||
[pendingAskQuestions],
|
||||
);
|
||||
const previousTimestampByMessage = useMemo(
|
||||
() => buildPreviousTimestampMap(messages),
|
||||
[messages],
|
||||
@@ -583,6 +622,14 @@ function ChatMessagesImpl({
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Stable identity so memoized MessageBubbles skip re-rendering on stream
|
||||
// flushes; an inline lambda here would invalidate every bubble per flush.
|
||||
const requestRestoreCheckpoint = useCallback(
|
||||
(messageId: string, runCount: number) => {
|
||||
setCheckpointConfirmation({ messageId, runCount });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExpandImage = useCallback(
|
||||
(image: ChatMessageImage) => {
|
||||
@@ -658,28 +705,10 @@ function ChatMessagesImpl({
|
||||
requestErrors={toolApprovalErrors}
|
||||
/>
|
||||
) : null}
|
||||
{pendingAskQuestions.length > 0 ? (
|
||||
{askQuestionItems.length > 0 ? (
|
||||
<AgentAskQuestion
|
||||
errors={askQuestionErrors}
|
||||
items={pendingAskQuestions.map((item) => ({
|
||||
description: (
|
||||
<>
|
||||
Request {item.requestId}
|
||||
{item.context?.iteration != null
|
||||
? ` · Iteration ${item.context.iteration}`
|
||||
: ""}
|
||||
</>
|
||||
),
|
||||
id: item.requestId,
|
||||
meta: (
|
||||
<>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{formatApprovalTimestamp(item.createdAt)}
|
||||
</>
|
||||
),
|
||||
options: item.options,
|
||||
question: item.question,
|
||||
}))}
|
||||
items={askQuestionItems}
|
||||
onAnswer={handleAskQuestionAnswer}
|
||||
pendingAnswers={askQuestionActions}
|
||||
/>
|
||||
@@ -727,13 +756,7 @@ function ChatMessagesImpl({
|
||||
editError={editErrors[message.id]}
|
||||
editPending={editingMessageId === message.id}
|
||||
onRestoreCheckpoint={
|
||||
onRestoreCheckpoint
|
||||
? (messageId, runCount) =>
|
||||
setCheckpointConfirmation({
|
||||
messageId,
|
||||
runCount,
|
||||
})
|
||||
: undefined
|
||||
onRestoreCheckpoint ? requestRestoreCheckpoint : undefined
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
@@ -818,7 +841,7 @@ function ChatMessagesImpl({
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
<div className="mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1326,7 +1349,7 @@ function ReasoningBlock({
|
||||
// Prose reflows, so the X axis is pinned shut: `overflow-y-auto`
|
||||
// alone would compute overflow-x to `auto` and let a long
|
||||
// unbreakable token add a horizontal scrollbar.
|
||||
"overflow-x-hidden overflow-y-auto",
|
||||
"max-h-48 overflow-x-hidden overflow-y-auto",
|
||||
"text-sm leading-relaxed text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -1354,6 +1377,7 @@ type ToolSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
noun: string;
|
||||
pluralNoun?: string;
|
||||
completedVerb: string;
|
||||
progressVerb: string;
|
||||
};
|
||||
@@ -1605,14 +1629,390 @@ function pluralize(
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function resultRecords(result: unknown): Record<string, unknown>[] {
|
||||
const normalized = normalizeDisplayValue(result);
|
||||
if (Array.isArray(normalized)) {
|
||||
return normalized.map(asRecord).filter((item) => item !== null);
|
||||
}
|
||||
const record = asRecord(normalized);
|
||||
return record ? [record] : [];
|
||||
}
|
||||
|
||||
function recordString(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
fallback = "",
|
||||
): string {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function teamSummary(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
result: unknown,
|
||||
inProgress: boolean,
|
||||
isError: boolean,
|
||||
): ToolSummary | null {
|
||||
if (!toolName.startsWith("team_")) return null;
|
||||
if (isError) {
|
||||
const failureLabels: Record<string, string> = {
|
||||
team_attach_outcome_fragment: "Failed to attach outcome fragment",
|
||||
team_await_runs: "Failed while waiting for teammates",
|
||||
team_broadcast: "Failed to broadcast message to teammates",
|
||||
team_cancel_run: "Failed to cancel teammate run",
|
||||
team_cleanup: "Failed to clean up team",
|
||||
team_create_outcome: "Failed to create team outcome",
|
||||
team_finalize_outcome: "Failed to finalize team outcome",
|
||||
team_list_outcomes: "Failed to list team outcomes",
|
||||
team_list_runs: "Failed to list teammate runs",
|
||||
team_mission_log: "Failed to update mission log",
|
||||
team_read_mailbox: "Failed to read team mailbox",
|
||||
team_review_outcome_fragment: "Failed to review outcome fragment",
|
||||
team_run_task: "Failed to assign team task",
|
||||
team_send_message: "Failed to send message",
|
||||
team_shutdown_teammate: "Failed to stop teammate",
|
||||
team_spawn_teammate: "Failed to spawn teammate",
|
||||
team_status: "Failed to check team status",
|
||||
team_task: "Failed to update team task",
|
||||
};
|
||||
return {
|
||||
label: failureLabels[toolName] ?? `Failed ${toolName}`,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
const inputRecord = asRecord(input);
|
||||
const records = resultRecords(result);
|
||||
const resultRecord = records[0];
|
||||
const aggregate = (
|
||||
key: string,
|
||||
noun: string,
|
||||
completedVerb: string,
|
||||
progressVerb: string,
|
||||
details: string[],
|
||||
pluralNoun?: string,
|
||||
count = 1,
|
||||
): ToolSummary => ({
|
||||
label: `${inProgress ? progressVerb : completedVerb} ${pluralize(
|
||||
count,
|
||||
noun,
|
||||
pluralNoun,
|
||||
)}`,
|
||||
aggregate: {
|
||||
key,
|
||||
count,
|
||||
noun,
|
||||
pluralNoun,
|
||||
completedVerb,
|
||||
progressVerb,
|
||||
},
|
||||
details,
|
||||
});
|
||||
const agentId = recordString(
|
||||
resultRecord,
|
||||
"agentId",
|
||||
recordString(inputRecord, "agentId"),
|
||||
);
|
||||
|
||||
switch (toolName) {
|
||||
case "team_spawn_teammate":
|
||||
return aggregate(
|
||||
"team-spawn",
|
||||
"teammate",
|
||||
"Spawned",
|
||||
"Spawning",
|
||||
agentId ? [agentId] : [],
|
||||
);
|
||||
case "team_run_task": {
|
||||
const mode = recordString(
|
||||
resultRecord,
|
||||
"mode",
|
||||
recordString(inputRecord, "runMode", "sync"),
|
||||
);
|
||||
const status = inProgress
|
||||
? "assigning"
|
||||
: recordString(resultRecord, "status", "assigned");
|
||||
return aggregate(
|
||||
"team-run-task",
|
||||
"team task",
|
||||
"Assigned",
|
||||
"Assigning",
|
||||
[mode, agentId, status].filter(Boolean).join(" ")
|
||||
? [[mode, agentId, status].filter(Boolean).join(" ")]
|
||||
: [],
|
||||
"team tasks",
|
||||
);
|
||||
}
|
||||
case "team_await_runs": {
|
||||
const details = records.map((run) =>
|
||||
[
|
||||
recordString(run, "agentId", recordString(run, "id")),
|
||||
recordString(run, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
return {
|
||||
label: inProgress ? "Waiting for teammates" : "Waited for teammates",
|
||||
details,
|
||||
};
|
||||
}
|
||||
case "team_shutdown_teammate":
|
||||
return aggregate(
|
||||
"team-shutdown",
|
||||
"teammate",
|
||||
"Stopped",
|
||||
"Stopping",
|
||||
agentId ? [agentId] : [],
|
||||
);
|
||||
case "team_status": {
|
||||
const members = Array.isArray(resultRecord?.members)
|
||||
? resultRecord.members.map(asRecord).filter((item) => item !== null)
|
||||
: [];
|
||||
return {
|
||||
label: inProgress ? "Checking team status" : "Checked team status",
|
||||
details: members.map((member) =>
|
||||
[recordString(member, "agentId"), recordString(member, "status")]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
),
|
||||
};
|
||||
}
|
||||
case "team_task": {
|
||||
const action = recordString(
|
||||
inputRecord,
|
||||
"action",
|
||||
recordString(resultRecord, "action", "update"),
|
||||
);
|
||||
const verbs: Record<string, [string, string]> = {
|
||||
create: ["Created", "Creating"],
|
||||
list: ["Listed", "Listing"],
|
||||
claim: ["Claimed", "Claiming"],
|
||||
complete: ["Completed", "Completing"],
|
||||
block: ["Blocked", "Blocking"],
|
||||
};
|
||||
const [completedVerb, progressVerb] = verbs[action] ?? [
|
||||
"Updated",
|
||||
"Updating",
|
||||
];
|
||||
const tasks = Array.isArray(resultRecord?.tasks)
|
||||
? resultRecord.tasks.map(asRecord).filter((item) => item !== null)
|
||||
: records;
|
||||
const details = tasks.map((task) =>
|
||||
[
|
||||
recordString(
|
||||
task,
|
||||
"taskId",
|
||||
recordString(task, "id", recordString(inputRecord, "taskId")),
|
||||
),
|
||||
recordString(task, "title", recordString(inputRecord, "title")),
|
||||
recordString(task, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
return aggregate(
|
||||
`team-task-${action}`,
|
||||
"team task",
|
||||
completedVerb,
|
||||
progressVerb,
|
||||
details,
|
||||
undefined,
|
||||
action === "list" ? tasks.length : 1,
|
||||
);
|
||||
}
|
||||
case "team_list_runs":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Listing teammate runs"
|
||||
: `Listed ${pluralize(records.length, "teammate run")}`,
|
||||
details: records.map((run) =>
|
||||
[recordString(run, "agentId"), recordString(run, "status")]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
),
|
||||
};
|
||||
case "team_cancel_run":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Cancelling teammate run"
|
||||
: "Cancelled teammate run",
|
||||
details: [
|
||||
[
|
||||
recordString(
|
||||
resultRecord,
|
||||
"runId",
|
||||
recordString(inputRecord, "runId"),
|
||||
),
|
||||
recordString(resultRecord, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_send_message": {
|
||||
const recipient = recordString(
|
||||
resultRecord,
|
||||
"toAgentId",
|
||||
recordString(inputRecord, "toAgentId"),
|
||||
);
|
||||
return aggregate(
|
||||
"team-send-message",
|
||||
"message",
|
||||
"Sent",
|
||||
"Sending",
|
||||
[recipient, recordString(inputRecord, "subject")].filter(Boolean).length
|
||||
? [
|
||||
[recipient, recordString(inputRecord, "subject")]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
case "team_broadcast": {
|
||||
const delivered = resultRecord?.delivered;
|
||||
return {
|
||||
label: inProgress
|
||||
? "Broadcasting message to teammates"
|
||||
: `Broadcast message to ${pluralize(typeof delivered === "number" ? delivered : 0, "teammate")}`,
|
||||
details: recordString(inputRecord, "subject")
|
||||
? [recordString(inputRecord, "subject")]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case "team_read_mailbox":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Reading team mailbox"
|
||||
: `Read ${pluralize(records.length, "team message")}`,
|
||||
details: records.map((message) =>
|
||||
[
|
||||
recordString(message, "fromAgentId"),
|
||||
recordString(message, "subject"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
),
|
||||
};
|
||||
case "team_mission_log":
|
||||
return {
|
||||
label: inProgress ? "Updating mission log" : "Updated mission log",
|
||||
details: [
|
||||
[
|
||||
recordString(inputRecord, "kind"),
|
||||
recordString(inputRecord, "summary"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_cleanup":
|
||||
return {
|
||||
label: inProgress ? "Cleaning up team" : "Cleaned up team",
|
||||
details: recordString(resultRecord, "status")
|
||||
? [recordString(resultRecord, "status")]
|
||||
: [],
|
||||
};
|
||||
case "team_create_outcome":
|
||||
return {
|
||||
label: inProgress ? "Creating team outcome" : "Created team outcome",
|
||||
details: [
|
||||
[
|
||||
recordString(resultRecord, "outcomeId"),
|
||||
recordString(inputRecord, "title"),
|
||||
recordString(resultRecord, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_attach_outcome_fragment":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Attaching outcome fragment"
|
||||
: "Attached outcome fragment",
|
||||
details: [
|
||||
[
|
||||
recordString(inputRecord, "section"),
|
||||
recordString(resultRecord, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_review_outcome_fragment":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Reviewing outcome fragment"
|
||||
: "Reviewed outcome fragment",
|
||||
details: [
|
||||
[
|
||||
recordString(inputRecord, "fragmentId"),
|
||||
typeof inputRecord?.approved === "boolean"
|
||||
? inputRecord.approved
|
||||
? "approved"
|
||||
: "rejected"
|
||||
: recordString(resultRecord, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_finalize_outcome":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Finalizing team outcome"
|
||||
: "Finalized team outcome",
|
||||
details: [
|
||||
[
|
||||
recordString(
|
||||
resultRecord,
|
||||
"outcomeId",
|
||||
recordString(inputRecord, "outcomeId"),
|
||||
),
|
||||
recordString(resultRecord, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean),
|
||||
};
|
||||
case "team_list_outcomes":
|
||||
return {
|
||||
label: inProgress
|
||||
? "Listing team outcomes"
|
||||
: `Listed ${pluralize(records.length, "team outcome")}`,
|
||||
details: records.map((outcome) =>
|
||||
[
|
||||
recordString(outcome, "title", recordString(outcome, "id")),
|
||||
recordString(outcome, "status"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolSummary(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
result: unknown,
|
||||
inProgress: boolean,
|
||||
isError = false,
|
||||
): ToolSummary {
|
||||
const normalized = normalizeToolName(toolName);
|
||||
const inputObject = asRecord(input);
|
||||
const teamToolSummary = teamSummary(
|
||||
normalized,
|
||||
input,
|
||||
result,
|
||||
inProgress,
|
||||
isError,
|
||||
);
|
||||
if (teamToolSummary) return teamToolSummary;
|
||||
|
||||
if (normalized === "read_files") {
|
||||
const files = extractReadFilePaths(input);
|
||||
@@ -1835,7 +2235,13 @@ function buildToolPresentation(message: ChatMessage): ToolPresentation {
|
||||
(Boolean(payload) && payload?.result == null && !payload?.isError);
|
||||
const kind = classifyTool(toolName);
|
||||
const summary = payload
|
||||
? buildToolSummary(toolName, payload.input, payload.result, inProgress)
|
||||
? buildToolSummary(
|
||||
toolName,
|
||||
payload.input,
|
||||
payload.result,
|
||||
inProgress,
|
||||
Boolean(payload.isError),
|
||||
)
|
||||
: buildToolSummaryFromMeta(toolName, kind, inProgress);
|
||||
return { message, payload, toolName, kind, inProgress, summary };
|
||||
}
|
||||
@@ -1890,7 +2296,11 @@ function buildGroupedToolLabel(presentations: ToolPresentation[]): string {
|
||||
const verb = aggregate.inProgress
|
||||
? aggregate.progressVerb
|
||||
: aggregate.completedVerb;
|
||||
return `${verb} ${pluralize(aggregate.count, aggregate.noun)}`;
|
||||
return `${verb} ${pluralize(
|
||||
aggregate.count,
|
||||
aggregate.noun,
|
||||
aggregate.pluralNoun,
|
||||
)}`;
|
||||
})
|
||||
.join(". ");
|
||||
}
|
||||
@@ -1966,21 +2376,9 @@ const ToolMessageBlock = memo(
|
||||
showDisclosureIcon={false}
|
||||
status={hasError ? "error" : isRunning ? "running" : "success"}
|
||||
/>
|
||||
{/* `overflow-auto` (not just `-y`) overrides the `overflow-x: hidden`
|
||||
that agent-chat.css pins on this panel; the bare-scrollbar class
|
||||
then hides the horizontal bar without disabling the axis. */}
|
||||
<ToolActivityContent
|
||||
className={cn(
|
||||
EXPANDED_PANEL_RAIL_CLASS,
|
||||
"overflow-auto",
|
||||
SCROLL_X_BARE_CLASS,
|
||||
)}
|
||||
>
|
||||
<ToolActivityContent className={EXPANDED_PANEL_RAIL_CLASS}>
|
||||
{details.length > 0 ? (
|
||||
// Commands and paths stay on one line and scroll with the panel;
|
||||
// the stylesheet's `overflow-wrap: anywhere` would otherwise break
|
||||
// them mid-token and leave the X axis unreachable.
|
||||
<ToolActivityDetails className="w-max whitespace-pre">
|
||||
<ToolActivityDetails className="whitespace-pre-wrap">
|
||||
{details.map(({ detail, key }) => (
|
||||
<div key={key}>{detail}</div>
|
||||
))}
|
||||
@@ -1993,16 +2391,14 @@ const ToolMessageBlock = memo(
|
||||
? `${preview.toolName} input`
|
||||
: "Input"}
|
||||
</div>
|
||||
{/* Drop the stylesheet's own 13rem scroller so the panel above
|
||||
is the single scroll container on both axes. */}
|
||||
<ToolActivityCode className="max-h-none overflow-visible text-sm">
|
||||
<ToolActivityCode className="text-sm">
|
||||
{preview.value}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
))}
|
||||
{resultPreviews.map((preview) => (
|
||||
<div
|
||||
className="mt-1 text-destructive"
|
||||
className="mt-1 break-words text-destructive"
|
||||
key={`result_${preview.key}`}
|
||||
>
|
||||
{presentations.length > 1 ? `${preview.toolName}: ` : null}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
|
||||
{" "}
|
||||
<button
|
||||
aria-label="Close diff view"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
@@ -198,7 +198,7 @@ function DiffFileSection({
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-accent/50 transition-colors">
|
||||
<div className="group flex w-full items-center gap-2 bg-card/80 px-4 py-2 hover:bg-surface-hover-lighter transition-colors">
|
||||
<button
|
||||
className="flex min-w-0 shrink items-center gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
@@ -216,7 +216,7 @@ function DiffFileSection({
|
||||
<button
|
||||
aria-label={`Copy file path for ${file.path}`}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
|
||||
"shrink-0 rounded-md p-1 text-muted-foreground transition-opacity hover:bg-surface-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
|
||||
copied ? "opacity-100 text-primary" : "opacity-0",
|
||||
)}
|
||||
onClick={() => void handleCopyPath()}
|
||||
@@ -242,7 +242,7 @@ function DiffFileSection({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Open ${file.path} in editor`}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-surface-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 data-[state=open]:opacity-100 data-[state=open]:bg-surface-hover data-[state=open]:text-foreground"
|
||||
disabled={opening}
|
||||
title="Open in editor"
|
||||
type="button"
|
||||
@@ -323,7 +323,7 @@ function DiffHunk({ hunk }: { hunk: SessionFileDiff["hunks"][number] }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
|
||||
<div className="cline-chat-selectable overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
|
||||
{oldLineEntries.map((entry) => (
|
||||
<div className="flex bg-destructive/10" key={entry.key}>
|
||||
<span className="hidden w-12 shrink-0 select-none items-center justify-end border-r border-border px-2 text-muted-foreground/40 sm:flex">
|
||||
|
||||
@@ -30,6 +30,7 @@ afterEach(async () => {
|
||||
async function renderWelcomeScreen({
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
gitBranch = "main",
|
||||
onStartChat = vi.fn(),
|
||||
selectChat = vi.fn(async () => true),
|
||||
onListGitBranches = vi.fn(async () => ({
|
||||
@@ -39,6 +40,7 @@ async function renderWelcomeScreen({
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
gitBranch?: string | null;
|
||||
onStartChat?: (prompt: string) => void;
|
||||
selectChat?: () => Promise<boolean>;
|
||||
onListGitBranches?: () => Promise<{
|
||||
@@ -63,7 +65,7 @@ async function renderWelcomeScreen({
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onStartChat={onStartChat}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
@@ -103,6 +105,100 @@ describe("WelcomeScreen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows code-centric suggestions only inside a git repository", async () => {
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: "main",
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Review changes");
|
||||
expect(container.textContent).toContain("Check for build errors");
|
||||
expect(container.textContent).not.toContain("Summarize this folder");
|
||||
});
|
||||
|
||||
it("offers general-purpose suggestions for a plain (non-git) folder", async () => {
|
||||
const onStartChat = vi.fn();
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: "no-git",
|
||||
onStartChat,
|
||||
workspaceRoot: "/home/beatrix/recipes",
|
||||
workspaces: ["/home/beatrix/recipes"],
|
||||
});
|
||||
|
||||
// No developer vocabulary for a documents folder.
|
||||
expect(container.textContent).not.toContain("Review changes");
|
||||
expect(container.textContent).not.toContain("build errors");
|
||||
expect(container.textContent).toContain("Summarize this folder");
|
||||
expect(container.textContent).toContain("Organize these files");
|
||||
expect(container.textContent).toContain("Draft a document");
|
||||
|
||||
await clickButton("Summarize this folder");
|
||||
expect(onStartChat).toHaveBeenCalledWith(
|
||||
"Look through the files in this folder and give me a plain-language summary of what's here.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no suggestions for a folder while branch discovery is pending", async () => {
|
||||
// Initial load and workspace switches report null until the folder is
|
||||
// classified; guessing a card set here would misclassify git repos.
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: null,
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Review changes");
|
||||
expect(container.textContent).not.toContain("Check for build errors");
|
||||
expect(container.textContent).not.toContain("Summarize this folder");
|
||||
expect(container.textContent).not.toContain("Draft a document");
|
||||
});
|
||||
|
||||
it("resolves pending branch discovery to the matching card set", async () => {
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: null,
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: "main",
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Review changes");
|
||||
expect(container.textContent).toContain("Check for build errors");
|
||||
expect(container.textContent).not.toContain("Summarize this folder");
|
||||
});
|
||||
|
||||
it("offers folderless suggestions even while branch state is pending", async () => {
|
||||
// Switching to "Just chat" resets branch discovery to pending; the
|
||||
// chat cards never depend on git state, so they show immediately.
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: null,
|
||||
workspaceRoot: "",
|
||||
workspaces: [],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Draft a document");
|
||||
expect(container.textContent).toContain("Research a topic");
|
||||
expect(container.textContent).toContain("Plan something");
|
||||
});
|
||||
|
||||
it("offers folderless suggestions when no workspace is selected", async () => {
|
||||
await renderWelcomeScreen({
|
||||
gitBranch: "no-git",
|
||||
workspaceRoot: "",
|
||||
workspaces: [],
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Review changes");
|
||||
expect(container.textContent).not.toContain("Summarize this folder");
|
||||
expect(container.textContent).toContain("Draft a document");
|
||||
expect(container.textContent).toContain("Research a topic");
|
||||
expect(container.textContent).toContain("Plan something");
|
||||
});
|
||||
|
||||
it("renders every known project in the opened workspace menu", async () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import {
|
||||
AgentAurora,
|
||||
AgentHeroHeading,
|
||||
@@ -7,12 +8,13 @@ import {
|
||||
AgentQuickActions,
|
||||
} from "@cline/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
/** Code-centric starters, shown only when the folder is a git repository. */
|
||||
const DEVELOPER_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
{
|
||||
id: "review-changes",
|
||||
label: "Review changes",
|
||||
@@ -27,10 +29,94 @@ const DEFAULT_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* General-purpose starters for a plain (non-git) folder — phrased around the
|
||||
* files the agent can see, with no developer vocabulary.
|
||||
*/
|
||||
const FOLDER_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
{
|
||||
id: "summarize-folder",
|
||||
label: "Summarize this folder",
|
||||
description: "Get a plain-language overview of the files here.",
|
||||
value:
|
||||
"Look through the files in this folder and give me a plain-language summary of what's here.",
|
||||
},
|
||||
{
|
||||
id: "organize-files",
|
||||
label: "Organize these files",
|
||||
description: "Tidy up names and structure, with your approval.",
|
||||
value:
|
||||
"Help me organize this folder: suggest a tidy structure and clearer file names, and check with me before moving anything.",
|
||||
},
|
||||
{
|
||||
id: "draft-document",
|
||||
label: "Draft a document",
|
||||
description: "Start a new doc with a first draft you can edit.",
|
||||
value:
|
||||
"Help me draft a new document in this folder. Ask me a few questions about what it should cover, then write a first draft.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Starters for chat with no folder selected at all. */
|
||||
const CHAT_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
{
|
||||
id: "draft-document",
|
||||
label: "Draft a document",
|
||||
description: "Start a new doc with a first draft you can edit.",
|
||||
value:
|
||||
"Help me draft a document. Ask me a few questions about what it should cover, then write a first draft.",
|
||||
},
|
||||
{
|
||||
id: "research-topic",
|
||||
label: "Research a topic",
|
||||
description: "Gather the key facts and sum them up.",
|
||||
value:
|
||||
"Research a topic for me: ask me what I want to learn about, then summarize the key points in plain language.",
|
||||
},
|
||||
{
|
||||
id: "plan-something",
|
||||
label: "Plan something",
|
||||
description: "Break a goal into clear, doable steps.",
|
||||
value:
|
||||
"Help me plan something. Ask me what I'm trying to get done, then break it into clear steps.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Picks starter suggestions that match what the user actually opened: code
|
||||
* cards only make sense inside a git repo; a plain folder gets file-oriented
|
||||
* cards; no folder at all gets folderless general-purpose cards.
|
||||
*
|
||||
* `gitBranch` is `null` while branch discovery for the selected folder is
|
||||
* still pending; no cards are suggested until the folder is classified so a
|
||||
* git repo never flashes the plain-folder set (or vice versa).
|
||||
*/
|
||||
export function defaultQuickActionsForContext({
|
||||
workspaceRoot,
|
||||
gitBranch,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
gitBranch: string | null;
|
||||
}): AgentQuickAction[] {
|
||||
const isChatWorkspace =
|
||||
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
if (isChatWorkspace) {
|
||||
return CHAT_QUICK_ACTIONS;
|
||||
}
|
||||
if (gitBranch === null) {
|
||||
return [];
|
||||
}
|
||||
if (gitBranch !== "no-git") {
|
||||
return DEVELOPER_QUICK_ACTIONS;
|
||||
}
|
||||
return FOLDER_QUICK_ACTIONS;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({
|
||||
active,
|
||||
body,
|
||||
composer,
|
||||
notice,
|
||||
onStartChat,
|
||||
quickActions,
|
||||
gitBranch,
|
||||
@@ -40,9 +126,12 @@ export function WelcomeScreen({
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
/** Rendered above the composer on the welcome state (e.g. setup notice). */
|
||||
notice?: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: AgentQuickAction[];
|
||||
gitBranch: string;
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
gitBranch: string | null;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
@@ -54,8 +143,11 @@ export function WelcomeScreen({
|
||||
pickWorkspaceDirectory,
|
||||
selectChat,
|
||||
} = useWorkspace();
|
||||
const actions =
|
||||
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
|
||||
const defaultActions = useMemo(
|
||||
() => defaultQuickActionsForContext({ workspaceRoot, gitBranch }),
|
||||
[workspaceRoot, gitBranch],
|
||||
);
|
||||
const actions = quickActions.length > 0 ? quickActions : defaultActions;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void refreshWorkspaces();
|
||||
@@ -85,7 +177,7 @@ export function WelcomeScreen({
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="cline-view-enter">
|
||||
<AgentHeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
@@ -101,16 +193,22 @@ export function WelcomeScreen({
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
|
||||
className={
|
||||
active
|
||||
? "hidden"
|
||||
: "cline-view-enter h-full min-h-0 overflow-hidden"
|
||||
}
|
||||
key="conversation-body"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
|
||||
{active && notice ? notice : null}
|
||||
|
||||
<div
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
|
||||
key="persistent-composer"
|
||||
@@ -121,7 +219,7 @@ export function WelcomeScreen({
|
||||
{active ? (
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
className="mt-11"
|
||||
className="cline-view-enter mt-11"
|
||||
onSelect={(action) => onStartChat(action.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Cable } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Shown on the welcome screen when no model provider has credentials yet.
|
||||
* Without it the composer looks fully functional and the first prompt dies
|
||||
* with an opaque failure — the single worst moment of the first-run flow.
|
||||
*/
|
||||
export function WelcomeSetupNotice({
|
||||
onOpenSetup,
|
||||
onOpenModelSettings,
|
||||
}: {
|
||||
onOpenSetup: () => void;
|
||||
onOpenModelSettings: () => void;
|
||||
}) {
|
||||
return (
|
||||
// <output> carries an implicit "status" role, announcing the notice to
|
||||
// assistive tech when it appears.
|
||||
<output className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3 backdrop-blur-sm">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Cable className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Connect a model to start building
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Sign in with Cline or add an API key — it takes under a minute.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={onOpenSetup}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Connect a model
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={onOpenModelSettings}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Model settings
|
||||
</Button>
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
// @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 { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderControls({
|
||||
onSwitchWorkspace = vi.fn(async () => true),
|
||||
onPickWorkspaceDirectory = vi.fn(async (): Promise<string | null> => null),
|
||||
}: {
|
||||
onSwitchWorkspace?: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
|
||||
} = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/projects/project-1"
|
||||
workspaces={["/projects/project-1"]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function clickButton(text: string): Promise<void> {
|
||||
const button = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceMenu(): Promise<void> {
|
||||
await clickButton("project-1");
|
||||
}
|
||||
|
||||
async function typeInSearch(value: string): Promise<void> {
|
||||
const input = container.querySelector<HTMLInputElement>("input");
|
||||
expect(input).toBeDefined();
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, value);
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("WelcomeWorkspaceControls manual path entry", () => {
|
||||
it("offers to open a typed absolute path and switches to it", async () => {
|
||||
const onSwitchWorkspace = vi.fn(async () => true);
|
||||
await renderControls({ onSwitchWorkspace });
|
||||
await openWorkspaceMenu();
|
||||
await typeInSearch("/home/user/personal-stuff");
|
||||
|
||||
await clickButton("Open folder \u201c/home/user/personal-stuff\u201d");
|
||||
|
||||
expect(onSwitchWorkspace).toHaveBeenCalledWith("/home/user/personal-stuff");
|
||||
});
|
||||
|
||||
it("shows a visible error when the typed path cannot be opened", async () => {
|
||||
const onSwitchWorkspace = vi.fn(async () => false);
|
||||
await renderControls({ onSwitchWorkspace });
|
||||
await openWorkspaceMenu();
|
||||
await typeInSearch("/does/not/exist");
|
||||
|
||||
await clickButton("Open folder \u201c/does/not/exist\u201d");
|
||||
|
||||
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
|
||||
});
|
||||
|
||||
it("does not offer path entry for plain search text", async () => {
|
||||
await renderControls();
|
||||
await openWorkspaceMenu();
|
||||
await typeInSearch("project");
|
||||
|
||||
const pathOption = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes("Open folder \u201c"));
|
||||
expect(pathOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps typed path and errors when the workspace catalog refreshes mid-open", async () => {
|
||||
// The workspace catalog re-derives on a timer (session-history refresh),
|
||||
// handing the picker a new onRefreshWorkspaces identity. That must not
|
||||
// wipe the menu's typed path or a visible error while it is open.
|
||||
const onSwitchWorkspace = vi.fn(async () => false);
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/projects/project-1"
|
||||
workspaces={["/projects/project-1"]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
await render();
|
||||
await openWorkspaceMenu();
|
||||
await typeInSearch("/does/not/exist");
|
||||
await clickButton("Open folder \u201c/does/not/exist\u201d");
|
||||
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
|
||||
|
||||
// Re-render with fresh callback identities, as the page does when the
|
||||
// session history poll lands.
|
||||
await render();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>("input");
|
||||
expect(input?.value).toBe("/does/not/exist");
|
||||
expect(container.textContent).toContain('Couldn\'t open "/does/not/exist"');
|
||||
});
|
||||
|
||||
it("surfaces picker failures from Open folder instead of a silent no-op", async () => {
|
||||
const onPickWorkspaceDirectory = vi.fn(async () => {
|
||||
throw new Error(
|
||||
"No system folder picker found (zenity or kdialog). Type or paste a folder path in the workspace selector instead.",
|
||||
);
|
||||
});
|
||||
await renderControls({ onPickWorkspaceDirectory });
|
||||
await openWorkspaceMenu();
|
||||
|
||||
await clickButton("Open folder...");
|
||||
|
||||
expect(container.textContent).toContain("No system folder picker found");
|
||||
});
|
||||
});
|
||||
|
||||
async function renderBranchChipControls(overrides: {
|
||||
currentBranch: string;
|
||||
}): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch={overrides.currentBranch}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: overrides.currentBranch,
|
||||
branches:
|
||||
overrides.currentBranch === "no-git"
|
||||
? []
|
||||
: [overrides.currentBranch],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/home/beatrix/recipes"
|
||||
workspaces={["/home/beatrix/recipes"]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("WelcomeWorkspaceControls branch chip", () => {
|
||||
it("hides the branch chip entirely for a plain (non-git) folder", async () => {
|
||||
await renderBranchChipControls({ currentBranch: "no-git" });
|
||||
|
||||
expect(container.textContent).toContain("recipes");
|
||||
// No git terminology may leak for non-developers: previously this
|
||||
// rendered a chip reading "No branch".
|
||||
expect(container.textContent).not.toContain("No branch");
|
||||
expect(container.textContent).not.toContain("no-git");
|
||||
const buttons = [...container.querySelectorAll("button")];
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the branch switcher chip for git repositories", async () => {
|
||||
await renderBranchChipControls({ currentBranch: "main" });
|
||||
|
||||
expect(container.textContent).toContain("recipes");
|
||||
expect(container.textContent).toContain("main");
|
||||
const buttons = [...container.querySelectorAll("button")];
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("offers Open folder wording instead of Add project", async () => {
|
||||
await renderBranchChipControls({ currentBranch: "no-git" });
|
||||
|
||||
await clickButton("recipes");
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Open folder...");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Add project");
|
||||
});
|
||||
});
|
||||
+78
-20
@@ -13,7 +13,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
normalizeWorkspacePath,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
@@ -36,7 +39,7 @@ function workspaceName(path: string): string {
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const PANEL_CLASS =
|
||||
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
|
||||
|
||||
@@ -91,6 +94,7 @@ function WorkspacePicker({
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [selectingChat, setSelectingChat] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isChatWorkspace =
|
||||
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
|
||||
@@ -100,11 +104,20 @@ function WorkspacePicker({
|
||||
);
|
||||
|
||||
// Refresh the catalog and clear the filter each time the menu opens.
|
||||
// The refresh callback lives in a ref: its identity changes whenever the
|
||||
// workspace catalog re-derives (e.g. periodic session-history refresh), and
|
||||
// re-running this effect mid-open would wipe the user's typed path and any
|
||||
// visible error message.
|
||||
const refreshWorkspacesRef = useRef(onRefreshWorkspaces);
|
||||
useEffect(() => {
|
||||
refreshWorkspacesRef.current = onRefreshWorkspaces;
|
||||
}, [onRefreshWorkspaces]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
void onRefreshWorkspaces();
|
||||
}, [open, onRefreshWorkspaces]);
|
||||
setError(null);
|
||||
void refreshWorkspacesRef.current();
|
||||
}, [open]);
|
||||
|
||||
// The active workspace can be an excluded path (restored session, process
|
||||
// cwd fallback); register it explicitly so it stays visible while active.
|
||||
@@ -131,20 +144,34 @@ function WorkspacePicker({
|
||||
return;
|
||||
}
|
||||
if (switching) return;
|
||||
setError(null);
|
||||
setSwitching(true);
|
||||
const switched = await onSwitchWorkspace(next);
|
||||
setSwitching(false);
|
||||
if (switched) onClose();
|
||||
if (switched) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
`Couldn't open "${next}". Check that the folder exists and try again.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddWorkspace = async () => {
|
||||
if (picking || selectingChat || switching) return;
|
||||
setError(null);
|
||||
setPicking(true);
|
||||
try {
|
||||
const picked = await onPickWorkspaceDirectory(
|
||||
isChatWorkspace ? undefined : workspaceRoot || undefined,
|
||||
);
|
||||
if (picked?.trim()) await handleSelect(picked.trim());
|
||||
} catch (pickError) {
|
||||
setError(
|
||||
pickError instanceof Error && pickError.message.trim()
|
||||
? pickError.message
|
||||
: "The folder picker could not be opened. Type a folder path above instead.",
|
||||
);
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
@@ -174,22 +201,40 @@ function WorkspacePicker({
|
||||
title={workspaceLabel}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate">{workspaceLabel}</span>
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate text-sm">{workspaceLabel}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<SearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search workspaces"
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Search workspaces, or type a folder path"
|
||||
value={search}
|
||||
/>
|
||||
<div className="p-1.5">
|
||||
{looksLikeFolderPath(search) && (
|
||||
<Button
|
||||
className="mb-0.5 flex h-auto w-full items-center justify-start gap-2 rounded-md p-2 text-left"
|
||||
disabled={switching}
|
||||
onClick={() => void handleSelect(search)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-foreground">
|
||||
Open folder “{search.trim()}”
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No workspaces found
|
||||
{looksLikeFolderPath(search)
|
||||
? "Press the option above to open this folder"
|
||||
: "No workspaces found — type a full folder path to add one"}
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkspaces.map((path) => {
|
||||
@@ -199,7 +244,9 @@ function WorkspacePicker({
|
||||
<Button
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
|
||||
isActive ? "bg-accent" : "hover:bg-accent/50",
|
||||
isActive
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={path}
|
||||
@@ -228,7 +275,7 @@ function WorkspacePicker({
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{picking ? "Opening folder picker..." : "Add project..."}
|
||||
{picking ? "Opening folder picker..." : "Open folder..."}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start text-xs text-muted-foreground"
|
||||
@@ -240,6 +287,11 @@ function WorkspacePicker({
|
||||
<FilePlus2 className="size-3" />
|
||||
{selectingChat ? "Switching to chat..." : "Just chat"}
|
||||
</Button>
|
||||
{error && (
|
||||
<div className="mt-1 rounded-md bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,6 +310,7 @@ function BranchPicker({
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
/** Always a real branch name: the parent only mounts this for git repos. */
|
||||
currentBranch: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
@@ -285,8 +338,7 @@ function BranchPicker({
|
||||
};
|
||||
}, [open, onListGitBranches]);
|
||||
|
||||
const hasGit = currentBranch !== "no-git";
|
||||
const branchLabel = hasGit ? currentBranch : "No branch";
|
||||
const branchLabel = currentBranch;
|
||||
|
||||
const filteredBranches = branches.filter((branch) =>
|
||||
branch.toLowerCase().includes(search.toLowerCase()),
|
||||
@@ -314,8 +366,8 @@ function BranchPicker({
|
||||
title={branchLabel}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{branchLabel}</span>
|
||||
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-sm">{branchLabel}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
@@ -342,8 +394,8 @@ function BranchPicker({
|
||||
className={cn(
|
||||
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
disabled={switching}
|
||||
key={branch}
|
||||
@@ -386,7 +438,8 @@ export function WelcomeWorkspaceControls({
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
|
||||
onSelectChat: () => Promise<boolean>;
|
||||
currentBranch: string;
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
currentBranch: string | null;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
@@ -427,7 +480,12 @@ export function WelcomeWorkspaceControls({
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{!isChatWorkspace ? (
|
||||
{/* Git is a developer affordance: a plain (non-git) folder gets no
|
||||
branch chrome at all instead of a confusing "No branch" chip.
|
||||
Pending discovery (null) is treated the same until it resolves. */}
|
||||
{!isChatWorkspace &&
|
||||
currentBranch !== null &&
|
||||
currentBranch !== "no-git" ? (
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
|
||||
@@ -154,6 +154,81 @@ describe("WorkspaceSelector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides git chrome for a plain folder and keeps folder language", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="no-git"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "no-git",
|
||||
branches: [],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => false)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/home/beatrix/recipes"
|
||||
workspaces={["/home/beatrix/recipes"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const trigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
expect(trigger?.textContent).toContain("recipes");
|
||||
// The "no-git" sentinel and the branch separator are developer jargon
|
||||
// that must never leak into the chip for a plain folder.
|
||||
expect(trigger?.textContent).not.toContain("no-git");
|
||||
expect(trigger?.textContent).not.toContain("/home");
|
||||
expect(trigger?.getAttribute("aria-label")).toBe("Folder recipes");
|
||||
|
||||
await click(trigger as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Workspaces");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Branches");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Create and checkout new branch",
|
||||
);
|
||||
expect(container.textContent).not.toContain("No branches found");
|
||||
expect(container.textContent).toContain("Open folder...");
|
||||
expect(
|
||||
container.querySelector('input[placeholder="Search workspaces"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the branch switcher for git repositories", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main", "feature/review"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/workspace/one"
|
||||
workspaces={["/workspace/one"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const trigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
expect(trigger?.textContent).toContain("one");
|
||||
expect(trigger?.textContent).toContain("main");
|
||||
|
||||
await click(trigger as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Branches");
|
||||
});
|
||||
expect(container.textContent).toContain("feature/review");
|
||||
expect(container.textContent).toContain("Create and checkout new branch");
|
||||
});
|
||||
|
||||
it("labels the SDK chat workspace as Chat without listing the raw path", async () => {
|
||||
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Folder,
|
||||
FolderCode,
|
||||
GitBranch,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,7 +18,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
normalizeWorkspacePath,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
function formatWorkspacePath(path: string): string {
|
||||
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
@@ -38,7 +48,8 @@ export function WorkspaceSelector({
|
||||
onCreateGitBranch,
|
||||
disabled = false,
|
||||
}: {
|
||||
currentBranch: string;
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
currentBranch: string | null;
|
||||
workspaceRoot: string;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
workspaces: string[];
|
||||
@@ -58,6 +69,7 @@ export function WorkspaceSelector({
|
||||
const [pickingWorkspace, setPickingWorkspace] = useState(false);
|
||||
const [showWorkspacePathInput, setShowWorkspacePathInput] = useState(false);
|
||||
const [workspacePathInput, setWorkspacePathInput] = useState("");
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
const [showCreateBranch, setShowCreateBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
|
||||
@@ -76,6 +88,10 @@ export function WorkspaceSelector({
|
||||
() => normalizeWorkspacePath(workspaceRoot),
|
||||
[workspaceRoot],
|
||||
);
|
||||
// Git chrome (branch label, branch list, create-branch) is a developer
|
||||
// affordance; a plain folder shows only folder language. Pending branch
|
||||
// discovery (null) is presented like a plain folder until it resolves.
|
||||
const hasGit = currentBranch !== null && currentBranch !== "no-git";
|
||||
|
||||
const openMenu = async () => {
|
||||
if (disabled) {
|
||||
@@ -85,6 +101,7 @@ export function WorkspaceSelector({
|
||||
setSearch("");
|
||||
setShowWorkspacePathInput(false);
|
||||
setWorkspacePathInput("");
|
||||
setWorkspaceError(null);
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
setLoadingBranches(true);
|
||||
@@ -125,19 +142,25 @@ export function WorkspaceSelector({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setWorkspaceError(null);
|
||||
setSwitchingWorkspace(true);
|
||||
const switched = await onSwitchWorkspace(next);
|
||||
setSwitchingWorkspace(false);
|
||||
if (switched) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
setWorkspaceError(
|
||||
`Couldn't open "${next}". Check that the folder exists and try again.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSwitchWorkspacePath = async () => {
|
||||
if (pickingWorkspace || switchingWorkspace) {
|
||||
return;
|
||||
}
|
||||
setWorkspaceError(null);
|
||||
if (onPickWorkspaceDirectory) {
|
||||
setPickingWorkspace(true);
|
||||
try {
|
||||
@@ -145,10 +168,17 @@ export function WorkspaceSelector({
|
||||
if (picked?.trim()) {
|
||||
await handleWorkspaceSelect(picked.trim());
|
||||
}
|
||||
return;
|
||||
} catch (pickError) {
|
||||
// No usable native picker — fall through to manual path entry.
|
||||
setWorkspaceError(
|
||||
pickError instanceof Error && pickError.message.trim()
|
||||
? pickError.message
|
||||
: "The folder picker could not be opened. Type a folder path instead.",
|
||||
);
|
||||
} finally {
|
||||
setPickingWorkspace(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setShowWorkspacePathInput(true);
|
||||
setWorkspacePathInput(workspaceRoot);
|
||||
@@ -215,7 +245,11 @@ export function WorkspaceSelector({
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Workspace ${workspaceName}, branch ${currentBranch}`}
|
||||
aria-label={
|
||||
hasGit
|
||||
? `Workspace ${workspaceName}, branch ${currentBranch}`
|
||||
: `Folder ${workspaceName}`
|
||||
}
|
||||
className="flex max-w-full min-w-0 items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
|
||||
disabled={disabled || switching}
|
||||
id="git-branch-btn"
|
||||
@@ -230,21 +264,30 @@ export function WorkspaceSelector({
|
||||
void openMenu();
|
||||
}}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
{hasGit ? (
|
||||
<GitBranch className="size-3" />
|
||||
) : (
|
||||
<Folder className="size-3" />
|
||||
)}
|
||||
<span className="max-w-20 shrink-0 truncate max-[560px]:sr-only">
|
||||
{workspaceName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground/60 max-[560px]:sr-only">
|
||||
/
|
||||
</span>
|
||||
<span className="min-w-0 truncate max-[560px]:sr-only">
|
||||
{currentBranch}
|
||||
</span>
|
||||
{hasGit ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/60 max-[560px]:sr-only">
|
||||
/
|
||||
</span>
|
||||
<span className="min-w-0 truncate max-[560px]:sr-only">
|
||||
{currentBranch}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="top" sideOffset={6}>
|
||||
{workspaceRoot || workspaceName} / {currentBranch}
|
||||
{workspaceRoot || workspaceName}
|
||||
{hasGit ? ` / ${currentBranch}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -274,7 +317,11 @@ export function WorkspaceSelector({
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search workspaces & branches"
|
||||
placeholder={
|
||||
hasGit
|
||||
? "Search workspaces & branches"
|
||||
: "Search workspaces"
|
||||
}
|
||||
className="flex-1 h-auto border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -291,10 +338,27 @@ export function WorkspaceSelector({
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Workspaces
|
||||
</div>
|
||||
{looksLikeFolderPath(search) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={switchingWorkspace}
|
||||
onClick={() => {
|
||||
void handleWorkspaceSelect(search);
|
||||
}}
|
||||
className="mb-0.5 flex h-auto w-full items-center justify-start gap-2 rounded-md p-2 text-left"
|
||||
>
|
||||
<FolderCode className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-foreground">
|
||||
Open folder “{search.trim()}”
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No workspaces found
|
||||
{looksLikeFolderPath(search)
|
||||
? "Press the option above to open this folder"
|
||||
: "No workspaces found — type a full folder path to add one"}
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkspaces.map((wp) => {
|
||||
@@ -311,7 +375,9 @@ export function WorkspaceSelector({
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between h-auto rounded-md p-2 text-left w-full",
|
||||
isActive ? "bg-accent" : "hover:bg-accent/50",
|
||||
isActive
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
@@ -339,7 +405,7 @@ export function WorkspaceSelector({
|
||||
>
|
||||
{pickingWorkspace
|
||||
? "Opening folder picker..."
|
||||
: "Switch workspace path..."}
|
||||
: "Open folder..."}
|
||||
</Button>
|
||||
{showWorkspacePathInput ? (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
@@ -374,104 +440,113 @@ export function WorkspaceSelector({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Branches section */}
|
||||
<div className="p-1.5">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Branches
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={branch}
|
||||
disabled={switching}
|
||||
onClick={() => {
|
||||
void handleSelectBranch(branch);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-start gap-2 h-auto rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<GitBranch className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{branch}
|
||||
</span>
|
||||
{currentBranch === branch && (
|
||||
<Check className="h-3 w-3 text-foreground ml-auto shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create branch */}
|
||||
<div className="border-t border-border p-1.5">
|
||||
{showCreateBranch ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreateBranch();
|
||||
if (e.key === "Escape") {
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
}
|
||||
}}
|
||||
placeholder="Branch name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void handleCreateBranch()}
|
||||
disabled={!newBranchName.trim() || switching}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
}}
|
||||
className="flex-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{workspaceError && (
|
||||
<div className="mt-1 rounded-md bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{workspaceError}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreateBranch(true)}
|
||||
size="sm"
|
||||
className="justify-start w-full text-xs text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Create and checkout new branch...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branches section (git repos only) */}
|
||||
{hasGit ? (
|
||||
<div className="p-1.5">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Branches
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={branch}
|
||||
disabled={switching}
|
||||
onClick={() => {
|
||||
void handleSelectBranch(branch);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-start gap-2 h-auto rounded-md px-2 py-2 text-left",
|
||||
currentBranch === branch
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
>
|
||||
<GitBranch className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{branch}
|
||||
</span>
|
||||
{currentBranch === branch && (
|
||||
<Check className="h-3 w-3 text-foreground ml-auto shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Create branch (git repos only) */}
|
||||
{hasGit ? (
|
||||
<div className="border-t border-border p-1.5">
|
||||
{showCreateBranch ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreateBranch();
|
||||
if (e.key === "Escape") {
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
}
|
||||
}}
|
||||
placeholder="Branch name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void handleCreateBranch()}
|
||||
disabled={!newBranchName.trim() || switching}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
}}
|
||||
className="flex-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreateBranch(true)}
|
||||
size="sm"
|
||||
className="justify-start w-full text-xs text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Create and checkout new branch...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ function MarketplaceEntryCard({
|
||||
<Button
|
||||
disabled={!installedStatusReady || busy}
|
||||
onClick={handleActionClick}
|
||||
size="sm"
|
||||
size="xs"
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
@@ -462,7 +462,7 @@ function MarketplaceEntryCard({
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
<div className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -473,7 +473,7 @@ function MarketplaceEntryCard({
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="relative grid min-w-0 cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-surface-hover-lighter focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
|
||||
@@ -22,11 +22,16 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
writeModelSelectionStorageToWindow,
|
||||
} from "@/lib/model-selection";
|
||||
import {
|
||||
CLINE_DASHBOARD_URL,
|
||||
getProviderApiKeyUrl,
|
||||
} from "@/lib/provider-key-urls";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
invalidateProviderCatalogCache,
|
||||
@@ -153,11 +158,16 @@ function WelcomeStep({ onContinue }: { onContinue: () => void }) {
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-foreground">
|
||||
Cline
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Build software your way
|
||||
</p>
|
||||
<p className="mt-3 max-w-xs text-sm leading-relaxed text-muted-foreground">
|
||||
Cline is an AI coding agent. It reads your code, edits files, runs
|
||||
commands, and works through tasks with you — in any project on your
|
||||
machine.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-9 h-11 w-full rounded-full text-[15px]"
|
||||
className="mt-9 h-11 w-full rounded-full text-base"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
@@ -307,10 +317,15 @@ function ConnectStep({
|
||||
// account context swallows errors, so an invalid key would
|
||||
// otherwise onboard the user into a broken signed-in state.
|
||||
try {
|
||||
await desktopClient.invoke("cline_account", {
|
||||
const verified = await desktopClient.invoke("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
// A typed not-authenticated result means the sidecar found no
|
||||
// usable credential after the save — the key did not stick.
|
||||
if (isClineAccountNotAuthenticatedResult(verified)) {
|
||||
throw new Error("no Cline account credentials were found");
|
||||
}
|
||||
} catch (verifyError) {
|
||||
// Roll back the persisted key so an unusable credential does
|
||||
// not linger in provider settings.
|
||||
@@ -341,6 +356,9 @@ function ConnectStep({
|
||||
|
||||
const selectedProvider =
|
||||
providers.find((provider) => provider.id === selectedProviderId) ?? null;
|
||||
const selectedProviderKeyUrl = selectedProvider
|
||||
? getProviderApiKeyUrl(selectedProvider)
|
||||
: null;
|
||||
|
||||
const connectProvider = useCallback(async () => {
|
||||
if (!selectedProvider || !apiKey.trim()) {
|
||||
@@ -395,7 +413,7 @@ function ConnectStep({
|
||||
{/* Cline account */}
|
||||
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[15px] font-semibold text-foreground">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Sign in with Cline
|
||||
</p>
|
||||
<Badge className="bg-primary/15 text-primary" variant="secondary">
|
||||
@@ -512,9 +530,14 @@ function ConnectStep({
|
||||
{clineKeySaving ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Find your key in the Cline dashboard under Account.
|
||||
</p>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={() => void openExternalUrl(CLINE_DASHBOARD_URL)}
|
||||
type="button"
|
||||
>
|
||||
Find your key in the Cline dashboard under Account
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
{clineKeyError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
Failed to save API key: {clineKeyError}
|
||||
@@ -538,7 +561,7 @@ function ConnectStep({
|
||||
<KeyRound className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-semibold text-foreground">
|
||||
<span className="block text-base font-semibold text-foreground">
|
||||
Use your own API key
|
||||
</span>
|
||||
<span className="mt-0.5 block text-sm text-muted-foreground">
|
||||
@@ -598,15 +621,14 @@ function ConnectStep({
|
||||
value={apiKey}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{selectedProvider?.docUrl ? (
|
||||
{selectedProviderKeyUrl ? (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() =>
|
||||
void openExternalUrl(selectedProvider.docUrl ?? "")
|
||||
}
|
||||
onClick={() => void openExternalUrl(selectedProviderKeyUrl)}
|
||||
type="button"
|
||||
>
|
||||
{selectedProvider.docLabel || "Get an API key"}
|
||||
{selectedProvider?.docLabel ||
|
||||
`Get ${selectedProvider ? `a ${selectedProvider.name}` : "an"} API key`}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
@@ -665,7 +687,7 @@ function DoneStep({
|
||||
: "Your Cline account is connected. Pick a project and start your first session."}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-8 h-11 w-full rounded-full text-[15px]"
|
||||
className="mt-8 h-11 w-full rounded-full text-base"
|
||||
onClick={onFinish}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageFrame({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -54,13 +54,13 @@ export function PageHeader({
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
|
||||
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
|
||||
<h1 className="truncate text-3xl font-semibold text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
|
||||
<p className="mt-3 max-w-2xl text-base text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -317,10 +317,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-end justify-between gap-6 px-18 pb-7 pt-10 max-[1200px]:px-8 max-md:pl-12 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:pr-4 max-[720px]:pt-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[32px] font-semibold leading-[1.15] tracking-normal">
|
||||
Sessions
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
<h1 className="text-3xl font-semibold">Sessions</h1>
|
||||
<p className="mt-3 text-base leading-6 text-muted-foreground">
|
||||
Recent sessions across clients and workspaces.
|
||||
</p>
|
||||
</div>
|
||||
@@ -459,8 +457,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
// never reflows as long values wrap or hydrate in.
|
||||
"grid h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] items-center gap-x-4 border-t px-4 text-sm transition-colors",
|
||||
activeSessionId === thread.id
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-accent/30",
|
||||
? "bg-surface-hover"
|
||||
: "hover:bg-surface-hover-lighter",
|
||||
)}
|
||||
key={thread.id}
|
||||
>
|
||||
@@ -605,7 +603,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @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 { AccountView } from "./account-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invoke.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AccountView signed-out state", () => {
|
||||
it("renders the sign-in prompt from the typed result and stops fetching account data", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
signedIn: false,
|
||||
code: "ACCOUNT_NOT_AUTHENTICATED",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AccountView />);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Sign in to Cline");
|
||||
});
|
||||
expect(container.textContent).not.toContain(
|
||||
"No Cline account auth token found",
|
||||
);
|
||||
// The auth state gates the rest of the overview: signed out means the
|
||||
// balance/organization commands are never fired.
|
||||
const accountCalls = invoke.mock.calls.filter(
|
||||
([command]) => command === "cline_account",
|
||||
);
|
||||
expect(accountCalls).toEqual([
|
||||
["cline_account", { action: "clineAccount", operation: "fetchMe" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("signs out when the organization balance fetch reports the typed signed-out result", async () => {
|
||||
// The token can expire between the initial account fetches and the
|
||||
// organization-balance fetch; the typed result must sign the view out
|
||||
// rather than being coerced into a signed-in view with no balance.
|
||||
invoke.mockImplementation(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
switch (args?.operation) {
|
||||
case "fetchMe":
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [],
|
||||
};
|
||||
case "fetchBalance":
|
||||
return { balance: 5_000_000 };
|
||||
case "fetchUserOrganizations":
|
||||
return [
|
||||
{
|
||||
organizationId: "org-1",
|
||||
name: "Cline",
|
||||
active: true,
|
||||
roles: ["member"],
|
||||
},
|
||||
];
|
||||
case "fetchOrganizationBalance":
|
||||
return { signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AccountView />);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Sign in to Cline");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Beatrix");
|
||||
});
|
||||
|
||||
it("renders account data when the session is signed in", async () => {
|
||||
invoke.mockImplementation(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
switch (args?.operation) {
|
||||
case "fetchMe":
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [],
|
||||
};
|
||||
case "fetchBalance":
|
||||
return { balance: 5_000_000 };
|
||||
case "fetchUserOrganizations":
|
||||
return [];
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AccountView />);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Beatrix");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Sign in to Cline");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
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";
|
||||
@@ -172,6 +173,10 @@ export function AccountView() {
|
||||
>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
// Signed out is an expected state carried by a typed sidecar result (or a
|
||||
// definitive auth error from older sidecars), tracked separately from
|
||||
// failures so it renders the sign-in prompt instead of an error card.
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
@@ -215,16 +220,38 @@ export function AccountView() {
|
||||
setOverviewLoading(true);
|
||||
setOverviewError(null);
|
||||
try {
|
||||
const [userData, balanceData, orgsData] = await Promise.all([
|
||||
fetchAccountUser(),
|
||||
// Resolve the auth state first: when the session is signed out the
|
||||
// remaining account commands would just fail the same way, so they
|
||||
// are never fired.
|
||||
const userData = await fetchAccountUser();
|
||||
if (isClineAccountNotAuthenticatedResult(userData)) {
|
||||
resetAccountData();
|
||||
setSignedOut(true);
|
||||
return;
|
||||
}
|
||||
const [balanceData, orgsData] = await Promise.all([
|
||||
fetchAccountBalance(),
|
||||
fetchAccountOrganizations(),
|
||||
]);
|
||||
if (
|
||||
isClineAccountNotAuthenticatedResult(balanceData) ||
|
||||
isClineAccountNotAuthenticatedResult(orgsData)
|
||||
) {
|
||||
resetAccountData();
|
||||
setSignedOut(true);
|
||||
return;
|
||||
}
|
||||
const nextActiveOrganization =
|
||||
orgsData.find((organization) => organization.active) ?? null;
|
||||
const organizationBalanceData = nextActiveOrganization
|
||||
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
|
||||
: null;
|
||||
if (isClineAccountNotAuthenticatedResult(organizationBalanceData)) {
|
||||
resetAccountData();
|
||||
setSignedOut(true);
|
||||
return;
|
||||
}
|
||||
setSignedOut(false);
|
||||
setUser(userData);
|
||||
setBalance(balanceData);
|
||||
setOrganizationBalance(organizationBalanceData);
|
||||
@@ -232,7 +259,11 @@ export function AccountView() {
|
||||
} catch (err) {
|
||||
resetAccountData();
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
if (isAccountAuthError(message)) {
|
||||
setSignedOut(true);
|
||||
} else {
|
||||
setOverviewError(message);
|
||||
}
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
@@ -280,7 +311,8 @@ export function AccountView() {
|
||||
});
|
||||
resetAccountData();
|
||||
setActiveTab("overview");
|
||||
setOverviewError("No Cline account auth token found");
|
||||
setOverviewError(null);
|
||||
setSignedOut(true);
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
@@ -321,6 +353,14 @@ export function AccountView() {
|
||||
)
|
||||
: await fetchUsageTransactions();
|
||||
if (usageGenerationRef.current !== generation) return;
|
||||
// The token can expire mid-session: render the sign-in state
|
||||
// instead of an error toast.
|
||||
if (isClineAccountNotAuthenticatedResult(data)) {
|
||||
resetAccountData();
|
||||
setSignedOut(true);
|
||||
setActiveTab("overview");
|
||||
return;
|
||||
}
|
||||
setUsageTransactions(data);
|
||||
setUsageLoaded(true);
|
||||
} catch (err) {
|
||||
@@ -332,7 +372,7 @@ export function AccountView() {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
}, [activeOrganization, resetAccountData]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
|
||||
useEffect(() => {
|
||||
@@ -354,6 +394,12 @@ export function AccountView() {
|
||||
setBillingError(null);
|
||||
try {
|
||||
const data = await fetchPaymentTransactions();
|
||||
if (isClineAccountNotAuthenticatedResult(data)) {
|
||||
resetAccountData();
|
||||
setSignedOut(true);
|
||||
setActiveTab("overview");
|
||||
return;
|
||||
}
|
||||
setPaymentTransactions(data);
|
||||
setBillingLoaded(true);
|
||||
} catch (err) {
|
||||
@@ -362,7 +408,7 @@ export function AccountView() {
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [resetAccountData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "billing" && !billingLoaded) {
|
||||
@@ -409,7 +455,7 @@ export function AccountView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
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-accent hover:text-foreground transition-colors"
|
||||
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 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
@@ -449,7 +495,7 @@ export function AccountView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(CREATE_ACCOUNT_URL)}
|
||||
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-accent hover:text-foreground transition-colors"
|
||||
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 "
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
@@ -481,7 +527,7 @@ export function AccountView() {
|
||||
onClick={input.onSelect}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border border-border px-4 py-3 text-left transition-colors",
|
||||
input.active ? "cursor-default" : "hover:bg-accent/20",
|
||||
input.active ? "cursor-default" : "hover:bg-surface-hover-lighter",
|
||||
!input.active && switchTargetId !== null && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -511,13 +557,13 @@ export function AccountView() {
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account</h2>
|
||||
<h2 className="text-2xl font-semibold text-foreground">Account</h2>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signOut()}
|
||||
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-accent hover:text-foreground transition-colors disabled:opacity-60"
|
||||
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" />
|
||||
@@ -561,22 +607,20 @@ export function AccountView() {
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError &&
|
||||
(isAccountAuthError(overviewError)
|
||||
? renderSignedOut()
|
||||
: renderError(overviewError, loadOverview))}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
{!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-primary/20 text-2xl font-bold text-primary">
|
||||
<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-base font-semibold text-foreground">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
@@ -590,7 +634,7 @@ export function AccountView() {
|
||||
type="button"
|
||||
title="Open dashboard"
|
||||
onClick={() => void openExternalUrl(DASHBOARD_URL)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -618,7 +662,7 @@ export function AccountView() {
|
||||
: 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-accent hover:text-foreground transition-colors"
|
||||
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
|
||||
@@ -652,7 +696,7 @@ export function AccountView() {
|
||||
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-accent hover:text-foreground transition-colors"
|
||||
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 "
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
@@ -715,7 +759,7 @@ export function AccountView() {
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
@@ -769,7 +813,7 @@ export function AccountView() {
|
||||
{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 transition-colors hover:bg-accent/20"
|
||||
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" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user