Compare commits

..

3 Commits

Author SHA1 Message Date
abeatrix 8ec403e16a patch 2026-05-26 11:05:28 -07:00
abeatrix 6155fc1d82 patches 2026-05-26 10:48:55 -07:00
abeatrix bd4821a2ea feat: ClineCore supports updateConnection
The existing SessionRuntime.updateConnection already supports the lower-level fields, but the host API and persistence layer do not expose or persist per-turn updates yet. This PR fixes it.
2026-05-26 10:26:22 -07:00
1118 changed files with 3568 additions and 43713 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
/README.md @saoudrizwan @juanpflores
+7 -7
View File
@@ -33,7 +33,7 @@ permissions:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
publish-main:
@@ -105,12 +105,12 @@ jobs:
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "apps/cli/package.json has invalid version: ${VERSION}"
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
@@ -147,7 +147,7 @@ jobs:
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -194,7 +194,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag latest
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Get Previous CLI Tag
id: prev_tag
@@ -375,7 +375,7 @@ jobs:
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -424,7 +424,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag nightly
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
@@ -15,11 +15,9 @@ jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
# to opt their PR in by commenting /test-jetbrains.
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
if: |
(github.event_name == 'pull_request_target' &&
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/test-jetbrains') &&
@@ -29,8 +27,8 @@ jobs:
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
app-id: 1998650
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
owner: cline
repositories: intellij-plugin
@@ -0,0 +1,69 @@
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
# Owner: Max Paulus
name: ext-vscode-publish-nightly-sdk
on:
schedule:
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
permissions:
contents: read
packages: write
checks: write
pull-requests: write
env:
# Keep the publish source pinned to one reviewed branch instead of accepting arbitrary refs.
SDK_NIGHTLY_REF: dpc/sdk-migration-simpler-login
jobs:
publish:
name: Publish Cline New SDK Extension Nightly
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout trusted SDK nightly branch
uses: actions/checkout@v4
with:
ref: ${{ env.SDK_NIGHTLY_REF }}
lfs: true
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install root dependencies
run: npm ci --include=optional
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Publish SDK nightly extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run publish:marketplace:nightly
@@ -20,7 +20,6 @@ jobs:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/ext-vscode-test.yml
publish:
@@ -31,9 +30,6 @@ jobs:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
@@ -57,13 +53,11 @@ jobs:
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
- name: Install root dependencies
run: npm ci --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: cd webview-ui && npm ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -114,13 +114,11 @@ jobs:
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install root dependencies
run: npm install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
run: cd webview-ui && npm install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
+3 -5
View File
@@ -128,15 +128,13 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Install vsce
run: npm install -g @vscode/vsce
+10 -18
View File
@@ -91,15 +91,13 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
@@ -132,19 +130,16 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Set up NPM on Windows
if: runner.os == 'Windows'
working-directory: ${{ github.workspace }}
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
@@ -234,15 +229,13 @@ jobs:
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: cd webview-ui && npm ci
- name: Download ripgrep binaries
run: npm run download-ripgrep
@@ -251,8 +244,7 @@ jobs:
run: npm run compile-standalone
- name: Install testing platform dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
timeout-minutes: 7
+9 -9
View File
@@ -26,7 +26,7 @@ on:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
test:
@@ -148,7 +148,7 @@ jobs:
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
TIMESTAMP=$(date +%s)
@@ -166,11 +166,11 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: bun sdk/scripts/version.ts "$VERSION"
run: bun scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun sdk/scripts/check-publish.ts
run: bun scripts/check-publish.ts
- name: Prepare package tarball directory
if: steps.check_commits.outputs.skip != 'true'
@@ -187,7 +187,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/shared
cd packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -199,7 +199,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/llms
cd packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -211,7 +211,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/agents
cd packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -223,7 +223,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/core
cd packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -235,7 +235,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/sdk
cd packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
+4 -4
View File
@@ -21,7 +21,7 @@ permissions:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
quality-checks:
@@ -96,12 +96,12 @@ jobs:
- name: Run SDK Tests (Windows)
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './sdk/packages/**' test
run: bun -F './packages/**' test
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
run: bun sdk/scripts/ci-node-smoke.ts
run: bun scripts/ci-node-smoke.ts
- name: Run TUI e2e tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
@@ -109,4 +109,4 @@ jobs:
- name: Verify packages are publishable
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun sdk/scripts/check-publish.ts
run: bun scripts/check-publish.ts
-11
View File
@@ -61,17 +61,6 @@ tests/**/cache
# Should never be committed: only exists if a publish aborts mid-swap.
.README.github.bak
# Tauri generated code
apps/*/src-tauri/gen
apps/*/src-tauri/bin
apps/examples/*/src-tauri/gen
apps/examples/*/src-tauri/bin
# Tauri UI test snapshots
apps/*/src/tests/.tui-test
apps/*/src/tests/tui-traces
apps/vscode/webview-ui/src/**/*.js
apps/vscode/webview-ui/src/**/*.js.map
# SDK Session files / User data
.cline/data
-9
View File
@@ -1,10 +1 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
+21 -22
View File
@@ -5,8 +5,8 @@
"tasks": [
{
"label": "compile-standalone",
"type": "shell",
"command": "npm run compile-standalone",
"type": "npm",
"script": "compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -18,8 +18,8 @@
},
{
"label": "npm: protos",
"type": "shell",
"command": "npm run protos",
"type": "npm",
"script": "protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -64,8 +64,8 @@
"group": "build"
},
{
"type": "shell",
"command": "npm run build:webview",
"type": "npm",
"script": "build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -85,8 +85,8 @@
}
},
{
"type": "shell",
"command": "npm run build:webview:test",
"type": "npm",
"script": "build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -107,8 +107,8 @@
}
},
{
"type": "shell",
"command": "npm run dev:webview",
"type": "npm",
"script": "dev:webview",
"group": "build",
"problemMatcher": [
{
@@ -144,8 +144,8 @@
}
},
{
"type": "shell",
"command": "npm run watch:esbuild",
"type": "npm",
"script": "watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -183,8 +183,8 @@
}
},
{
"type": "shell",
"command": "npm run watch:esbuild:test",
"type": "npm",
"script": "watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -223,8 +223,8 @@
}
},
{
"type": "shell",
"command": "npm run watch:tsc",
"type": "npm",
"script": "watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -241,9 +241,8 @@
}
},
{
"type": "shell",
"command": "npm run watch-tests",
"label": "npm: watch-tests",
"type": "npm",
"script": "watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"dependsOn": [
@@ -281,8 +280,8 @@
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
},
{
"type": "shell",
"command": "npm run storybook",
"type": "npm",
"script": "storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
@@ -309,7 +308,7 @@
"$tsc"
],
"options": {
"cwd": "${workspaceFolder}"
"cwd": "${workspaceFolder}/sdk"
}
}
],
-38
View File
@@ -1,43 +1,5 @@
# Changelog
## [3.87.0]
### Added
- Add MiniMax M3 model support.
### Fixed
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
## [3.86.2]
### Fixed
- Fix `@` file mentions and workspace file search on VS Code 1.122+ by resolving the new bundled `@vscode/ripgrep-universal` per-platform binary layout before falling back to legacy ripgrep paths.
## [3.86.1]
### Fixed
- Fix `@` file mentions failing to find files in some environments (notably VS Code Remote SSH, and after certain VS Code updates) by keeping the file-search fallback alive when the workspace index or bundled ripgrep binary is unavailable.
## [3.86.0]
### Added
- Add Claude Opus 4.8 provider support, including 1M-context variants where available.
- Add Moonshot Kimi K2.6 model support.
### Fixed
- Show prompt-cache support for Qwen 3.7 Max in the Cline provider.
- Fix the VS Code nightly publish workflow startup permissions.
### Changed
- Move the VS Code extension project into `apps/vscode`.
## [3.85.0]
### Added
+2 -2
View File
@@ -51,7 +51,7 @@ for CI/CD and scripting.
npm i -g cline
```
<a href="./apps/cli/README.md">Learn more</a>
<a href="./sdk/apps/cli/README.md">Learn more</a>
<br><br>
</td>
@@ -129,7 +129,7 @@ npm install @cline/sdk
| Product | Description | Location | CHANGELOG |
|---------|------------|--------------|--------------|
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": ["../sdk/biome.json"],
"linter": {
"rules": {
"a11y": {
"noStaticElementInteractions": "warn"
}
}
}
}
-36
View File
@@ -1,36 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
describe("runAcpMode", () => {
afterEach(() => {
vi.doUnmock("@agentclientprotocol/sdk");
vi.doUnmock("./acpAgent");
vi.restoreAllMocks();
});
it("writes the startup diagnostic without labeling it as an error", async () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
vi.doMock("@agentclientprotocol/sdk", () => ({
ndJsonStream: vi.fn(() => ({})),
AgentSideConnection: class {
closed = Promise.resolve();
},
}));
vi.doMock("./acpAgent", () => ({
AcpAgent: class {},
}));
const { runAcpMode } = await import("./index");
await runAcpMode();
expect(stderrWrite).toHaveBeenCalledWith(
"[acp] starting ACP mode over stdio…\n",
);
expect(stderrWrite).not.toHaveBeenCalledWith(
expect.stringContaining("error:"),
);
});
});
-184
View File
@@ -1,184 +0,0 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { arch, platform, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_WRAPPER_PATH",
] as const;
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
);
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe("runDashboardCommand", () => {
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
const output: string[] = [];
const errors: string[] = [];
const opened: string[] = [];
const stop = vi.fn();
let observedEnv:
| {
workspaceRoot: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
roomSecret: string | undefined;
webviewDistDir: string | undefined;
}
| undefined;
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
cwd: "sdk",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
io: {
writeln: (text) => output.push(text ?? ""),
writeErr: (text) => errors.push(text),
},
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
roomSecret: process.env.ROOM_SECRET,
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
};
return {
listenUrl: "http://127.0.0.1:9090/",
publicUrl: "http://127.0.0.1:9090",
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
hubUrl: "ws://127.0.0.1:25463/hub",
stop,
};
},
openUrl: async (url) => {
opened.push(url);
},
waitForShutdown: async (server) => {
await server.stop();
},
});
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
webviewDistDir,
});
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
expect(stop).toHaveBeenCalledTimes(1);
expect(output.join("\n")).toContain("Cline dashboard listening at");
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
expect(errors).toEqual([]);
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
});
it("honors --no-open behavior", async () => {
const openUrl = vi.fn();
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => ({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
}),
openUrl,
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(openUrl).not.toHaveBeenCalled();
});
it("finds webview assets from the published wrapper package layout", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
const platformName = platform() === "win32" ? "windows" : platform();
const webviewDistDir = join(
root,
"node_modules",
"cline",
"node_modules",
"@cline",
`cli-${platformName}-${arch()}`,
"cline-hub",
"webview",
);
mkdirSync(join(wrapperPath, ".."), { recursive: true });
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_WRAPPER_PATH = wrapperPath;
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
let observedWebviewDistDir: string | undefined;
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => {
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
return {
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
};
},
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(observedWebviewDistDir).toBe(webviewDistDir);
});
it("settles shutdown when server stop rejects", async () => {
const shutdown = waitForProcessShutdown({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(async () => {
throw new Error("stop failed");
}),
});
process.emit("SIGINT", "SIGINT");
await expect(shutdown).rejects.toThrow("stop failed");
});
});
-195
View File
@@ -1,195 +0,0 @@
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { c } from "../utils/output";
export interface DashboardServerHandle {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
hubUrl?: string;
stop: () => void | Promise<void>;
}
interface DashboardCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
export interface RunDashboardCommandOptions {
cwd?: string;
host?: string;
port?: string;
publicUrl?: string;
roomSecret?: string;
openBrowser?: boolean;
io: DashboardCommandIo;
startServer?: () => Promise<DashboardServerHandle>;
openUrl?: (url: string) => Promise<void>;
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
}
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value === undefined) {
return () => {};
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
};
}
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const restore = [
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
];
try {
return await fn();
} finally {
for (let i = restore.length - 1; i >= 0; i--) {
restore[i]?.();
}
}
}
function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const candidates = [
...resolveInstalledPlatformPackageWebviewCandidates(),
// Source checkout: apps/cli/src/commands/dashboard.ts
join(moduleDir, "../../../cline-hub/dist/webview"),
// Node bundle: apps/cli/dist/index.js
join(moduleDir, "cline-hub/webview"),
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
join(dirname(process.execPath), "../cline-hub/webview"),
];
return candidates.find((candidate) => existsSync(candidate));
}
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
const packageName = resolvePlatformPackageName();
const starts = [
process.env.CLINE_WRAPPER_PATH
? dirname(process.env.CLINE_WRAPPER_PATH)
: undefined,
dirname(process.execPath),
].filter((value): value is string => !!value?.trim());
const candidates: string[] = [];
for (const start of starts) {
let current = start;
for (;;) {
candidates.push(
join(current, "node_modules", packageName, "cline-hub/webview"),
);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
}
return candidates;
}
function resolvePlatformPackageName(): string {
const platformName = platform() === "win32" ? "windows" : platform();
return `@cline/cli-${platformName}-${arch()}`;
}
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
return await startClineHubDashboardServer();
}
async function openDefaultUrl(url: string): Promise<void> {
await open(url, { wait: false });
}
export function waitForProcessShutdown(
server: DashboardServerHandle,
): Promise<void> {
return new Promise<void>((resolveShutdown, rejectShutdown) => {
let settled = false;
const cleanup = () => {
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
};
const stop = async () => {
if (settled) return;
settled = true;
cleanup();
try {
await server.stop();
resolveShutdown();
} catch (error) {
rejectShutdown(error);
}
};
function handleSignal() {
void stop();
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
});
}
export async function runDashboardCommand(
options: RunDashboardCommandOptions,
): Promise<number> {
try {
const server = await withDashboardEnvironment(options, () =>
(options.startServer ?? startDefaultDashboardServer)(),
);
const dashboardUrl =
server.inviteUrl || server.publicUrl || server.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (server.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
}
if (options.openBrowser !== false) {
try {
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io.writeErr(`Failed to open browser: ${message}`);
}
}
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
return 0;
} catch (error) {
options.io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
@@ -1,626 +0,0 @@
import { writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectDiscordOptions } from "@cline/shared";
import type { Thread } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { readBindings, writeBindings } from "../thread-bindings";
import { __test__, discordConnector } from "./discord";
const parseDiscordArgs = (rawArgs: string[]): ConnectDiscordOptions =>
(
discordConnector as unknown as {
parseArgs(rawArgs: string[]): ConnectDiscordOptions;
}
).parseArgs(rawArgs);
type TestDiscordState = {
sessionId?: string;
enableTools?: boolean;
autoApproveTools?: boolean;
cwd?: string;
workspaceRoot?: string;
systemPrompt?: string;
participantKey?: string;
participantLabel?: string;
welcomeSentAt?: string;
};
function createThread(
initialState: TestDiscordState,
): Thread<TestDiscordState> {
let state = { ...initialState };
return {
id: "discord:guild:channel:thread",
channelId: "discord:guild:channel",
isDM: false,
get state() {
return Promise.resolve(state);
},
async setState(nextState: TestDiscordState) {
state = { ...nextState };
},
toJSON() {
return {
id: "discord:guild:channel:thread",
channelId: "discord:guild:channel",
isDM: false,
state,
};
},
} as unknown as Thread<TestDiscordState>;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("discordConnector", () => {
it("accepts the documented app id and token aliases", () => {
const options = parseDiscordArgs([
"--app-id",
"app-123",
"--token",
"bot-token",
"--public-key",
"public-key",
"--base-url",
"https://example.test",
]);
expect(options.applicationId).toBe("app-123");
expect(options.botToken).toBe("bot-token");
expect(options.publicKey).toBe("public-key");
expect(options.baseUrl).toBe("https://example.test");
});
it("keeps accepting the explicit application id and bot token options", () => {
const options = parseDiscordArgs([
"--application-id",
"app-456",
"--bot-token",
"other-token",
"--public-key",
"public-key",
"--owner-user-id",
"owner-123",
]);
expect(options.applicationId).toBe("app-456");
expect(options.botToken).toBe("other-token");
expect(options.ownerUserId).toBe("owner-123");
expect(options.allowBotAuthors).toBe(true);
});
it("can explicitly ignore bot-authored Discord messages", () => {
const options = parseDiscordArgs([
"--application-id",
"app-456",
"--bot-token",
"other-token",
"--public-key",
"public-key",
"--ignore-bot-authors",
]);
expect(options.allowBotAuthors).toBe(false);
});
it("builds empty-runtime fallback replies from the current Discord turn", async () => {
const priorMessages = [
{
role: "user",
content: [{ type: "text", text: "previous question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Previous reply." }],
},
];
const currentMessages = [
...priorMessages,
{
role: "user",
content: [{ type: "text", text: "read README.md" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Summary from saved session." }],
},
];
const client = {
readMessages: vi
.fn()
.mockResolvedValueOnce(priorMessages)
.mockResolvedValueOnce(currentMessages),
};
const resolveFallbackText =
await __test__.createDiscordEmptyRuntimeReplyResolver({
client: client as never,
sessionId: "session-1",
});
await expect(resolveFallbackText?.()).resolves.toBe(
"Summary from saved session.",
);
expect(client.readMessages).toHaveBeenCalledTimes(2);
});
it("does not reuse prior Discord replies as empty-runtime fallback text", async () => {
const priorMessages = [
{
role: "user",
content: [{ type: "text", text: "previous question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Previous reply." }],
},
];
const currentMessages = [
...priorMessages,
{
role: "user",
content: [{ type: "text", text: "run ls /tmp" }],
},
{
role: "tool",
content: [{ type: "text", text: "tool output" }],
},
];
const client = {
readMessages: vi
.fn()
.mockResolvedValueOnce(priorMessages)
.mockResolvedValueOnce(currentMessages),
};
const resolveFallbackText =
await __test__.createDiscordEmptyRuntimeReplyResolver({
client: client as never,
sessionId: "session-1",
});
await expect(resolveFallbackText?.()).resolves.toBeUndefined();
expect(client.readMessages).toHaveBeenCalledTimes(2);
});
it("resolves Discord participants from normalized gateway message authors", () => {
expect(
__test__.resolveDiscordParticipant(
{
content: "<@1509620637721821224> Heyo",
author: {
id: "bot-message-author-should-not-win",
username: "beebot",
},
},
{
userId: "850213762576810065",
userName: "alice",
fullName: "Alice Example",
},
),
).toEqual({
key: "discord:user:850213762576810065",
label: "Alice Example",
});
});
it("resolves Discord interaction users even when raw.data is command data", () => {
expect(
__test__.resolveDiscordParticipant({
id: "interaction-1",
data: { name: "ask" },
member: {
user: {
id: "488220547356950529",
username: "bob",
global_name: "Bob Example",
},
},
}),
).toEqual({
key: "discord:user:488220547356950529",
label: "Bob Example",
});
});
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
const bindingsPath = join(dir, "threads.json");
const thread = createThread({
sessionId: "session-alice",
participantKey: "discord:user:alice",
participantLabel: "Alice",
});
writeBindings<TestDiscordState>(bindingsPath, {
"discord:user:alice": {
channelId: thread.channelId,
isDM: thread.isDM,
participantKey: "discord:user:alice",
participantLabel: "Alice",
serializedThread: JSON.stringify(thread.toJSON()),
sessionId: "session-alice",
state: {
sessionId: "session-alice",
participantKey: "discord:user:alice",
participantLabel: "Alice",
},
updatedAt: "2026-05-26T00:00:00.000Z",
},
});
await __test__.persistDiscordThreadContext({
thread,
bindingsPath,
baseStartRequest: {
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
systemPrompt: "system",
provider: "cline",
model: "test-model",
mode: "act",
},
message: {
raw: {
author: {
id: "bob",
username: "bob",
global_name: "Bob",
},
},
},
errorLabel: "Discord",
});
const bob =
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
expect(bob?.state?.participantKey).toBe("discord:user:bob");
expect(bob?.state?.participantLabel).toBe("Bob");
expect(bob?.state?.sessionId).toBeUndefined();
expect(
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
?.sessionId,
).toBe("session-alice");
});
it("adds Discord author context to runtime turns", () => {
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
ownerUserId: "850213762576810065",
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("authorId: 850213762576810065");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{ ownerUserId: "850213762576810065" },
),
).toContain("isOwner: true");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("isDirectMention: false");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("isSubscribedThreadMessage: true");
});
it("instructs Discord agents to use /idle for unrelated subscribed thread messages", () => {
expect(__test__.DISCORD_SYSTEM_RULES).toContain("reply exactly /idle");
expect(__test__.DISCORD_SYSTEM_RULES).toContain("isDirectMention is false");
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /mute@BotName");
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /unmute@BotName");
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
"/mute@BotName @user-or-bot",
);
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
"/unmute@BotName @user-or-bot",
);
});
it("resolves Discord mute targets from user mentions and ids", () => {
expect(__test__.resolveDiscordMuteTarget("<@123456789012345678>")).toEqual({
participantKey: "discord:user:123456789012345678",
participantLabel: "<@123456789012345678>",
});
expect(__test__.resolveDiscordMuteTarget("<@!123456789012345678>")).toEqual(
{
participantKey: "discord:user:123456789012345678",
participantLabel: "<@123456789012345678>",
},
);
expect(__test__.resolveDiscordMuteTarget("@123456789012345678")).toEqual({
participantKey: "discord:user:123456789012345678",
participantLabel: "<@123456789012345678>",
});
expect(__test__.resolveDiscordMuteTarget("@not-a-user-id")).toBeUndefined();
});
it("resolves outbound Discord mention names to user mention ids", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain(
"/guilds/guild-123/members/search?query=cline-test-bot&limit=10",
);
return new Response(
JSON.stringify([
{
nick: "cline-test-bot",
user: {
id: "1509620637721821224",
username: "clinetestbot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "@cline-test-bot how is your day?",
}),
).resolves.toBe("<@1509620637721821224> how is your day?");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("repairs adapter-split hyphenated Discord mention names before resolving", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain("query=cline-test-bot");
return new Response(
JSON.stringify([
{
nick: "cline-test-bot",
user: {
id: "1509620637721821224",
username: "clinetestbot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "<@cline>-test-bot how is your day?",
}),
).resolves.toBe("<@1509620637721821224> how is your day?");
});
it("does not resolve outbound mentions from non-exact Discord member search results", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify([
{
nick: "team-alice-bot",
user: {
id: "wrong-user",
username: "team-alice-bot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "@alice can you check this?",
}),
).resolves.toBe("@alice can you check this?");
});
it("normalizes forwarded bot-role mentions as Discord mentions", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain("/guilds/guild-role-test/members/app-123");
return new Response(JSON.stringify({ roles: ["role-123"] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const request = new Request("https://example.test/api/webhooks/discord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
data: {
id: "message-1",
guild_id: "guild-role-test",
channel_id: "channel-1",
content: "<@&role-123> hello",
mention_roles: ["role-123"],
mentions: [],
author: {
id: "user-1",
username: "alice",
bot: false,
},
},
}),
});
const normalized = await __test__.normalizeDiscordForwardedGatewayRequest({
request,
botToken: "token",
applicationId: "app-123",
});
const event = (await normalized.json()) as {
data: { is_mention?: boolean };
};
expect(event.data.is_mention).toBe(true);
});
it("retries bot role lookups after transient Discord API failures", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("temporary", { status: 500 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ roles: ["role-123"] }), {
status: 200,
}),
);
vi.stubGlobal("fetch", fetchMock);
const buildRequest = () =>
new Request("https://example.test/api/webhooks/discord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
data: {
id: "message-1",
guild_id: "guild-retry-test",
channel_id: "channel-1",
content: "<@&role-123> hello",
mention_roles: ["role-123"],
mentions: [],
author: {
id: "user-1",
username: "alice",
bot: false,
},
},
}),
});
const failed = await __test__.normalizeDiscordForwardedGatewayRequest({
request: buildRequest(),
botToken: "token",
applicationId: "app-retry",
});
const failedEvent = (await failed.json()) as {
data: { is_mention?: boolean };
};
expect(failedEvent.data.is_mention).toBeUndefined();
const retried = await __test__.normalizeDiscordForwardedGatewayRequest({
request: buildRequest(),
botToken: "token",
applicationId: "app-retry",
});
const retriedEvent = (await retried.json()) as {
data: { is_mention?: boolean };
};
expect(retriedEvent.data.is_mention).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("restores persisted thread subscriptions once on startup", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-bindings-"));
const bindingsPath = join(dir, "threads.json");
const subscribe = vi.fn(async () => undefined);
const threads = new Map([
[
"thread-1",
{
id: "thread-1",
subscribe,
},
],
]);
const bot = {
reviver: () => (_key: string, value: unknown) => {
if (
value &&
typeof value === "object" &&
(value as { _type?: string })._type === "chat:Thread"
) {
return threads.get((value as { id: string }).id) ?? value;
}
return value;
},
};
const logger = {
core: { log: vi.fn() },
} as unknown as Parameters<
typeof __test__.restoreDiscordThreadSubscriptions
>[0]["logger"];
writeFileSync(
bindingsPath,
JSON.stringify({
"discord:user:1": {
channelId: "discord:g:c",
isDM: false,
participantKey: "discord:user:1",
serializedThread: JSON.stringify({
_type: "chat:Thread",
id: "thread-1",
}),
updatedAt: "2026-05-26T00:00:00.000Z",
},
duplicate: {
channelId: "discord:g:c",
isDM: false,
serializedThread: JSON.stringify({
_type: "chat:Thread",
id: "thread-1",
}),
updatedAt: "2026-05-26T00:00:00.000Z",
},
}),
);
const restored = await __test__.restoreDiscordThreadSubscriptions({
bot,
bindingsPath,
logger,
});
expect(restored).toBe(1);
expect(subscribe).toHaveBeenCalledTimes(1);
expect(logger.core.log).not.toHaveBeenCalled();
});
});
-36
View File
@@ -1,36 +0,0 @@
export type ConnectorCatalogEntry = {
name: string;
description: string;
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
},
{
name: "gchat",
description: "Google Chat webhook bridge backed by RPC runtime sessions",
},
{
name: "linear",
description: "Linear webhook bridge backed by RPC runtime sessions",
},
{
name: "slack",
description: "Slack webhook bridge backed by RPC runtime sessions",
},
{
name: "telegram",
description: "Bridge Telegram bot messages into RPC chat sessions",
},
{
name: "whatsapp",
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
},
];
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
}
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { getConnector, listConnectors } from "./registry";
describe("connector registry", () => {
it("registers the Discord connector", async () => {
expect(listConnectors().map((connector) => connector.name)).toContain(
"discord",
);
await expect(getConnector("discord")).resolves.toMatchObject({
name: "discord",
});
});
});
-190
View File
@@ -1,190 +0,0 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export type ActiveConnectorRecord = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
};
function listConnectorStatePaths(
type: ActiveConnectorRecord["type"],
): string[] {
const dir = join(resolveClineDataDir(), "connectors", type);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
.map((name) => join(dir, name));
}
function readJsonRecord(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore malformed connector state.
}
return undefined;
}
type ConnectorFieldKey = keyof Omit<
ActiveConnectorRecord,
"id" | "type" | "pid" | "hubUrl"
>;
const connectorFieldExtractors: Record<
ConnectorFieldKey,
(p: Record<string, unknown>) => string | number | undefined
> = {
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
port: (p) => (typeof p.port === "number" ? p.port : undefined),
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
};
const connectorConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
discord: {
required: ["userName", "applicationId"],
optional: ["startedAt", "port", "baseUrl"],
},
telegram: { required: ["botUsername"], optional: ["startedAt"] },
gchat: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
linear: {
required: ["userName"],
optional: ["startedAt", "port", "baseUrl"],
},
slack: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
whatsapp: {
required: ["userName"],
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
},
};
function connectorRecordId(
type: ActiveConnectorRecord["type"],
fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
>,
pid: number,
): string {
const identity =
fields.botUsername ??
fields.userName ??
fields.applicationId ??
fields.phoneNumberId ??
String(pid);
return `${type}:${identity}`;
}
function readActiveConnectorRecord(
type: ActiveConnectorRecord["type"],
statePath: string,
): ActiveConnectorRecord | undefined {
const parsed = readJsonRecord(statePath);
if (!parsed) {
return undefined;
}
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
const hubUrl =
typeof parsed.hubUrl === "string"
? parsed.hubUrl
: typeof parsed.rpcAddress === "string"
? parsed.rpcAddress
: undefined;
if (!pid || !hubUrl || !isProcessRunning(pid)) {
return undefined;
}
const config = connectorConfigs[type];
if (!config) {
return undefined;
}
const fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
> = {};
for (const key of config.required) {
const value = connectorFieldExtractors[key](parsed);
if (!value || (typeof value === "string" && !value.trim())) {
return undefined;
}
(fields as Record<string, unknown>)[key] = value;
}
for (const key of config.optional) {
const value = connectorFieldExtractors[key](parsed);
if (value !== undefined) {
(fields as Record<string, unknown>)[key] = value;
}
}
return {
id: connectorRecordId(type, fields, pid),
type,
pid,
hubUrl,
...fields,
} as ActiveConnectorRecord;
}
export function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"discord",
"telegram",
"gchat",
"linear",
"slack",
"whatsapp",
];
const records: ActiveConnectorRecord[] = [];
for (const type of connectorTypes) {
for (const statePath of listConnectorStatePaths(type)) {
const record = readActiveConnectorRecord(type, statePath);
if (record) {
records.push(record);
}
}
}
return records.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
const leftName = left.botUsername ?? left.userName ?? "";
const rightName = right.botUsername ?? right.userName ?? "";
return leftName.localeCompare(rightName);
});
}
@@ -1,252 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Thread } from "chat";
import { afterEach, describe, expect, it } from "vitest";
import {
type ConnectorThreadState,
clearBindingSessionIds,
isParticipantMuted,
isThreadMuted,
readBindingForThread,
readBindings,
setParticipantMuted,
setThreadMuted,
writeBindings,
} from "./thread-bindings";
type TestState = ConnectorThreadState & {
teamId?: string;
};
const tempDirs: string[] = [];
function createBindingsPath(): string {
const dir = mkdtempSync(join(tmpdir(), "thread-bindings-"));
tempDirs.push(dir);
return join(dir, "bindings.json");
}
function createThread(input: {
id: string;
channelId: string;
isDM: boolean;
participantKey?: string;
}): Thread<TestState> {
return {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
toJSON: () => ({
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
}),
} as unknown as Thread<TestState>;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("thread binding refresh", () => {
it("refreshes the serialized thread immediately when channel fallback rebinds a thread id", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
legacy_thread_id: {
channelId: "slack:C123",
isDM: false,
serializedThread: JSON.stringify({
id: "legacy_thread_id",
channelId: "slack:C123",
isDM: false,
}),
sessionId: "sess-1",
state: { sessionId: "sess-1", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const binding = readBindingForThread<TestState>(
path,
createThread({
id: "new_thread_id",
channelId: "slack:C123",
isDM: false,
}),
"Slack",
);
expect(binding?.serializedThread).toContain("new_thread_id");
const bindings = readBindings<TestState>(path);
expect(bindings.legacy_thread_id).toBeUndefined();
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
});
it("refreshes the serialized thread when a participant-key binding matches a new thread id", () => {
const path = createBindingsPath();
const participantKey = "slack:team:T123:user:U123";
writeBindings<TestState>(path, {
[participantKey]: {
channelId: "slack:C123",
isDM: false,
participantKey,
serializedThread: JSON.stringify({
id: "legacy_thread_id",
channelId: "slack:C123",
isDM: false,
}),
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
participantKey,
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const binding = readBindingForThread<TestState>(
path,
createThread({
id: "new_thread_id",
channelId: "slack:C123",
isDM: false,
}),
"Slack",
participantKey,
);
expect(binding?.serializedThread).toContain("new_thread_id");
expect(
readBindings<TestState>(path)[participantKey]?.serializedThread,
).toContain("new_thread_id");
});
it("stores mute state at thread scope instead of participant scope", () => {
const path = createBindingsPath();
const thread = createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
participantKey: "discord:user:alice",
});
setThreadMuted(path, thread, true, "Discord");
expect(
isThreadMuted(
path,
createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
participantKey: "discord:user:bob",
}),
),
).toBe(true);
const binding = readBindingForThread<TestState>(
path,
thread,
"Discord",
"discord:user:alice",
);
expect(binding).toBeUndefined();
setThreadMuted(path, thread, false, "Discord");
expect(isThreadMuted(path, thread)).toBe(false);
});
it("stores participant mute state scoped to the current thread", () => {
const path = createBindingsPath();
const thread = createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
});
const otherThread = createThread({
id: "thread-2",
channelId: "discord:guild:channel",
isDM: false,
});
setParticipantMuted(
path,
thread,
{
participantKey: "discord:user:bob",
participantLabel: "Bob",
},
true,
"Discord",
);
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(true);
expect(isParticipantMuted(path, thread, "discord:user:alice")).toBe(false);
expect(isParticipantMuted(path, otherThread, "discord:user:bob")).toBe(
false,
);
expect(
readBindingForThread<TestState>(
path,
thread,
"Discord",
"discord:user:bob",
),
).toBeUndefined();
setParticipantMuted(
path,
thread,
{ participantKey: "discord:user:bob" },
false,
"Discord",
);
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(false);
});
});
describe("clearBindingSessionIds", () => {
it("clears session ids from bindings and serialized thread state", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
thread_1: {
channelId: "discord:C123",
isDM: false,
serializedThread: JSON.stringify({
id: "thread_1",
channelId: "discord:C123",
isDM: false,
sessionId: "legacy-root-session",
state: {
sessionId: "sess-1",
cwd: "/tmp/work",
teamId: "T123",
},
}),
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
clearBindingSessionIds<TestState>(path);
const binding = readBindings<TestState>(path).thread_1;
expect(binding?.sessionId).toBeUndefined();
expect(binding?.state?.sessionId).toBeUndefined();
expect(binding?.state?.cwd).toBe("/tmp/work");
const serializedThread = JSON.parse(binding?.serializedThread ?? "{}") as {
sessionId?: string;
state?: TestState;
};
expect(serializedThread.sessionId).toBeUndefined();
expect(serializedThread.state?.sessionId).toBeUndefined();
expect(serializedThread.state?.cwd).toBe("/tmp/work");
});
});
-139
View File
@@ -1,139 +0,0 @@
import { Llms } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback, useMemo } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
InteractiveConfigTab,
LoadInteractiveConfigDataOptions,
} from "../../tui/interactive-config";
import type { CliCompactionMode, Config } from "../../utils/types";
import { ExtDetailContent } from "../components/dialogs/config-dialogs";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { ConfigPanelContent } from "../views/config-view";
import type { ConfigAction } from "../views/config-view-helpers";
import type { OpenModelSelectorOptions } from "./use-model-selector";
export interface OpenConfigOptions {
initialTab?: InteractiveConfigTab;
}
export function useConfigPanel(opts: {
dialog: DialogActions;
config: Config;
sessionUiMode: string;
compactionMode: CliCompactionMode;
toggleMode: () => void;
toggleAutoApprove: () => void;
setCompactionMode: (mode: CliCompactionMode) => void;
termHeight: number;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData>;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
refocusTextarea: () => void;
}) {
const emptyConfigData = useMemo(
() => ({
workflows: [] as InteractiveConfigItem[],
rules: [] as InteractiveConfigItem[],
skills: [] as InteractiveConfigItem[],
hooks: [] as InteractiveConfigItem[],
agents: [] as InteractiveConfigItem[],
plugins: [] as InteractiveConfigItem[],
mcp: [] as InteractiveConfigItem[],
tools: [] as InteractiveConfigItem[],
workflowSlashCommands: [],
}),
[],
);
const openConfig = useCallback(
async (options: OpenConfigOptions = {}) => {
let keepOpen = true;
let activeTab = options.initialTab;
while (keepOpen) {
const [data, providerInfo] = await withLoadingDialog(
opts.dialog,
"Loading settings...",
async () =>
await Promise.all([
opts
.loadConfigData({ includePluginTools: false })
.catch(() => emptyConfigData),
Llms.getProvider(opts.config.providerId).catch(() => undefined),
]),
);
const providerDisplayName =
providerInfo?.name ?? opts.config.providerId;
const action = await opts.dialog.choice<ConfigAction>({
size: "large",
style: { maxHeight: opts.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<ConfigAction>) => (
<ConfigPanelContent
{...ctx}
config={opts.config}
configData={data}
loadConfigData={opts.loadConfigData}
providerDisplayName={providerDisplayName}
currentMode={opts.sessionUiMode}
currentCompactionMode={opts.compactionMode}
initialTab={activeTab}
onActiveTabChange={(tab) => {
activeTab = tab;
}}
onToggleConfigItem={opts.onToggleConfigItem}
onToggleMode={opts.toggleMode}
onToggleAutoApprove={opts.toggleAutoApprove}
onSetCompactionMode={opts.setCompactionMode}
/>
),
});
if (!action) {
keepOpen = false;
continue;
}
if (action.kind === "open-provider") {
await opts.openModelSelector({
startWithProviderChange: true,
onCancel: () => {},
});
} else if (action.kind === "open-model") {
await opts.openModelSelector({ onCancel: () => {} });
} else if (action.kind === "toggle-item") {
await opts.onToggleConfigItem?.(action.item);
} else if (action.kind === "ext-detail") {
await opts.dialog.choice<void>({
style: { maxHeight: opts.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<void>) => (
<ExtDetailContent
{...ctx}
item={action.item}
onToggleConfigItem={opts.onToggleConfigItem}
/>
),
});
} else if (action.kind === "open-mcp") {
const changed = await opts.openMcpManager({ refocus: false });
if (changed) {
keepOpen = false;
}
}
}
opts.refocusTextarea();
},
[opts, emptyConfigData],
);
return openConfig;
}
-114
View File
@@ -1,114 +0,0 @@
# Cline Hub
A browser dashboard for the local Cline hub. Open it to see who's connected, what sessions are running, drive a session from a chat box, and restart the hub when you need a fresh daemon.
## Capabilities
- live list of connected hub clients (from `HubUIClient.subscribeUI`)
- live list of active sessions with status, model, and titles
- click a session to view its message history and stream new assistant output
- start a new session from an initial prompt — workspace/provider/model are reused from the most recent session, or `CLINE_PROVIDER` / `CLINE_MODEL` env vars
- send messages to the selected session and watch chunks stream back
- **Restart Hub** button: gracefully stops the local detached hub and respawns a fresh one
- optional LAN/tunnel exposure gated by a shared `ROOM_SECRET`
The dashboard registers two clients with the hub: a `cline-hub-server` (via `ClineCore`) for driving sessions and a `cline-hub-server` (via `HubUIClient`) for the admin view.
## Run
```bash
cd apps/cline-hub
bun run start
```
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
For webview development with Vite hot reload:
```bash
cd apps/cline-hub
bun run dev
```
This starts the Vite webview server on <http://127.0.0.1:5173> and the hub dashboard on <http://127.0.0.1:8787>. Open the dashboard URL; the served page loads webview modules from Vite, so changes under `src/webview/src` hot reload without rebuilding. Use `CLINE_HUB_WEBVIEW_DEV_PORT` or `CLINE_HUB_WEBVIEW_DEV_HOST` to change the Vite bind address.
To start a brand-new session, the dashboard needs to know which provider and model to use. It picks them up automatically from the most recent session on the hub. If there are no recent sessions, set `CLINE_PROVIDER` and `CLINE_MODEL` in the environment before running.
## Configuration
Environment variables:
| Variable | Default | Description |
| --- | --- | --- |
| `HOST` | `127.0.0.1` | Bind host for the dashboard. Use the default for same-machine development. Set `HOST=0.0.0.0` only when intentionally exposing the dashboard on a LAN/tunnel. |
| `CLINE_HUB_DASHBOARD_PORT` | `8787` | Dashboard HTTP/WebSocket port. |
| `PUBLIC_URL` | `http://<HOST>:<PORT>` (`127.0.0.1` when binding `0.0.0.0`) | URL printed for humans to open/copy. Set this to your LAN URL or tunnel URL. |
| `ROOM_SECRET` | unset | Shared invite secret required for browser WebSocket connections when `HOST` is non-local. |
| `WORKSPACE_ROOT` | current directory | Workspace passed to the hub on startup. |
| `CLINE_PROVIDER` | unset | Fallback provider id when no recent session is available to copy from. |
| `CLINE_MODEL` | unset | Fallback model id when no recent session is available to copy from. |
The server prints both the bind URL and the public/invite URL at startup. When `ROOM_SECRET` is set, the printed invite URL includes `?roomSecret=...`; the browser UI also lets you paste the secret manually.
Validate option parsing without starting a server:
```bash
bun run smoke:options
```
## LAN usage
Choose a strong room secret and bind explicitly to all interfaces:
```bash
cd apps/cline-hub
HOST=0.0.0.0 \
CLINE_HUB_DASHBOARD_PORT=8787 \
PUBLIC_URL=http://YOUR_LAN_IP:8787 \
ROOM_SECRET='use-a-long-random-secret' \
bun run start
```
Share the printed invite URL with another machine on the same LAN.
`ROOM_SECRET` is required for `HOST=0.0.0.0`; without it the dashboard exits before listening.
## Tunnel usage
Start the dashboard locally with an explicit secret:
```bash
cd apps/cline-hub
ROOM_SECRET='use-a-long-random-secret' bun run start
```
In another terminal, expose the local port with your tunnel provider, for example:
```bash
ngrok http 8787
```
Restart the dashboard with the tunnel URL as `PUBLIC_URL` so the printed invite URL is copyable:
```bash
PUBLIC_URL=https://YOUR-TUNNEL.example \
ROOM_SECRET='use-a-long-random-secret' \
bun run start
```
Share only the printed invite URL with trusted participants.
## Restarting the hub
Clicking **Restart Hub** in the sidebar:
1. Detaches the dashboard's `ClineCore` and `HubUIClient` from the current hub.
2. Calls `stopLocalHubServerGracefully()` to shut the local detached hub down.
3. Calls `ensureDetachedHubServer(workspaceRoot)` to spawn a fresh hub.
4. Reconnects and broadcasts the new hub state to every open browser tab.
Sessions running on the previous hub are stopped along with the hub. Other clients connected to that hub (CLI, VS Code, menubar) will see their connection drop and reconnect to the new daemon on next request.
## Security warning
This is an example dashboard, not a production admin tool. Exposing it on a LAN or tunnel lets anyone with the invite secret list clients/sessions on your hub, drive sessions, and restart the hub. Use a long random `ROOM_SECRET`, only share the URL with trusted participants, and stop the process when you are done. The hub and agent runtime remain owned by the host machine.
-22
View File
@@ -1,22 +0,0 @@
{
"name": "@cline/cline-hub",
"version": "0.0.0",
"private": true,
"description": "Browser dashboard for the Cline hub: live clients, sessions, streaming chat, and hub restart.",
"type": "module",
"exports": {
".": "./src/server.ts"
},
"scripts": {
"build:webview": "bun run --cwd src/webview build",
"dev": "bun run src/dev.ts",
"start": "bun run src/server.ts",
"smoke:options": "bun run src/validate-options.ts",
"typecheck": "bun tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*"
}
}
-85
View File
@@ -1,85 +0,0 @@
import { join } from "node:path";
import process from "node:process";
const webviewHost =
process.env.CLINE_HUB_WEBVIEW_DEV_HOST?.trim() || "127.0.0.1";
const webviewPort = process.env.CLINE_HUB_WEBVIEW_DEV_PORT?.trim() || "5173";
const webviewDevServerUrl =
process.env.VITE_DEV_SERVER_URL?.trim() ||
`http://${webviewHost}:${webviewPort}`;
const cwd = process.cwd();
const webviewCwd = join(cwd, "src", "webview");
const children: Bun.Subprocess[] = [];
let shuttingDown = false;
function spawn(
name: string,
command: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
},
): Bun.Subprocess {
const child = Bun.spawn(command, {
...options,
stdout: "inherit",
stderr: "inherit",
});
children.push(child);
void child.exited.then((code) => {
if (!shuttingDown) {
console.error(`[cline-hub:dev] ${name} exited with code ${code}`);
shutdown(code === 0 ? 0 : 1);
}
});
return child;
}
function shutdown(exitCode = 0): void {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
try {
child.kill();
} catch {
// The process may have already exited.
}
}
process.exitCode = exitCode;
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
spawn(
"webview",
[
"bun",
"run",
"dev",
"--host",
webviewHost,
"--port",
webviewPort,
"--strictPort",
],
{
cwd: webviewCwd,
env: process.env,
},
);
spawn("server", ["bun", "run", "src/server.ts"], {
cwd,
env: {
...process.env,
VITE_DEV_SERVER_URL: webviewDevServerUrl,
},
});
await Promise.allSettled(children.map((child) => child.exited));
-96
View File
@@ -1,96 +0,0 @@
export interface ClineHubServerOptions {
host: string;
port: number;
publicUrl: string;
roomSecret?: string;
workspaceRoot: string;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
function parsePort(value: string | undefined): number {
if (!value?.trim()) return DEFAULT_PORT;
const port = Number.parseInt(value, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(
`${DASHBOARD_PORT_ENV} must be an integer from 1 to 65535, got ${value}`,
);
}
return port;
}
function normalizeHost(value: string | undefined): string {
return value?.trim() || DEFAULT_HOST;
}
function normalizePublicUrl(
value: string | undefined,
host: string,
port: number,
): string {
const fallbackHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
const raw = value?.trim() || `http://${fallbackHost}:${port}`;
let parsed: URL;
try {
parsed = new URL(raw);
} catch (error) {
throw new Error(
`PUBLIC_URL must be a valid http(s) URL, got ${raw}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
);
}
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}
function normalizeRoomSecret(value: string | undefined): string | undefined {
const secret = value?.trim();
return secret ? secret : undefined;
}
function isLocalBindHost(host: string): boolean {
return host === "127.0.0.1" || host === "localhost" || host === "::1";
}
export function isNonLocalBindHost(host: string): boolean {
return !isLocalBindHost(host);
}
export function resolveClineHubServerOptions(
env: NodeJS.ProcessEnv = process.env,
): ClineHubServerOptions {
const host = normalizeHost(env.HOST);
const port = parsePort(env[DASHBOARD_PORT_ENV]);
const publicUrl = normalizePublicUrl(env.PUBLIC_URL, host, port);
const roomSecret = normalizeRoomSecret(env.ROOM_SECRET);
if (isNonLocalBindHost(host) && !roomSecret) {
throw new Error(
`ROOM_SECRET is required when HOST=${host}. Use HOST=127.0.0.1 for local-only development or set ROOM_SECRET before exposing this example on a LAN/tunnel.`,
);
}
return {
host,
port,
publicUrl,
roomSecret,
workspaceRoot: env.WORKSPACE_ROOT?.trim() || process.cwd(),
};
}
export function buildInviteUrl(
publicUrl: string,
roomSecret: string | undefined,
): string {
if (!roomSecret) return publicUrl;
const url = new URL(publicUrl);
url.searchParams.set("roomSecret", roomSecret);
return url.toString();
}
-255
View File
@@ -1,255 +0,0 @@
import { CORE_BUILD_VERSION } from "@cline/core";
import { isNonLocalBindHost } from "./options";
import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
} from "./server/approvals";
import {
browserConfig,
host,
inviteUrl,
port,
publicUrl,
roomSecret,
webviewDistDir,
} from "./server/deps";
import { handleDesktopCommand } from "./server/desktop-commands";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
attachHub,
detachHub,
restartHub,
syncHubClientsAndSessions,
syncHubHealth,
} from "./server/hub";
import {
loadModels,
runProviderOAuthLogin,
saveProviderSettings,
sendProviderCatalog,
} from "./server/providers";
import {
abortPeerTurn,
deleteSession,
forkPeerSession,
initializePeer,
resetPeer,
restorePeerSession,
selectSession,
sendMessage,
} from "./server/sessions";
import { HubContext } from "./server/state";
import { broadcastHubState, hubStatusPayload } from "./server/state-payloads";
import type { BrowserFrame, BrowserPeer } from "./server/types";
export interface ClineHubDashboardServer {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
bindHost: string;
inviteRequired: boolean;
hubUrl: string | undefined;
stop: () => Promise<void>;
}
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
await attachHub(ctx);
const healthInterval = setInterval(() => {
void (async () => {
await syncHubHealth(ctx);
broadcastHubState(ctx);
})();
}, 5_000);
const server = Bun.serve<BrowserPeer>({
port,
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
if (url.pathname === "/health") {
await syncHubHealth(ctx);
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
displayName,
sending: false,
};
if (server.upgrade(req, { data })) return undefined;
return new Response("upgrade failed", { status: 400 });
}
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
return assets.serve(url.pathname);
},
websocket: {
async open(socket) {
const peer = socket.data;
peer.socket = socket;
ctx.peers.add(peer);
},
async message(socket, raw) {
const peer = socket.data;
try {
const frame = JSON.parse(String(raw)) as BrowserFrame;
if (frame.type === "desktopCommand") {
try {
const result = await handleDesktopCommand(
ctx,
frame.command,
frame.args,
);
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: true,
result,
});
} catch (error) {
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else if (frame.type === "ready") {
await initializePeer(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "loadModels") {
await loadModels(ctx, peer, frame.providerId);
} else if (frame.type === "loadProviderCatalog") {
await sendProviderCatalog(ctx, peer);
} else if (frame.type === "saveProviderSettings") {
await saveProviderSettings(ctx, peer, frame);
} else if (frame.type === "runProviderOAuthLogin") {
await runProviderOAuthLogin(ctx, peer, frame.providerId);
} else if (frame.type === "attachSession") {
await selectSession(ctx, peer, frame.sessionId);
} else if (frame.type === "deleteSession") {
await deleteSession(ctx, peer, frame.sessionId);
} else if (frame.type === "updateSessionMetadata") {
if (!ctx.cline) throw new Error("Hub is not connected.");
const session = await ctx.cline.get(frame.sessionId);
const metadata =
session?.metadata && typeof session.metadata === "object"
? (session.metadata as Record<string, unknown>)
: {};
await ctx.cline.update(frame.sessionId, {
metadata: { ...metadata, ...frame.metadata },
});
await syncHubClientsAndSessions(ctx);
broadcastHubState(ctx);
} else if (frame.type === "approval_response") {
handleToolApprovalResponse(ctx, frame);
} else if (frame.type === "abort") {
await abortPeerTurn(ctx, peer);
} else if (frame.type === "reset") {
await resetPeer(ctx, peer);
} else if (frame.type === "send") {
if (peer.sending) {
ctx.send(peer, {
type: "status",
text: "A turn is already in progress.",
});
return;
}
peer.sending = true;
try {
await sendMessage(
ctx,
peer,
frame.prompt,
frame.config,
frame.attachments,
);
} finally {
peer.sending = false;
}
} else if (frame.type === "forkSession") {
await forkPeerSession(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "restore") {
await restorePeerSession(
ctx,
peer,
frame.checkpointRunCount,
syncClientsAndSessions,
);
} else if (frame.type === "restart_hub") {
await restartHub(ctx);
}
} catch (error) {
ctx.send(peer, {
type: "error",
text: error instanceof Error ? error.message : String(error),
});
}
},
close(socket) {
const peer = socket.data;
peer.unsubscribeEvents?.();
ctx.peers.delete(peer);
rejectOrphanedApprovals(ctx);
},
},
});
return {
listenUrl: server.url.toString(),
publicUrl,
inviteUrl,
bindHost: host,
inviteRequired: Boolean(roomSecret),
hubUrl: ctx.hubUrl,
stop: async () => {
if (stopped) return;
stopped = true;
clearInterval(healthInterval);
try {
server.stop(true);
} finally {
await detachHub(ctx);
}
},
};
}
export function printClineHubDashboardServerInfo(
server: ClineHubDashboardServer,
): void {
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
console.log(`Cline Hub public URL: ${server.publicUrl}`);
console.log(`hub endpoint: ${server.hubUrl}`);
if (server.inviteRequired) {
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
} else if (isNonLocalBindHost(server.bindHost)) {
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
} else {
console.log(
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
);
}
}
if (import.meta.main) {
const server = await startClineHubDashboardServer();
printClineHubDashboardServerInfo(server);
}
-180
View File
@@ -1,180 +0,0 @@
import type { CoreSessionEvent } from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import type { WebviewToolEvent } from "../webview-protocol";
import { rejectPendingApprovalsForSession } from "./approvals";
import type { HubContext } from "./state";
import { broadcastHubState } from "./state-payloads";
import { asString, chunkText } from "./utils";
function agentEventText(event: AgentEvent): string {
if (
event.type === "content_start" &&
event.contentType === "text" &&
typeof event.text === "string"
) {
return event.text;
}
return "";
}
function sendChunkToSelectedPeers(
ctx: HubContext,
sessionId: string,
text: string,
): void {
if (!text) return;
ctx.sendToSelectedPeers(sessionId, { type: "assistant_delta", text });
}
function forwardAgentEvent(
ctx: HubContext,
sessionId: string,
event: AgentEvent,
): void {
if (event.type === "content_start") {
if (event.contentType === "reasoning") {
ctx.sendToSelectedPeers(sessionId, {
type: "reasoning_delta",
text: event.reasoning ?? event.text ?? "",
redacted: event.redacted,
});
return;
}
if (event.contentType === "tool") {
ctx.sendToSelectedPeers(sessionId, {
type: "tool_event",
text: `Running ${event.toolName ?? "tool"}...`,
event: {
toolCallId: event.toolCallId,
toolName: event.toolName,
status: "running",
input: event.input,
},
});
return;
}
const text = agentEventText(event);
if (text) sendChunkToSelectedPeers(ctx, sessionId, text);
return;
}
if (event.type === "content_update" && event.contentType === "tool") {
const toolEvent: WebviewToolEvent = {
toolCallId: event.toolCallId,
toolName: event.toolName,
status: "running",
output: event.update,
};
ctx.sendToSelectedPeers(sessionId, {
type: "tool_event",
text: `${event.toolName ?? "tool"} updated`,
event: toolEvent,
});
return;
}
if (event.type === "content_end") {
if (event.contentType === "reasoning") {
ctx.sendToSelectedPeers(sessionId, {
type: "reasoning_delta",
text: event.reasoning ?? event.text ?? "",
});
return;
}
if (event.contentType === "tool") {
const toolName = event.toolName ?? "tool";
ctx.sendToSelectedPeers(sessionId, {
type: "tool_event",
text: event.error
? `${toolName} failed: ${event.error}`
: `${toolName} completed`,
event: {
toolCallId: event.toolCallId,
toolName,
status: event.error ? "failed" : "completed",
output: event.output,
error: event.error,
},
});
}
return;
}
if (event.type === "notice") {
ctx.sendToSelectedPeers(sessionId, { type: "status", text: event.message });
return;
}
if (event.type === "done") {
ctx.sendToSelectedPeers(sessionId, {
type: "turn_done",
finishReason: event.reason,
iterations: event.iterations,
usage: event.usage
? {
inputTokens: event.usage.inputTokens,
outputTokens: event.usage.outputTokens,
cacheCreationInputTokens: event.usage.cacheWriteTokens,
cacheReadInputTokens: event.usage.cacheReadTokens,
totalCost: event.usage.totalCost,
}
: undefined,
});
return;
}
if (event.type === "error") {
ctx.sendToSelectedPeers(sessionId, {
type: "error",
text: event.error.message,
});
}
}
export function handleSessionEvent(
ctx: HubContext,
event: CoreSessionEvent,
): void {
const payload = event.payload as Record<string, unknown> | undefined;
const sessionId = asString(payload?.sessionId);
if (!sessionId) return;
if (event.type === "chunk") {
const text = chunkText((payload as Record<string, unknown>).chunk);
sendChunkToSelectedPeers(ctx, sessionId, text);
} else if (event.type === "agent_event") {
if (event.payload.teamRole === "teammate") return;
forwardAgentEvent(ctx, sessionId, event.payload.event);
} else if (event.type === "status") {
const status = asString((payload as Record<string, unknown>).status);
const tracked = ctx.sessions.get(sessionId);
if (tracked && status) {
tracked.status = status;
tracked.updatedAt = Date.now();
}
for (const peer of ctx.peers) {
if (peer.selectedSessionId === sessionId) {
ctx.send(peer, {
type: "status",
text: status ?? "Session status changed.",
});
}
}
broadcastHubState(ctx);
} else if (event.type === "ended") {
rejectPendingApprovalsForSession(
ctx,
sessionId,
"Session ended before approval was resolved.",
);
const tracked = ctx.sessions.get(sessionId);
if (tracked) {
tracked.status = "completed";
tracked.updatedAt = Date.now();
}
for (const peer of ctx.peers) {
if (peer.selectedSessionId === sessionId) {
ctx.send(peer, {
type: "turn_done",
finishReason: event.payload.reason,
iterations: 0,
});
}
}
broadcastHubState(ctx);
}
}
-122
View File
@@ -1,122 +0,0 @@
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import type { WebviewInboundMessage } from "../webview-protocol";
import type { HubContext } from "./state";
import { broadcastHubState } from "./state-payloads";
function createApprovalId(): string {
return `approval-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function resolveToolApproval(
ctx: HubContext,
approvalId: string,
result: ToolApprovalResult,
): boolean {
const pending = ctx.pendingToolApprovals.get(approvalId);
if (!pending) return false;
clearTimeout(pending.timeout);
ctx.pendingToolApprovals.delete(approvalId);
ctx.sendToSelectedPeers(pending.sessionId, {
type: "approval_resolved",
approvalId,
approved: result.approved,
reason: result.reason,
});
pending.resolve(result);
return true;
}
export function rejectPendingApprovalsForSession(
ctx: HubContext,
sessionId: string,
reason: string,
): void {
for (const [approvalId, pending] of [...ctx.pendingToolApprovals.entries()]) {
if (pending.sessionId === sessionId) {
resolveToolApproval(ctx, approvalId, { approved: false, reason });
}
}
}
export function rejectAllPendingApprovals(
ctx: HubContext,
reason: string,
): void {
for (const approvalId of [...ctx.pendingToolApprovals.keys()]) {
resolveToolApproval(ctx, approvalId, { approved: false, reason });
}
}
export function rejectOrphanedApprovals(ctx: HubContext): void {
for (const [approvalId, pending] of [...ctx.pendingToolApprovals.entries()]) {
if (!ctx.hasSelectedPeer(pending.sessionId)) {
resolveToolApproval(ctx, approvalId, {
approved: false,
reason: "Cline Hub webview disconnected before approval was resolved.",
});
}
}
}
export function requestToolApprovalFromWebview(
ctx: HubContext,
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
if (!ctx.hasSelectedPeer(request.sessionId)) {
return Promise.resolve({
approved: false,
reason: "No Cline Hub webview is attached to this session.",
});
}
const approvalId = createApprovalId();
ctx.pushEvent(
"Tool approval requested",
`${request.toolName} is waiting for approval`,
"warn",
);
broadcastHubState(ctx);
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolveToolApproval(ctx, approvalId, {
approved: false,
reason: "Tool approval request timed out.",
});
}, 10 * 60_000);
ctx.pendingToolApprovals.set(approvalId, {
sessionId: request.sessionId,
resolve,
timeout,
});
ctx.sendToSelectedPeers(request.sessionId, {
type: "approval_request",
approvalId,
sessionId: request.sessionId,
agentId: request.agentId,
conversationId: request.conversationId,
iteration: request.iteration,
toolCallId: request.toolCallId,
toolName: request.toolName,
input: request.input,
policy: request.policy as Record<string, unknown> | undefined,
});
});
}
export function handleToolApprovalResponse(
ctx: HubContext,
frame: Extract<WebviewInboundMessage, { type: "approval_response" }>,
): void {
const approvalId = frame.approvalId.trim();
if (!approvalId) return;
const resolved = resolveToolApproval(ctx, approvalId, {
approved: frame.approved,
reason:
frame.reason ??
(frame.approved ? "Approved in Cline Hub." : "Rejected in Cline Hub."),
});
if (!resolved) {
console.warn(`Ignoring unknown tool approval response: ${approvalId}`);
}
}
-178
View File
@@ -1,178 +0,0 @@
import { spawn } from "node:child_process";
import process from "node:process";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { PLATFORMS } from "../../../cli/src/wizards/connect/platforms";
import type {
WebviewConnectorChannel,
WebviewConnectorChannelsResponse,
} from "../webview-protocol";
import { cliIndexPath, workspaceRoot } from "./deps";
import { asRecord, asString } from "./utils";
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
return { available, active: listActiveConnectors() };
}
async function runCliConnectCommand(args: string[]): Promise<{
code: number;
stdout: string;
stderr: string;
}> {
const launcher = (process.versions as Record<string, string | undefined>).bun
? process.execPath
: "bun";
const child = spawn(
launcher,
["--conditions=development", cliIndexPath, "connect", ...args],
{
cwd: workspaceRoot,
env: {
...process.env,
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode) => resolve(exitCode ?? 0));
});
return { code, stdout, stderr };
}
async function waitForConnectorState(
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const cliArgs = [channel];
for (const field of platform.fields) {
const value = asString(values[field.flag]);
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(
"--hook-command",
platform.security.buildHookCommand(hookValues),
);
}
return cliArgs;
}
export async function startConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
-26
View File
@@ -1,26 +0,0 @@
import { dirname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { ProviderSettingsManager } from "@cline/core";
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
import type { BrowserConfig } from "./types";
export const options = resolveClineHubServerOptions();
export const { host, port, publicUrl, roomSecret, workspaceRoot } = options;
export const inviteUrl = buildInviteUrl(publicUrl, roomSecret);
const serverDir = dirname(fileURLToPath(import.meta.url));
/** server.ts lives one level up from this module, so resolve relative to it. */
export const appSrcDir = join(serverDir, "..");
export const webviewDistDir =
process.env.CLINE_HUB_WEBVIEW_DIST_DIR?.trim() ||
join(appSrcDir, "../dist/webview");
export const cliIndexPath = normalize(
join(appSrcDir, "../../cli/src/index.ts"),
);
export const providerSettingsManager = new ProviderSettingsManager();
export const browserConfig: BrowserConfig = {
inviteRequired: Boolean(roomSecret),
publicUrl,
};
@@ -1,238 +0,0 @@
import {
addLocalProvider,
type ClineAccountActionRequest,
ClineAccountService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
getLocalProviderModels,
listLocalProviders,
loginLocalProvider,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
type ProviderProtocol,
readGlobalSettings,
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
deleteMcpServer,
ensureMcpSettingsFile,
readMcpServersResponse,
setMcpServerDisabled,
upsertMcpServer,
} from "./mcp";
import { handleRoutineScheduleCommand } from "./schedules";
import { toWebviewSessionSummary } from "./session-mapping";
import type { HubContext } from "./state";
import { broadcastHubState } from "./state-payloads";
import type { JsonRecord } from "./types";
import { listUserInstructionConfigs } from "./user-instructions";
import { openExternalUrl, readProviderSettingsUpdate } from "./utils";
const ROUTINE_SCHEDULE_COMMANDS = new Set([
"list_routine_schedules",
"create_routine_schedule",
"update_routine_schedule",
"pause_routine_schedule",
"resume_routine_schedule",
"trigger_routine_schedule",
"delete_routine_schedule",
]);
export async function handleDesktopCommand(
ctx: HubContext,
command: string,
args?: Record<string, unknown>,
): Promise<unknown> {
if (command === "list_provider_catalog") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager);
}
if (command === "list_provider_models") {
const provider = String(args?.provider ?? "").trim();
return await getLocalProviderModels(
provider,
providerSettingsManager.getProviderConfig(provider),
);
}
if (command === "save_provider_settings") {
return saveLocalProviderSettings(providerSettingsManager, {
...readProviderSettingsUpdate(args),
providerId: String(args?.provider ?? ""),
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
}
if (command === "add_provider") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await addLocalProvider(providerSettingsManager, {
providerId: String(args?.provider_id ?? ""),
name: String(args?.name ?? ""),
baseUrl: String(args?.base_url ?? ""),
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
headers:
args?.headers && typeof args.headers === "object"
? (args.headers as Record<string, string>)
: undefined,
timeoutMs:
typeof args?.timeout_ms === "number" ? args.timeout_ms : undefined,
models: Array.isArray(args?.models)
? (args.models as string[])
: undefined,
defaultModelId:
typeof args?.default_model_id === "string"
? args.default_model_id
: undefined,
modelsSourceUrl:
typeof args?.models_source_url === "string"
? args.models_source_url
: undefined,
protocol:
typeof args?.protocol === "string"
? (args.protocol as ProviderProtocol)
: undefined,
client:
typeof args?.client === "string"
? (args.client as ProviderClient)
: undefined,
capabilities: Array.isArray(args?.capabilities)
? (args.capabilities as ProviderCapability[])
: undefined,
});
}
if (command === "run_provider_oauth_login") {
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
const existing = providerSettingsManager.getProviderSettings(providerId);
const credentials = await loginLocalProvider(
providerId,
existing,
openExternalUrl,
);
const saved = saveLocalProviderOAuthCredentials(
providerSettingsManager,
providerId,
existing,
credentials,
);
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
}
if (command === "cline_account") {
const settings = providerSettingsManager.getProviderSettings("cline");
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
accountService,
);
}
if (command === "get_global_settings") {
return readGlobalSettings();
}
if (command === "set_telemetry_opt_out") {
if (typeof args?.telemetry_opt_out !== "boolean") {
throw new Error("telemetry_opt_out must be a boolean");
}
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
if (command === "start_connector_channel") {
const response = await startConnectorChannel(args);
broadcastHubState(ctx);
return response;
}
if (command === "stop_connector_channel") {
const response = await stopConnectorChannel(args);
broadcastHubState(ctx);
return response;
}
if (command === "list_mcp_servers") {
return readMcpServersResponse();
}
if (command === "set_mcp_server_disabled") {
return setMcpServerDisabled(
String(args?.name ?? "").trim(),
Boolean(args?.disabled),
);
}
if (command === "upsert_mcp_server") {
const input =
args?.input && typeof args.input === "object"
? (args.input as JsonRecord)
: ((args ?? {}) as JsonRecord);
return upsertMcpServer(input);
}
if (command === "delete_mcp_server") {
return deleteMcpServer(String(args?.name ?? "").trim());
}
if (command === "ensure_mcp_settings_file") {
return ensureMcpSettingsFile();
}
if (command === "open_mcp_settings_file") {
const path = ensureMcpSettingsFile();
openExternalUrl(path);
return path;
}
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
return await handleRoutineScheduleCommand(command, args);
}
if (command === "get_process_context") {
return { workspaceRoot, cwd: workspaceRoot };
}
if (
command === "list_cli_sessions" ||
command === "list_discovered_sessions"
) {
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
}
if (command === "read_session_hooks") {
return [];
}
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
toggleDisabledTool(toolName);
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "set_tool_disabled") {
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
const toolNames = rawNames
.map((name) => String(name ?? "").trim())
.filter(Boolean);
if (toolNames.length === 0) throw new Error("tool name is required");
setDisabledTools(toolNames, args?.disabled === true);
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);
return await listUserInstructionConfigs(workspaceRoot);
}
throw new Error(`unsupported desktop command: ${command}`);
}
-123
View File
@@ -1,123 +0,0 @@
import { extname, join, normalize, relative } from "node:path";
import process from "node:process";
export function createJsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
export function createTextResponse(text: string, status = 200): Response {
return new Response(text, {
status,
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
function contentTypeFor(path: string): string {
switch (extname(path)) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".ico":
return "image/x-icon";
case ".woff2":
return "font/woff2";
default:
return "application/octet-stream";
}
}
function isWebviewRoute(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/index.html" ||
pathname === "/chat" ||
pathname === "/settings" ||
pathname.startsWith("/settings/")
);
}
function renderDevIndexHtml(devServerUrl: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module">
import RefreshRuntime from "${devServerUrl}/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
<title>Cline Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="${devServerUrl}/src/main.tsx"></script>
</body>
</html>`;
}
/** Serves the built webview SPA and its static assets out of `webviewDistDir`. */
export class WebviewAssets {
constructor(private readonly webviewDistDir: string) {}
private resolveStaticPath(pathname: string): string | undefined {
const decoded = decodeURIComponent(pathname);
const requested = decoded === "/" ? "/index.html" : decoded;
const normalized = normalize(requested).replace(/^(\.\.[/\\])+/, "");
const relativePath = normalized.replace(/^[/\\]+/, "");
const filePath = join(this.webviewDistDir, relativePath);
if (relative(this.webviewDistDir, filePath).startsWith("..")) {
return undefined;
}
return filePath;
}
private async serveIndex(): Promise<Response> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return createTextResponse(
"Cline Hub webview is not built. Run `bun run build:webview` from apps/cline-hub.",
503,
);
}
async serve(pathname: string): Promise<Response> {
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
if (devServerUrl && isWebviewRoute(pathname)) {
return new Response(renderDevIndexHtml(devServerUrl), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
if (isWebviewRoute(pathname)) {
return this.serveIndex();
}
const filePath = this.resolveStaticPath(pathname);
if (!filePath) return createTextResponse("not found", 404);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return createTextResponse("not found", 404);
}
return new Response(file, {
headers: { "content-type": contentTypeFor(filePath) },
});
}
}
-273
View File
@@ -1,273 +0,0 @@
import {
ClineCore,
ensureDetachedHubServer,
type HubServerDiscoveryRecord,
HubUIClient,
stopLocalHubServerGracefully,
toHubHealthUrl,
} from "@cline/core";
import type { HubUINotifyPayload } from "@cline/shared";
import { handleSessionEvent } from "./agent-events";
import {
rejectAllPendingApprovals,
requestToolApprovalFromWebview,
} from "./approvals";
import { workspaceRoot } from "./deps";
import {
formatClientName,
formatSessionCreator,
parseSessionContext,
trackSession,
} from "./session-mapping";
import type { HubContext } from "./state";
import { broadcastHubState } from "./state-payloads";
import type { SessionContext } from "./types";
import { asString, basename, isActiveSession, isVisibleClient } from "./utils";
export async function syncHubHealth(ctx: HubContext): Promise<void> {
if (!ctx.hubUrl) {
ctx.hubHealthy = false;
return;
}
try {
const response = await fetch(toHubHealthUrl(ctx.hubUrl));
if (!response.ok) {
ctx.hubHealthy = false;
return;
}
ctx.hubHealthy = true;
const health = (await response.json()) as Partial<HubServerDiscoveryRecord>;
if (typeof health.startedAt === "string")
ctx.hubStartedAt = health.startedAt;
if (typeof health.coreVersion === "string") {
ctx.coreVersion = health.coreVersion;
}
} catch {
ctx.hubHealthy = false;
// best-effort
}
}
export async function syncHubClientsAndSessions(
ctx: HubContext,
): Promise<void> {
if (!ctx.uiClient) return;
const [knownClients, knownSessions] = await Promise.all([
ctx.uiClient.listClients(),
ctx.uiClient.listSessions(10),
]);
ctx.clients.clear();
for (const client of knownClients) {
if (!client.clientId || !isVisibleClient(client.clientType)) continue;
ctx.clients.set(client.clientId, {
clientId: client.clientId,
displayName: client.displayName,
clientType: client.clientType,
connectedAt: client.connectedAt,
});
}
ctx.sessions.clear();
for (const session of knownSessions) {
const tracked = trackSession(session);
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
}
if (!ctx.initialHubEventEmitted) {
const activeSessionCount = [...ctx.sessions.values()].filter((session) =>
isActiveSession(session.title, session.status, session.participantCount),
).length;
ctx.pushEvent(
"Hub monitor connected",
`${ctx.clients.size} connected client${ctx.clients.size === 1 ? "" : "s"}, ${activeSessionCount} active session${activeSessionCount === 1 ? "" : "s"}`,
"success",
);
ctx.initialHubEventEmitted = true;
}
const mostRecent = [...knownSessions]
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((s) => parseSessionContext(s))
.find((c): c is SessionContext => Boolean(c));
if (mostRecent) ctx.lastSessionContext = mostRecent;
}
export async function attachHub(ctx: HubContext): Promise<void> {
const hub = await ensureDetachedHubServer(workspaceRoot);
ctx.hubUrl = hub.url;
ctx.hubAuthToken = hub.authToken;
ctx.cline = await ClineCore.create({
clientName: "cline-hub",
backendMode: "hub",
capabilities: {
requestToolApproval: (request) =>
requestToolApprovalFromWebview(ctx, request),
},
hub: {
endpoint: ctx.hubUrl,
authToken: ctx.hubAuthToken,
clientType: "cline-hub-chat",
displayName: "Cline Hub Chat",
workspaceRoot,
},
});
ctx.uiClient = new HubUIClient({
address: ctx.hubUrl,
authToken: ctx.hubAuthToken,
clientType: "cline-hub-server",
displayName: "Cline Hub Server",
});
await ctx.uiClient.connect();
ctx.uiClient.subscribeUI({
onNotify(payload: HubUINotifyPayload) {
ctx.pushEvent(
payload.title,
payload.body,
payload.severity === "error"
? "error"
: payload.severity === "warning"
? "warn"
: "info",
);
ctx.broadcast({
type: "notification",
title: payload.title,
body: payload.body,
severity: payload.severity ?? "info",
});
},
onClientRegistered(payload) {
const clientId = asString(payload.clientId);
const clientType = asString(payload.clientType) ?? "unknown";
if (!clientId || !isVisibleClient(clientType)) return;
ctx.clients.set(clientId, {
clientId,
displayName: asString(payload.displayName),
clientType,
connectedAt: Date.now(),
});
ctx.pushEvent(
"Client connected",
`${asString(payload.displayName) ?? clientType} joined the hub`,
"success",
);
broadcastHubState(ctx);
},
onClientDisconnected(payload) {
const clientId = asString(payload.clientId);
if (!clientId) return;
const client = ctx.clients.get(clientId);
ctx.clients.delete(clientId);
if (client) {
ctx.pushEvent(
"Client disconnected",
`${formatClientName(client)} left the hub`,
"info",
);
}
broadcastHubState(ctx);
},
onSessionCreated(payload) {
const record =
payload.session && typeof payload.session === "object"
? (payload.session as Record<string, unknown>)
: (payload as unknown as Record<string, unknown>);
const tracked = trackSession(record);
if (tracked) {
ctx.sessions.set(tracked.sessionId, tracked);
const context = parseSessionContext(record);
if (context) ctx.lastSessionContext = context;
ctx.pushEvent(
"Session started",
`By ${formatSessionCreator(ctx, tracked)} at ${basename(tracked.workspaceRoot || tracked.cwd)}`,
"success",
);
broadcastHubState(ctx);
}
},
onSessionUpdated(payload) {
const record =
payload.session && typeof payload.session === "object"
? (payload.session as Record<string, unknown>)
: (payload as unknown as Record<string, unknown>);
const tracked = trackSession(record);
if (tracked) {
ctx.sessions.set(tracked.sessionId, tracked);
const context = parseSessionContext(record);
if (context) ctx.lastSessionContext = context;
broadcastHubState(ctx);
}
},
onSessionDetached(payload) {
const sessionId =
asString((payload as Record<string, unknown>).sessionId) ??
asString(
(
(payload as Record<string, unknown>).session as
| Record<string, unknown>
| undefined
)?.sessionId,
);
if (sessionId) {
ctx.sessions.delete(sessionId);
broadcastHubState(ctx);
}
},
});
ctx.cline.subscribe((event) => handleSessionEvent(ctx, event));
await syncHubClientsAndSessions(ctx);
await syncHubHealth(ctx);
}
export async function detachHub(ctx: HubContext): Promise<void> {
rejectAllPendingApprovals(
ctx,
"Hub disconnected before approval was resolved.",
);
for (const peer of ctx.peers) {
peer.unsubscribeEvents?.();
peer.unsubscribeEvents = undefined;
}
try {
ctx.uiClient?.close();
} catch {
// ignore
}
ctx.uiClient = undefined;
try {
await ctx.cline?.dispose();
} catch {
// ignore
}
ctx.cline = undefined;
ctx.clients.clear();
ctx.sessions.clear();
ctx.hubStartedAt = undefined;
ctx.coreVersion = undefined;
ctx.initialHubEventEmitted = false;
}
export async function restartHub(ctx: HubContext): Promise<void> {
ctx.broadcast({
type: "notification",
title: "Hub restarting",
body: "Shutting down and respawning hub...",
severity: "warn",
});
await detachHub(ctx);
try {
await stopLocalHubServerGracefully();
} catch (error) {
console.warn("stopLocalHubServerGracefully failed:", error);
}
await attachHub(ctx);
broadcastHubState(ctx);
ctx.broadcast({
type: "notification",
title: "Hub restarted",
body: `Connected to ${ctx.hubUrl}`,
severity: "info",
});
}
-145
View File
@@ -1,145 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
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;
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "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 };
}
export function writeMcpServersMap(servers: JsonRecord): void {
const path = resolveMcpSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
}
export function ensureMcpSettingsFile(): string {
const path = resolveMcpSettingsPath();
if (!existsSync(path)) {
writeMcpServersMap({});
}
return path;
}
function readServersMap(): { path: string; servers: JsonRecord } {
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
return { path, servers: (parsed.mcpServers as JsonRecord | undefined) ?? {} };
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
const { servers } = readServersMap();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
writeMcpServersMap(servers);
return readMcpServersResponse();
}
export function upsertMcpServer(input: JsonRecord): JsonRecord {
const name = String(input.name ?? "").trim();
if (!name) throw new Error("server name is required");
const previousName = String(
input.previousName ?? input.previous_name ?? "",
).trim();
const transportType = String(
input.transportType ?? input.transport_type ?? "",
).trim();
const next: JsonRecord =
transportType === "stdio"
? {
transport: {
type: "stdio",
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
},
disabled: input.disabled === true,
}
: {
transport: {
type: transportType === "sse" ? "sse" : "streamableHttp",
url: input.url,
headers: input.headers,
},
disabled: input.disabled === true,
};
const { servers } = readServersMap();
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
writeMcpServersMap(servers);
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
const { servers } = readServersMap();
delete servers[name];
writeMcpServersMap(servers);
return readMcpServersResponse();
}
-158
View File
@@ -1,158 +0,0 @@
import process from "node:process";
import {
ensureCustomProvidersLoaded,
getLocalProviderModels,
Llms,
listLocalProviders,
loginLocalProvider,
normalizeOAuthProvider,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
} from "@cline/core";
import type {
WebviewInboundMessage,
WebviewProviderModel,
} from "../webview-protocol";
import { providerSettingsManager, workspaceRoot } from "./deps";
import type { HubContext } from "./state";
import type { BrowserPeer } from "./types";
import { openExternalUrl } from "./utils";
export function resolveBrowserDefaults(ctx: HubContext): {
provider?: string;
model?: string;
workspaceRoot: string;
cwd: string;
} {
const lastUsed = providerSettingsManager.getLastUsedProviderSettings();
return {
provider:
lastUsed?.provider ??
ctx.lastSessionContext?.providerId ??
process.env.CLINE_PROVIDER?.trim(),
model:
lastUsed?.model ??
ctx.lastSessionContext?.modelId ??
process.env.CLINE_MODEL?.trim(),
workspaceRoot: ctx.lastSessionContext?.workspaceRoot ?? workspaceRoot,
cwd:
ctx.lastSessionContext?.cwd ??
ctx.lastSessionContext?.workspaceRoot ??
workspaceRoot,
};
}
export async function loadProviders(
ctx: HubContext,
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const state = providerSettingsManager.read();
const defaults = resolveBrowserDefaults(ctx);
const ids = Llms.getProviderIds().sort((a, b) => a.localeCompare(b));
const providers = (
await Promise.all(
ids.map(async (id) => {
const info = await Llms.getProvider(id);
const enabled =
Boolean(state.providers[id]?.settings) || id === defaults.provider;
return {
id,
name: info?.name ?? id,
enabled,
defaultModelId: info?.defaultModelId,
};
}),
)
).filter((provider) => provider.enabled);
ctx.send(peer, { type: "providers", providers });
const selected =
(defaults.provider &&
providers.find((provider) => provider.id === defaults.provider)) ||
providers[0];
if (selected) {
await loadModels(ctx, peer, selected.id);
}
}
export async function loadModels(
ctx: HubContext,
peer: BrowserPeer,
providerId: string,
): Promise<void> {
const provider = providerId.trim();
if (!provider) return;
const payload = await getLocalProviderModels(
provider,
providerSettingsManager.getProviderConfig(provider),
);
const models: WebviewProviderModel[] = payload.models.map((model) => ({
id: model.id,
name: model.name,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
}));
ctx.send(peer, { type: "models", providerId: provider, models });
}
export async function sendProviderCatalog(
ctx: HubContext,
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager);
ctx.send(peer, {
type: "provider_catalog",
providers: payload.providers,
settingsPath: payload.settingsPath,
});
}
export async function saveProviderSettings(
ctx: HubContext,
peer: BrowserPeer,
frame: Extract<WebviewInboundMessage, { type: "saveProviderSettings" }>,
): Promise<void> {
const result = saveLocalProviderSettings(providerSettingsManager, {
providerId: frame.providerId,
enabled: frame.enabled,
apiKey: frame.apiKey,
baseUrl: frame.baseUrl,
});
ctx.send(peer, {
type: "provider_settings_saved",
providerId: result.providerId,
enabled: result.enabled,
});
await sendProviderCatalog(ctx, peer);
await loadProviders(ctx, peer);
}
export async function runProviderOAuthLogin(
ctx: HubContext,
peer: BrowserPeer,
providerId: string,
): Promise<void> {
const normalized = normalizeOAuthProvider(providerId);
const existing = providerSettingsManager.getProviderSettings(normalized);
const credentials = await loginLocalProvider(
normalized,
existing,
openExternalUrl,
);
const saved = saveLocalProviderOAuthCredentials(
providerSettingsManager,
normalized,
existing,
credentials,
);
ctx.send(peer, {
type: "provider_oauth_login_done",
providerId: normalized,
accessTokenPresent:
(saved.auth?.accessToken?.trim() ?? saved.apiKey?.trim() ?? "").length >
0,
});
await sendProviderCatalog(ctx, peer);
await loadProviders(ctx, peer);
}
-182
View File
@@ -1,182 +0,0 @@
import {
createLocalHubScheduleRuntimeHandlers,
HubScheduleCommandService,
HubScheduleService,
} from "@cline/core";
import { asTrimmedString, toPositiveInt } from "./utils";
let scheduleService: HubScheduleService | undefined;
let scheduleCommands: HubScheduleCommandService | undefined;
function getCommands(): HubScheduleCommandService {
if (!scheduleService || !scheduleCommands) {
scheduleService = new HubScheduleService({
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
scheduleCommands = new HubScheduleCommandService(scheduleService);
}
return scheduleCommands;
}
async function clientCommand(
hubCommand: string,
payload?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const reply = await getCommands().handleCommand({
version: "v1",
clientId: "cline-hub-schedules",
command: hubCommand as never,
payload,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? `hub command failed: ${hubCommand}`,
);
}
return (reply.payload ?? {}) as Record<string, unknown>;
}
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
): Promise<unknown> {
if (command === "list_routine_schedules") {
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
clientCommand("schedule.list", {
limit: toPositiveInt(args?.limit) ?? 200,
}),
clientCommand("schedule.active"),
clientCommand("schedule.upcoming", { limit: 30 }),
]);
const scheduleRows = Array.isArray(schedules.schedules)
? schedules.schedules
: [];
const lastExecutions = await Promise.all(
scheduleRows.map(async (schedule) => {
const scheduleId = asTrimmedString(
(schedule as Record<string, unknown>).scheduleId,
);
if (!scheduleId) return undefined;
const reply = await clientCommand("schedule.list_executions", {
scheduleId,
limit: 1,
});
return Array.isArray(reply.executions)
? reply.executions[0]
: undefined;
}),
);
return {
schedules: scheduleRows,
activeExecutions: activeExecutions.executions ?? [],
upcomingRuns: upcomingRuns.runs ?? [],
lastExecutions: lastExecutions.filter(Boolean),
};
}
if (command === "create_routine_schedule") {
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
throw new Error(
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
name,
cronPattern,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
},
mode: args?.mode === "plan" ? "plan" : "act",
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd),
systemPrompt: asTrimmedString(args?.system_prompt),
maxIterations: toPositiveInt(args?.max_iterations),
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags:
Array.isArray(args?.tags) && args.tags.length > 0
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: undefined,
});
return { schedule: created.schedule ?? null };
}
const scheduleId = asTrimmedString(args?.schedule_id);
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
if (command === "update_routine_schedule") {
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
throw new Error(
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
cronPattern,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
},
mode: args?.mode === "plan" ? "plan" : "act",
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd) ?? null,
systemPrompt:
args?.system_prompt === null
? null
: asTrimmedString(args?.system_prompt),
maxIterations:
args?.max_iterations === null
? null
: toPositiveInt(args?.max_iterations),
timeoutSeconds:
args?.timeout_seconds === null
? null
: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: Array.isArray(args?.tags)
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: [],
});
return { schedule: reply.schedule ?? null };
}
if (command === "pause_routine_schedule") {
const reply = await clientCommand("schedule.disable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "resume_routine_schedule") {
const reply = await clientCommand("schedule.enable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "trigger_routine_schedule") {
const existing = await clientCommand("schedule.get", { scheduleId });
if (!existing.schedule)
throw new Error(`schedule not found: ${scheduleId}`);
const reply = await clientCommand("schedule.trigger", {
scheduleId,
wait: false,
});
return { execution: reply.execution ?? null };
}
if (command === "delete_routine_schedule") {
const reply = await clientCommand("schedule.delete", { scheduleId });
return { deleted: reply.deleted === true };
}
throw new Error(`unsupported routine schedule command: ${command}`);
}
@@ -1,285 +0,0 @@
import type {
WebviewActionSessionSummary,
WebviewChatMessage,
WebviewClientSummary,
WebviewOutboundMessage,
WebviewSessionSummary,
} from "../webview-protocol";
import type { HubContext } from "./state";
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
import {
asNumber,
asString,
asTimestamp,
basename,
formatClientLabel,
isActiveSession,
stringifyContent,
} from "./utils";
function metadataFor(record: Record<string, unknown>): Record<string, unknown> {
return (
(record.metadata && typeof record.metadata === "object"
? (record.metadata as Record<string, unknown>)
: undefined) ?? {}
);
}
function usageFor(record: Record<string, unknown>): Record<string, unknown> {
const metadata = metadataFor(record);
const pick = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
return (
pick(record.aggregateUsage) ??
pick(record.usage) ??
pick(metadata.aggregateUsage) ??
pick(metadata.usage) ??
{}
);
}
function sessionTitle(record: Record<string, unknown>): string {
const metadata = metadataFor(record);
const title = asString(metadata.title);
if (title) return title;
const prompt = asString(record.prompt) ?? asString(metadata.prompt);
if (prompt) return prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt;
return basename(asString(record.workspaceRoot) ?? asString(record.cwd));
}
export function formatClientName(client: TrackedClient): string {
return (
client.displayName?.trim() ||
client.clientType.trim() ||
client.clientId.trim() ||
"Unknown"
);
}
export function formatSessionCreator(
ctx: HubContext,
session: TrackedSession,
): string {
const clientId = session.createdByClientId?.trim();
if (!clientId) return "Unknown client";
const client = ctx.clients.get(clientId);
return client ? formatClientName(client) : clientId;
}
function summarizeClient(client: TrackedClient): {
key: string;
label: string;
name: string;
} {
const normalizedType = client.clientType.trim().toLowerCase();
if (
normalizedType === "code-sidecar" ||
normalizedType === "code-sidecar-approvals" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Code App", name: "Code App" };
}
return {
key: client.clientId,
label: formatClientLabel(client.clientType),
name: formatClientName(client),
};
}
export function mapHistoryToWebviewMessages(
history: unknown[],
): WebviewChatMessage[] {
return history.map((entry, index) => {
const record =
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: { content: entry };
const rawRole = asString(record.role)?.toLowerCase();
const role: WebviewChatMessage["role"] =
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
? rawRole
: "meta";
const text = stringifyContent(record.content ?? record.text ?? record);
return {
id: asString(record.id) ?? `history-${index}`,
role,
text,
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
};
});
}
export function trackSession(record: unknown): TrackedSession | undefined {
const raw =
record && typeof record === "object"
? (record as Record<string, unknown>)
: {};
const sessionId = asString(raw.sessionId);
if (!sessionId) return undefined;
const metadata = metadataFor(raw);
const usage = usageFor(raw);
const participantCount = Array.isArray(raw.participants)
? raw.participants.length
: 0;
const createdAt =
asTimestamp(raw.createdAt) ??
asTimestamp(raw.startedAt) ??
asTimestamp(metadata.createdAt) ??
Date.now();
return {
sessionId,
status: asString(raw.status) ?? "running",
title: sessionTitle(raw),
workspaceRoot: asString(raw.workspaceRoot) ?? asString(raw.cwd) ?? "",
cwd: asString(raw.cwd),
provider: asString(raw.provider) ?? asString(metadata.provider),
model: asString(raw.model) ?? asString(metadata.model),
source: asString(raw.source) ?? asString(metadata.source),
createdAt,
updatedAt:
asTimestamp(raw.updatedAt) ??
asTimestamp(raw.endedAt) ??
asTimestamp(metadata.updatedAt) ??
createdAt,
createdByClientId: asString(raw.createdByClientId),
prompt: asString(raw.prompt) ?? asString(metadata.prompt),
inputTokens:
asNumber(usage.inputTokens) ??
asNumber(usage.input) ??
asNumber(usage.totalInputTokens),
outputTokens:
asNumber(usage.outputTokens) ??
asNumber(usage.output) ??
asNumber(usage.totalOutputTokens),
totalCost: asNumber(usage.totalCost) ?? asNumber(metadata.totalCost),
agentCount: Math.max(1, participantCount),
participantCount,
};
}
export function toActionSessionSummary(
session: TrackedSession,
): WebviewActionSessionSummary {
return {
sessionId: session.sessionId,
title: session.title || basename(session.workspaceRoot || session.cwd),
status: session.status,
workspaceRoot: session.workspaceRoot,
workspaceName: basename(session.workspaceRoot || session.cwd),
cwd: session.cwd,
model: session.model,
provider: session.provider,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
createdByClientId: session.createdByClientId,
prompt: session.prompt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
agentCount: session.agentCount,
};
}
export function clientSummariesPayload(
ctx: HubContext,
): WebviewClientSummary[] {
const sessionCounts = new Map<string, number>();
for (const session of ctx.sessions.values()) {
if (
!isActiveSession(session.title, session.status, session.participantCount)
)
continue;
const clientId = session.createdByClientId?.trim();
if (!clientId) continue;
sessionCounts.set(clientId, (sessionCounts.get(clientId) ?? 0) + 1);
}
const grouped = new Map<
string,
WebviewClientSummary & { firstConnectedAt: number }
>();
for (const client of [...ctx.clients.values()].sort(
(a, b) => a.connectedAt - b.connectedAt,
)) {
const summary = summarizeClient(client);
const existing = grouped.get(summary.key);
if (existing) {
existing.sessionCount += sessionCounts.get(client.clientId) ?? 0;
existing.firstConnectedAt = Math.min(
existing.firstConnectedAt,
client.connectedAt,
);
continue;
}
grouped.set(summary.key, {
label: summary.label,
name: summary.name,
sessionCount: sessionCounts.get(client.clientId) ?? 0,
firstConnectedAt: client.connectedAt,
});
}
return [...grouped.values()]
.sort((a, b) => a.firstConnectedAt - b.firstConnectedAt)
.map(({ label, name, sessionCount }) => ({ label, name, sessionCount }));
}
export function toWebviewSessionSummary(
session: TrackedSession,
): WebviewSessionSummary {
return {
sessionId: session.sessionId,
title: session.title,
status: session.status,
source: session.source,
providerId: session.provider,
model: session.model,
workspaceRoot: session.workspaceRoot,
updatedAt: session.updatedAt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
export function webviewSessionsPayload(
ctx: HubContext,
): WebviewOutboundMessage {
return {
type: "sessions",
sessions: [...ctx.sessions.values()]
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(toWebviewSessionSummary),
};
}
export function parseSessionContext(
record: unknown,
): SessionContext | undefined {
const raw =
record && typeof record === "object"
? (record as Record<string, unknown>)
: {};
const metadata =
raw.metadata && typeof raw.metadata === "object"
? (raw.metadata as Record<string, unknown>)
: {};
const workspaceRootRaw = asString(raw.workspaceRoot);
const providerId =
asString(raw.providerId) ??
asString(metadata.providerId) ??
asString(raw.provider) ??
asString(metadata.provider);
const modelId =
asString(raw.modelId) ??
asString(metadata.modelId) ??
asString(raw.model) ??
asString(metadata.model);
if (!workspaceRootRaw || !providerId || !modelId) return undefined;
return {
workspaceRoot: workspaceRootRaw,
cwd: asString(raw.cwd) ?? workspaceRootRaw,
providerId,
modelId,
};
}
-483
View File
@@ -1,483 +0,0 @@
import process from "node:process";
import {
type ClineCoreStartInput,
type SessionRecord,
SessionSource,
} from "@cline/core";
import type { Message } from "@cline/llms";
import type { WebviewConfig, WebviewReasonLevel } from "../webview-protocol";
import { rejectPendingApprovalsForSession } from "./approvals";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
loadProviders,
resolveBrowserDefaults,
sendProviderCatalog,
} from "./providers";
import {
mapHistoryToWebviewMessages,
trackSession,
webviewSessionsPayload,
} from "./session-mapping";
import type { HubContext } from "./state";
import { broadcastHubState, hubStatePayload } from "./state-payloads";
import type { BrowserPeer, SessionContext } from "./types";
import { asNumber, asString } from "./utils";
function toRuntimeReasoningOptions(
reasonLevel?: WebviewReasonLevel,
): Pick<ClineCoreStartInput["config"], "reasoningEffort" | "thinking"> {
if (reasonLevel === undefined) return {};
if (reasonLevel === "none") return { thinking: false };
return { thinking: true, reasoningEffort: reasonLevel };
}
function asWebviewReasonLevel(value: unknown): WebviewReasonLevel | undefined {
return value === "none" ||
value === "low" ||
value === "medium" ||
value === "high"
? value
: undefined;
}
export function resolveLaunchContext(
ctx: HubContext,
override?: Partial<SessionContext> & WebviewConfig,
): SessionContext {
const providerId =
override?.provider ??
override?.providerId ??
ctx.lastSessionContext?.providerId ??
providerSettingsManager.getLastUsedProviderSettings()?.provider ??
process.env.CLINE_PROVIDER?.trim() ??
"";
const modelId =
override?.model ??
override?.modelId ??
ctx.lastSessionContext?.modelId ??
providerSettingsManager.getLastUsedProviderSettings()?.model ??
process.env.CLINE_MODEL?.trim() ??
"";
const root =
override?.workspaceRoot ??
ctx.lastSessionContext?.workspaceRoot ??
workspaceRoot;
if (!providerId || !modelId) {
throw new Error(
"No provider/model available. Start a session in another Cline client first, or set CLINE_PROVIDER and CLINE_MODEL.",
);
}
return {
workspaceRoot: root,
cwd: override?.cwd ?? ctx.lastSessionContext?.cwd ?? root,
providerId,
modelId,
};
}
function buildSessionStartInput(
context: SessionContext,
options?: {
mode?: "act" | "plan";
systemPrompt?: string;
maxIterations?: number;
reasonLevel?: WebviewReasonLevel;
enableTools?: boolean;
enableSpawn?: boolean;
enableTeams?: boolean;
autoApproveTools?: boolean;
teamName?: string;
source?: SessionSource;
sessionMetadata?: Record<string, unknown>;
initialMessages?: Message[];
},
): ClineCoreStartInput {
const mode = options?.mode === "plan" ? "plan" : "act";
const reasoningOptions = toRuntimeReasoningOptions(options?.reasonLevel);
return {
source: options?.source ?? SessionSource.WEB,
interactive: true,
config: {
workspaceRoot: context.workspaceRoot,
cwd: context.cwd,
providerId: context.providerId,
modelId: context.modelId,
systemPrompt: options?.systemPrompt ?? "",
mode,
...reasoningOptions,
maxIterations: options?.maxIterations,
enableTools: options?.enableTools !== false,
enableSpawnAgent: options?.enableSpawn !== false,
enableAgentTeams: options?.enableTeams === true,
teamName: options?.teamName ?? "cline-hub",
missionLogIntervalSteps: 3,
missionLogIntervalMs: 120000,
checkpoint: { enabled: true },
},
sessionMetadata: {
source: options?.source ?? SessionSource.WEB,
mode,
systemPrompt: options?.systemPrompt,
maxIterations: options?.maxIterations,
reasonLevel: options?.reasonLevel,
autoApproveTools: options?.autoApproveTools,
...(options?.sessionMetadata ?? {}),
},
...(options?.initialMessages
? { initialMessages: options.initialMessages }
: {}),
toolPolicies:
options?.autoApproveTools === false
? { "*": { autoApprove: false } }
: { "*": { autoApprove: true } },
};
}
function buildStartInputFromSession(
session: SessionRecord,
options?: {
sessionMetadata?: Record<string, unknown>;
initialMessages?: Message[];
},
) {
const metadata =
session.metadata && typeof session.metadata === "object"
? session.metadata
: {};
const mode = metadata.mode === "plan" ? "plan" : "act";
return buildSessionStartInput(
{
workspaceRoot: session.workspaceRoot,
cwd: session.cwd,
providerId: session.provider,
modelId: session.model,
},
{
mode,
systemPrompt: asString(metadata.systemPrompt),
maxIterations: asNumber(metadata.maxIterations),
reasonLevel: asWebviewReasonLevel(metadata.reasonLevel),
enableTools: session.enableTools,
enableSpawn: session.enableSpawn,
enableTeams: session.enableTeams,
autoApproveTools:
typeof metadata.autoApproveTools === "boolean"
? metadata.autoApproveTools
: undefined,
teamName: session.teamName,
source: session.source,
sessionMetadata: { ...metadata, ...(options?.sessionMetadata ?? {}) },
initialMessages: options?.initialMessages,
},
);
}
async function loadHistoryFor(
ctx: HubContext,
sessionId: string,
): Promise<unknown[]> {
if (!ctx.cline) return [];
try {
return (await ctx.cline.readMessages(sessionId)) as unknown[];
} catch (error) {
console.warn(`readMessages(${sessionId}) failed:`, error);
return [];
}
}
export async function selectSession(
ctx: HubContext,
peer: BrowserPeer,
sessionId: string,
): Promise<void> {
peer.selectedSessionId = sessionId;
const tracked = ctx.sessions.get(sessionId);
const history = await loadHistoryFor(ctx, sessionId);
ctx.send(peer, { type: "session_started", sessionId });
ctx.send(peer, {
type: "session_hydrated",
sessionId,
status: tracked?.status,
providerId: tracked?.provider,
modelId: tracked?.model,
messages: mapHistoryToWebviewMessages(history),
});
}
export async function createSession(
ctx: HubContext,
peer: BrowserPeer,
prompt: string,
config?: WebviewConfig,
attachments?: { userImages?: string[] },
): Promise<void> {
if (!ctx.cline) throw new Error("Hub is not connected.");
const context = resolveLaunchContext(ctx, config);
const mode = config?.mode === "plan" ? "plan" : "act";
const result = await ctx.cline.start(
buildSessionStartInput(context, {
mode,
systemPrompt: config?.systemPrompt,
maxIterations: config?.maxIterations,
reasonLevel: config?.reasonLevel,
enableTools: config?.enableTools,
enableSpawn: config?.enableSpawn,
enableTeams: config?.enableTeams,
autoApproveTools: config?.autoApproveTools,
}),
);
peer.selectedSessionId = result.sessionId;
ctx.sessions.set(result.sessionId, {
sessionId: result.sessionId,
status: "running",
title: prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt,
workspaceRoot: context.workspaceRoot,
cwd: context.cwd,
provider: context.providerId,
model: context.modelId,
source: SessionSource.WEB,
createdAt: Date.now(),
updatedAt: Date.now(),
prompt,
agentCount: 1,
participantCount: 1,
});
const tracked = ctx.sessions.get(result.sessionId);
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
ctx.send(peer, {
type: "session_hydrated",
sessionId: result.sessionId,
status: tracked?.status,
providerId: context.providerId,
modelId: context.modelId,
messages: [],
});
broadcastHubState(ctx);
await ctx.cline.send({
sessionId: result.sessionId,
prompt,
mode,
userImages: attachments?.userImages,
});
}
export async function sendMessage(
ctx: HubContext,
peer: BrowserPeer,
text: string,
config?: WebviewConfig,
attachments?: { userImages?: string[] },
): Promise<void> {
if (!ctx.cline) throw new Error("Hub is not connected.");
if (!peer.selectedSessionId) {
await createSession(ctx, peer, text, config, attachments);
return;
}
await ctx.cline.send({
sessionId: peer.selectedSessionId,
prompt: text,
mode: config?.mode === "plan" ? "plan" : "act",
userImages: attachments?.userImages,
});
}
export async function deleteSession(
ctx: HubContext,
peer: BrowserPeer,
sessionId: string,
): Promise<void> {
if (!ctx.cline) throw new Error("Hub is not connected.");
const deleted = await ctx.cline.delete(sessionId);
if (!deleted) {
ctx.send(peer, {
type: "status",
text: `Session ${sessionId} was not found.`,
});
return;
}
ctx.sessions.delete(sessionId);
if (peer.selectedSessionId === sessionId) {
peer.selectedSessionId = undefined;
ctx.send(peer, { type: "reset_done" });
}
ctx.send(peer, { type: "status", text: `Deleted session ${sessionId}` });
broadcastHubState(ctx);
}
export async function resetPeer(
ctx: HubContext,
peer: BrowserPeer,
): Promise<void> {
if (peer.selectedSessionId) {
rejectPendingApprovalsForSession(
ctx,
peer.selectedSessionId,
"Session detached before approval was resolved.",
);
}
peer.selectedSessionId = undefined;
ctx.send(peer, { type: "reset_done" });
ctx.send(peer, webviewSessionsPayload(ctx));
}
export async function abortPeerTurn(
ctx: HubContext,
peer: BrowserPeer,
): Promise<void> {
if (!ctx.cline || !peer.selectedSessionId) return;
rejectPendingApprovalsForSession(
ctx,
peer.selectedSessionId,
"Turn aborted before approval was resolved.",
);
await ctx.cline.abort(peer.selectedSessionId);
ctx.send(peer, { type: "status", text: "Abort requested." });
}
export async function forkPeerSession(
ctx: HubContext,
peer: BrowserPeer,
syncHubClientsAndSessions: () => Promise<void>,
): Promise<void> {
if (!ctx.cline) throw new Error("Hub is not connected.");
const forkedFromSessionId = peer.selectedSessionId;
if (!forkedFromSessionId) {
ctx.send(peer, { type: "fork_error", text: "No active session to fork." });
return;
}
try {
const rawMessages = (await ctx.cline.readMessages(
forkedFromSessionId,
)) as Message[];
if (rawMessages.length === 0) {
ctx.send(peer, {
type: "fork_error",
text: "Cannot fork an empty session.",
});
return;
}
const sourceSession = await ctx.cline.get(forkedFromSessionId);
if (!sourceSession) {
ctx.send(peer, {
type: "fork_error",
text: `Session ${forkedFromSessionId} was not found.`,
});
return;
}
const checkpointMetadata = sourceSession.metadata?.checkpoint;
const result = await ctx.cline.start(
buildStartInputFromSession(sourceSession, {
initialMessages: rawMessages,
sessionMetadata: {
...(sourceSession.metadata ?? {}),
fork: {
forkedFromSessionId,
forkedAt: new Date().toISOString(),
source: sourceSession.source,
...(checkpointMetadata !== undefined
? { checkpoints: checkpointMetadata }
: {}),
},
},
}),
);
peer.selectedSessionId = result.sessionId;
const newSession = await ctx.cline.get(result.sessionId);
const tracked = newSession ? trackSession(newSession) : undefined;
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
ctx.send(peer, {
type: "session_hydrated",
sessionId: result.sessionId,
status: newSession?.status,
providerId: newSession?.provider,
modelId: newSession?.model,
messages: mapHistoryToWebviewMessages(rawMessages),
});
ctx.send(peer, {
type: "fork_done",
forkedFromSessionId,
newSessionId: result.sessionId,
});
await syncHubClientsAndSessions();
broadcastHubState(ctx);
} catch (error) {
ctx.send(peer, {
type: "fork_error",
text: error instanceof Error ? error.message : String(error),
});
}
}
export async function restorePeerSession(
ctx: HubContext,
peer: BrowserPeer,
checkpointRunCount: number,
syncHubClientsAndSessions: () => Promise<void>,
): Promise<void> {
if (!ctx.cline) throw new Error("Hub is not connected.");
const sourceSessionId = peer.selectedSessionId;
if (!sourceSessionId) {
ctx.send(peer, { type: "error", text: "No active session to restore." });
return;
}
const sourceSession = await ctx.cline.get(sourceSessionId);
if (!sourceSession) {
ctx.send(peer, {
type: "error",
text: `Session ${sourceSessionId} was not found.`,
});
return;
}
const result = await ctx.cline.restore({
sessionId: sourceSessionId,
checkpointRunCount,
cwd: sourceSession.cwd,
start: buildStartInputFromSession(sourceSession, {
sessionMetadata: {
...(sourceSession.metadata ?? {}),
restoredFromSessionId: sourceSessionId,
restoredCheckpointRunCount: checkpointRunCount,
},
}),
restore: { messages: true, workspace: true },
});
if (!result.sessionId) {
ctx.send(peer, {
type: "error",
text: "Checkpoint restore did not start a session.",
});
return;
}
peer.selectedSessionId = result.sessionId;
const restoredSession = await ctx.cline.get(result.sessionId);
const tracked = restoredSession ? trackSession(restoredSession) : undefined;
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
const messages =
result.messages ?? (await loadHistoryFor(ctx, result.sessionId));
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
ctx.send(peer, {
type: "session_hydrated",
sessionId: result.sessionId,
status: restoredSession?.status,
providerId: restoredSession?.provider,
modelId: restoredSession?.model,
messages: mapHistoryToWebviewMessages(messages),
});
await syncHubClientsAndSessions();
broadcastHubState(ctx);
}
export async function initializePeer(
ctx: HubContext,
peer: BrowserPeer,
syncHubClientsAndSessions: () => Promise<void>,
): Promise<void> {
await syncHubClientsAndSessions();
ctx.send(peer, { type: "status", text: "Cline Hub is ready." });
ctx.send(peer, { type: "defaults", defaults: resolveBrowserDefaults(ctx) });
await loadProviders(ctx, peer);
await sendProviderCatalog(ctx, peer);
ctx.send(peer, webviewSessionsPayload(ctx));
ctx.send(peer, hubStatePayload(ctx));
}
@@ -1,72 +0,0 @@
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import type { WebviewHubState } from "../webview-protocol";
import {
clientSummariesPayload,
toActionSessionSummary,
webviewSessionsPayload,
} from "./session-mapping";
import type { HubContext } from "./state";
import { formatUptime, isActiveSession } from "./utils";
function activeSessionSummaries(ctx: HubContext) {
return [...ctx.sessions.values()]
.filter((session) =>
isActiveSession(session.title, session.status, session.participantCount),
)
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(toActionSessionSummary);
}
export function hubStatePayload(ctx: HubContext): WebviewHubState {
const sessionSummaries = activeSessionSummaries(ctx);
const clientList = [...ctx.clients.values()].sort(
(a, b) => a.connectedAt - b.connectedAt,
);
return {
type: "hub_state",
connected: Boolean(ctx.cline && ctx.uiClient),
hubUrl: ctx.hubUrl,
hubStartedAt: ctx.hubStartedAt,
coreVersion: ctx.coreVersion,
hubUptime: ctx.hubStartedAt
? formatUptime(Date.now() - Date.parse(ctx.hubStartedAt))
: undefined,
clients: clientList,
connectors: listActiveConnectors(),
sessions: sessionSummaries,
clientSummaries: clientSummariesPayload(ctx),
sessionSummaries,
events: ctx.events,
lastWorkspaceRoot: ctx.lastSessionContext?.workspaceRoot,
};
}
export function hubStatusPayload(ctx: HubContext) {
const clientList = [...ctx.clients.values()].sort(
(a, b) => a.connectedAt - b.connectedAt,
);
const sessionSummaries = activeSessionSummaries(ctx);
return {
address: ctx.hubUrl,
status: ctx.hubHealthy ? "healthy" : "unhealthy",
healthy: ctx.hubHealthy,
connected: Boolean(ctx.cline && ctx.uiClient),
startedAt: ctx.hubStartedAt,
uptime: ctx.hubStartedAt
? formatUptime(Date.now() - Date.parse(ctx.hubStartedAt))
: undefined,
coreVersion: ctx.coreVersion,
clients: clientList.map((client) => ({
clientId: client.clientId,
displayName: client.displayName,
clientType: client.clientType,
connectedAt: new Date(client.connectedAt).toISOString(),
})),
activeSessions: sessionSummaries.length,
};
}
export function broadcastHubState(ctx: HubContext): void {
ctx.broadcast(hubStatePayload(ctx));
ctx.broadcast(webviewSessionsPayload(ctx));
}
-78
View File
@@ -1,78 +0,0 @@
import {
type ClineCore,
CORE_BUILD_VERSION,
type HubUIClient,
} from "@cline/core";
import type { WebviewHubEvent } from "../webview-protocol";
import type {
BrowserPeer,
PendingToolApproval,
SessionContext,
TrackedClient,
TrackedSession,
} from "./types";
/**
* Shared mutable runtime state for the Cline Hub server. A single instance is
* created in `server.ts` and threaded through the feature modules, replacing
* what used to be a wall of module-level `let`s in the monolithic file.
*/
export class HubContext {
readonly peers = new Set<BrowserPeer>();
readonly clients = new Map<string, TrackedClient>();
readonly sessions = new Map<string, TrackedSession>();
readonly pendingToolApprovals = new Map<string, PendingToolApproval>();
readonly events: WebviewHubEvent[] = [];
hubUrl = "";
hubAuthToken = "";
hubHealthy = false;
cline: ClineCore | undefined;
uiClient: HubUIClient | undefined;
hubStartedAt: string | undefined;
coreVersion: string | undefined = CORE_BUILD_VERSION;
lastSessionContext: SessionContext | undefined;
initialHubEventEmitted = false;
send(peer: BrowserPeer, payload: unknown): void {
peer.socket.send(JSON.stringify(payload));
}
broadcast(payload: unknown): void {
const data = JSON.stringify(payload);
for (const peer of this.peers) {
peer.socket.send(data);
}
}
pushEvent(
title: string,
body: string,
severity: WebviewHubEvent["severity"] = "info",
timestamp = Date.now(),
): void {
this.events.unshift({
id: `${timestamp}-${this.events.length}-${title}`,
title,
body,
severity,
timestamp,
});
if (this.events.length > 30) this.events.length = 30;
}
sendToSelectedPeers(sessionId: string, payload: unknown): void {
for (const peer of this.peers) {
if (peer.selectedSessionId === sessionId) {
this.send(peer, payload);
}
}
}
hasSelectedPeer(sessionId: string): boolean {
for (const peer of this.peers) {
if (peer.selectedSessionId === sessionId) return true;
}
return false;
}
}
-69
View File
@@ -1,69 +0,0 @@
import type { SaveProviderSettingsActionRequest } from "@cline/core";
import type { ToolApprovalResult } from "@cline/shared";
import type {
WebviewInboundMessage,
WebviewReasonLevel,
} from "../webview-protocol";
export type BrowserFrame = WebviewInboundMessage | { type: "restart_hub" };
export type ProviderSettingsUpdate = Partial<
Omit<SaveProviderSettingsActionRequest, "action" | "providerId">
>;
export interface BrowserConfig {
inviteRequired: boolean;
publicUrl: string;
}
export type TrackedClient = {
clientId: string;
displayName?: string;
clientType: string;
connectedAt: number;
};
export type TrackedSession = {
sessionId: string;
status: string;
title: string;
workspaceRoot: string;
cwd?: string;
provider?: string;
model?: string;
source?: string;
createdAt: number;
updatedAt: number;
createdByClientId?: string;
prompt?: string;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
agentCount: number;
participantCount: number;
};
export type SessionContext = {
workspaceRoot: string;
cwd: string;
providerId: string;
modelId: string;
};
export type BrowserPeer = {
socket: Bun.ServerWebSocket<BrowserPeer>;
displayName: string;
selectedSessionId?: string;
unsubscribeEvents?: () => void;
sending: boolean;
};
export type PendingToolApproval = {
sessionId: string;
resolve: (result: ToolApprovalResult) => void;
timeout: ReturnType<typeof setTimeout>;
};
export type JsonRecord = Record<string, unknown>;
export type { WebviewReasonLevel };
@@ -1,178 +0,0 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
createUserInstructionConfigService,
discoverPluginModulePaths,
getCoreBuiltinToolCatalog,
listHookConfigFiles,
listPluginTools,
readGlobalSettings,
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
}
export async function listUserInstructionConfigs(
targetWorkspaceRoot: string,
): Promise<JsonRecord> {
const warnings: string[] = [];
const loadUserInstructionSnapshot = async (
type: "rule" | "skill" | "workflow",
): Promise<unknown[]> => {
const items: unknown[] = [];
const service = createUserInstructionConfigService({
skills: { workspacePath: targetWorkspaceRoot },
rules: { workspacePath: targetWorkspaceRoot },
workflows: { workspacePath: targetWorkspaceRoot },
});
try {
await service.start();
for (const record of service.listRecords(type)) {
const item = record.item as unknown as JsonRecord;
if (item.disabled === true) continue;
items.push({
id: record.id,
name: item.name ?? record.id,
instructions: item.instructions,
path: record.filePath,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`${type}: ${message}`);
} finally {
service.stop();
}
return items;
};
const loadAgents = (): unknown[] => {
const agentsById = new Map<string, { name: string; path: string }>();
const directories = resolveAgentConfigSearchPaths(
targetWorkspaceRoot,
).filter((d) => existsSync(d));
for (const directory of directories) {
try {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
const name =
parsedName && parsedName.length > 0
? parsedName
: pathBasename(entry.name, ext);
const id = name.toLowerCase();
if (!agentsById.has(id)) {
agentsById.set(id, { name, path: filePath });
}
}
} catch {
// best-effort
}
}
return [...agentsById.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
};
const loadHooks = (): unknown[] => {
try {
return listHookConfigFiles(targetWorkspaceRoot);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`hooks: ${message}`);
return [];
}
};
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: pathBasename(filePath, extname(filePath)),
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([
loadUserInstructionSnapshot("rule"),
loadUserInstructionSnapshot("workflow"),
loadUserInstructionSnapshot("skill"),
listPluginTools({
workspacePath: targetWorkspaceRoot,
cwd: targetWorkspaceRoot,
}),
]);
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
const builtinToolCatalog = getCoreBuiltinToolCatalog({
disabledToolIds: disabledTools,
});
return {
workspaceRoot: targetWorkspaceRoot,
rules,
workflows,
skills,
agents: loadAgents(),
plugins: loadPlugins(),
tools: [
...builtinToolCatalog.map((tool) => ({
id: tool.id,
name: tool.id,
description: tool.description,
enabled:
tool.defaultEnabled &&
!tool.headlessToolNames.some((name) => disabledTools.has(name)),
source: "builtin",
headlessToolNames: tool.headlessToolNames,
})),
...pluginTools.map((tool) => ({
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
name: tool.name,
description: tool.description,
enabled: tool.enabled,
source: tool.source,
path: tool.path,
pluginName: tool.pluginName,
})),
],
hooks: loadHooks(),
mcp: readMcpServersResponse(),
warnings,
};
}
-139
View File
@@ -1,139 +0,0 @@
import { spawn } from "node:child_process";
import process from "node:process";
import type { ProviderSettingsUpdate } from "./types";
export function readProviderSettingsUpdate(
args: Record<string, unknown> | undefined,
): ProviderSettingsUpdate {
return args?.settings && typeof args.settings === "object"
? (args.settings as ProviderSettingsUpdate)
: {};
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
export function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
}
export function asTimestamp(value: unknown): number | undefined {
const numeric = asNumber(value);
if (numeric !== undefined) return numeric;
if (typeof value !== "string" || !value.trim()) return undefined;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
export function basename(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) return "workspace";
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.at(-1) ?? trimmed;
}
export function toPositiveInt(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
const rounded = Math.trunc(value);
return rounded > 0 ? rounded : undefined;
}
export function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function isVisibleClient(clientType: string): boolean {
return clientType.trim().length > 0;
}
export function isActiveSession(
title: string | undefined,
status: string | undefined,
participantCount?: number,
): boolean {
if (!title || !status) return false;
const normalized = status?.trim().toLowerCase();
if (normalized !== "running" && normalized !== "idle") return false;
return typeof participantCount === "number" ? participantCount > 0 : false;
}
export function formatUptime(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const d = Math.floor(total / 86_400);
const h = Math.floor((total % 86_400) / 3_600);
const m = Math.floor((total % 3_600) / 60);
const s = total % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function formatClientLabel(clientType: string | undefined): string {
const normalized = clientType?.trim().toLowerCase() ?? "";
if (!normalized || normalized === "unknown") return "Client";
if (normalized.includes("cline")) return "Cline";
return normalized
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function stringifyContent(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value
.map((entry) => {
if (typeof entry === "string") return entry;
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
return (
asString(record.text) ??
asString(record.content) ??
asString(record.result) ??
""
);
}
return "";
})
.filter(Boolean)
.join("\n");
}
if (value == null) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function chunkText(chunk: unknown): string {
if (typeof chunk === "string") return chunk;
if (chunk && typeof chunk === "object") {
const record = chunk as Record<string, unknown>;
if (typeof record.text === "string") return record.text;
if (typeof record.content === "string") return record.content;
}
return "";
}
export function openExternalUrl(url: string): void {
const platform = process.platform;
const command =
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
const child = spawn(command, args, { stdio: "ignore", detached: true });
child.unref();
}
-57
View File
@@ -1,57 +0,0 @@
import { buildInviteUrl, resolveClineHubServerOptions } from "./options";
function expectEqual<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
throw new Error(
`${label}: expected ${String(expected)}, got ${String(actual)}`,
);
}
}
function expectThrows(fn: () => unknown, label: string): void {
try {
fn();
} catch {
return;
}
throw new Error(`${label}: expected an error`);
}
const defaults = resolveClineHubServerOptions({});
expectEqual(defaults.host, "127.0.0.1", "default host");
expectEqual(defaults.port, 8787, "default port");
expectEqual(defaults.publicUrl, "http://127.0.0.1:8787", "default public URL");
expectEqual(defaults.roomSecret, undefined, "default room secret");
const lan = resolveClineHubServerOptions({
HOST: "0.0.0.0",
CLINE_HUB_DASHBOARD_PORT: "9000",
PUBLIC_URL: "https://example.ngrok-free.app/",
ROOM_SECRET: "invite-123",
WORKSPACE_ROOT: "/tmp/workspace",
});
expectEqual(lan.host, "0.0.0.0", "LAN host");
expectEqual(lan.port, 9000, "LAN port");
expectEqual(lan.publicUrl, "https://example.ngrok-free.app", "LAN public URL");
expectEqual(lan.roomSecret, "invite-123", "LAN room secret");
expectEqual(lan.workspaceRoot, "/tmp/workspace", "workspace root");
expectEqual(
buildInviteUrl(lan.publicUrl, lan.roomSecret),
"https://example.ngrok-free.app/?roomSecret=invite-123",
"invite URL",
);
expectThrows(
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
"non-local bind without ROOM_SECRET",
);
expectThrows(
() => resolveClineHubServerOptions({ CLINE_HUB_DASHBOARD_PORT: "70000" }),
"invalid dashboard port",
);
expectThrows(
() => resolveClineHubServerOptions({ PUBLIC_URL: "ftp://example.test" }),
"invalid PUBLIC_URL protocol",
);
console.log("cline-hub option validation passed");
-335
View File
@@ -1,335 +0,0 @@
import type {
ChatMessage as CoreChatMessage,
ProviderListItem,
ProviderModel,
} from "@cline/core";
export type WebviewUsage = {
inputTokens?: number;
outputTokens?: number;
cacheCreationInputTokens?: number;
cacheReadInputTokens?: number;
totalCost?: number;
};
export type WebviewProviderModel = Pick<
ProviderModel,
"id" | "name" | "supportsReasoning"
> & {
supportsThinking?: boolean;
};
export type WebviewProviderCatalogItem = ProviderListItem;
export type WebviewReasonLevel = "none" | "low" | "medium" | "high";
export type WebviewToolEvent = {
toolCallId?: string;
toolName?: string;
status: "running" | "completed" | "failed";
input?: unknown;
output?: unknown;
error?: string;
};
export type WebviewChatMessageBlock =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; redacted?: boolean }
| {
id: string;
type: "tool";
toolEvent: NonNullable<WebviewChatMessage["toolEvents"]>[number];
};
export type WebviewChatMessage = Omit<
CoreChatMessage,
"content" | "createdAt" | "meta" | "role" | "sessionId"
> & {
role:
| Extract<CoreChatMessage["role"], "user" | "assistant" | "error">
| "meta";
text: string;
reasoning?: string;
reasoningRedacted?: boolean;
checkpoint?: NonNullable<CoreChatMessage["meta"]>["checkpoint"];
toolEvents?: Array<{
id: string;
toolCallId?: string;
name: string;
text: string;
state: "input-available" | "output-available" | "output-error";
input?: unknown;
output?: unknown;
error?: string;
}>;
blocks?: WebviewChatMessageBlock[];
};
export type WebviewConfig = {
provider?: string;
model?: string;
mode?: "act" | "plan";
systemPrompt?: string;
maxIterations?: number;
reasonLevel?: WebviewReasonLevel;
enableTools?: boolean;
enableSpawn?: boolean;
enableTeams?: boolean;
autoApproveTools?: boolean;
};
export type WebviewChatAttachments = {
userImages?: string[];
};
export type WebviewToolApprovalRequest = {
approvalId: string;
sessionId: string;
agentId: string;
conversationId: string;
iteration: number;
toolCallId: string;
toolName: string;
input: unknown;
policy?: Record<string, unknown>;
};
export type WebviewDefaults = {
provider?: string;
model?: string;
workspaceRoot: string;
cwd: string;
};
export type WebviewSessionSummary = {
sessionId: string;
title?: string;
status?: string;
source?: string;
providerId?: string;
model?: string;
workspaceRoot?: string;
updatedAt?: number;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
};
export type WebviewConnectedClient = {
clientId: string;
displayName?: string;
clientType: string;
connectedAt: number;
};
export type WebviewClientSummary = {
label: string;
name: string;
sessionCount: number;
};
export type WebviewConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
};
export type WebviewConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
export type WebviewConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook";
hint: string;
fields: WebviewConnectorField[];
security?: {
prompt: string;
fields: WebviewConnectorSecurityField[];
};
};
export type WebviewActiveConnector = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
};
export type WebviewConnectorChannelsResponse = {
available: WebviewConnectorChannel[];
active: WebviewActiveConnector[];
};
export type WebviewActionSessionSummary = {
sessionId: string;
title: string;
status: string;
workspaceRoot: string;
workspaceName: string;
cwd?: string;
model?: string;
provider?: string;
createdAt: number;
updatedAt: number;
createdByClientId?: string;
prompt?: string;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
agentCount: number;
};
export type WebviewHubEvent = {
id: string;
title: string;
body: string;
severity: "info" | "success" | "warn" | "error";
timestamp: number;
};
export type WebviewHubState = {
type: "hub_state";
connected: boolean;
hubUrl?: string;
hubStartedAt?: string;
coreVersion?: string;
hubUptime?: string;
clients: WebviewConnectedClient[];
connectors: WebviewActiveConnector[];
sessions: WebviewActionSessionSummary[];
clientSummaries: WebviewClientSummary[];
sessionSummaries: WebviewActionSessionSummary[];
events: WebviewHubEvent[];
lastWorkspaceRoot?: string;
};
export type WebviewInboundMessage =
| { type: "ready" }
| { type: "restart_hub" }
| {
type: "desktopCommand";
id: string;
command: string;
args?: Record<string, unknown>;
}
| {
type: "send";
prompt: string;
config?: WebviewConfig;
attachments?: WebviewChatAttachments;
}
| { type: "abort" }
| { type: "reset" }
| {
type: "approval_response";
approvalId: string;
approved: boolean;
reason?: string;
}
| { type: "loadModels"; providerId: string }
| { type: "loadProviderCatalog" }
| {
type: "saveProviderSettings";
providerId: string;
enabled?: boolean;
apiKey?: string;
baseUrl?: string;
}
| { type: "runProviderOAuthLogin"; providerId: string }
| { type: "attachSession"; sessionId: string }
| { type: "deleteSession"; sessionId: string }
| {
type: "updateSessionMetadata";
sessionId: string;
metadata: Record<string, unknown>;
}
| { type: "restore"; checkpointRunCount: number }
| { type: "forkSession" };
export type WebviewOutboundMessage =
| { type: "status"; text: string }
| { type: "error"; text: string }
| {
type: "desktopCommandResult";
id: string;
ok: true;
result: unknown;
}
| {
type: "desktopCommandResult";
id: string;
ok: false;
error: string;
}
| { type: "session_started"; sessionId: string }
| {
type: "session_hydrated";
sessionId: string;
status?: string;
providerId?: string;
modelId?: string;
messages: WebviewChatMessage[];
}
| { type: "assistant_delta"; text: string }
| { type: "reasoning_delta"; text: string; redacted?: boolean }
| { type: "tool_event"; text: string; event?: WebviewToolEvent }
| ({ type: "approval_request" } & WebviewToolApprovalRequest)
| {
type: "approval_resolved";
approvalId: string;
approved: boolean;
reason?: string;
}
| {
type: "turn_done";
finishReason: string;
iterations: number;
usage?: WebviewUsage;
}
| {
type: "providers";
providers: Array<
Pick<ProviderListItem, "defaultModelId" | "enabled" | "id" | "name">
>;
}
| {
type: "provider_catalog";
providers: WebviewProviderCatalogItem[];
settingsPath: string;
}
| {
type: "provider_settings_saved";
providerId: string;
enabled: boolean;
}
| {
type: "provider_oauth_login_done";
providerId: string;
accessTokenPresent: boolean;
}
| { type: "models"; providerId: string; models: WebviewProviderModel[] }
| { type: "sessions"; sessions: WebviewSessionSummary[] }
| WebviewHubState
| { type: "defaults"; defaults: WebviewDefaults }
| { type: "reset_done" }
| {
type: "fork_done";
forkedFromSessionId: string;
newSessionId: string;
}
| { type: "fork_error"; text: string };
-15
View File
@@ -1,15 +0,0 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/
# Environment variables
.env*.local
# Common ignores
node_modules
.next/
.DS_Store
-64
View File
@@ -1,64 +0,0 @@
{
"name": "@cline/cline-hub-webview",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^6.0.116",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-jsx-parser": "^2.4.1",
"recharts": "2.15.4",
"shadcn": "^4.0.8",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"esbuild": "^0.27.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,566 +0,0 @@
import {
CheckIcon,
HatGlassesIcon,
PaperclipIcon,
PlayIcon,
Settings2Icon,
SignalHigh,
SignalLow,
SignalMedium,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@/components/ai-elements/attachments";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorLogoGroup,
ModelSelectorName,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import {
PromptInput,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
PromptInputHeader,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
usePromptInputController,
} from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type {
WebviewChatAttachments,
WebviewOutboundMessage,
WebviewProviderModel,
WebviewReasonLevel,
} from "../../../webview-protocol";
type ProviderOption = Extract<
WebviewOutboundMessage,
{ type: "providers" }
>["providers"][number];
function PromptAttachmentsDisplay() {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) {
return null;
}
return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
}
function ComposerSettings({
autoApproveTools,
enableSpawn,
enableTeams,
model,
modelSelectorOpen,
models,
onAutoApproveToolsChange,
onEnableSpawnChange,
onEnableTeamsChange,
onModelChange,
onModelSelectorOpenChange,
onProviderChange,
provider,
providers,
workspaceRoot,
}: {
autoApproveTools: boolean;
enableSpawn: boolean;
enableTeams: boolean;
enableTools: boolean;
maxIterations: string;
model: string;
modelSelectorOpen: boolean;
models: WebviewProviderModel[];
onAutoApproveToolsChange: (value: boolean) => void;
onEnableSpawnChange: (value: boolean) => void;
onEnableTeamsChange: (value: boolean) => void;
onEnableToolsChange: (value: boolean) => void;
onMaxIterationsChange: (value: string) => void;
onModelChange: (value: string) => void;
onModelSelectorOpenChange: (value: boolean) => void;
onProviderChange: (value: string) => void;
onSystemPromptChange: (value: string) => void;
provider: string;
providers: ProviderOption[];
systemPrompt: string;
workspaceRoot: string;
}) {
const selectedProvider = providers.find((item) => item.id === provider);
const selectedModel =
models.find((item) => item.id === model) ?? models[0] ?? undefined;
return (
<div className="grid gap-3 bg-background/70 p-3">
<div className="grid gap-2 md:grid-cols-2">
<div className="grid gap-2">
<Label className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
Provider
</Label>
<Select
onValueChange={(value) => {
if (value) {
onProviderChange(value);
}
}}
value={provider}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center gap-2">
{renderProviderLogo(item.id)}
<span>{item.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
Model
</Label>
<ModelSelector
onOpenChange={onModelSelectorOpenChange}
open={modelSelectorOpen}
>
<ModelSelectorTrigger>
<Button className="w-full justify-between" variant="outline">
<div className="flex min-w-0 items-center gap-2">
{selectedProvider && renderProviderLogo(selectedProvider.id)}
<span className="truncate">
{selectedModel?.name || selectedModel?.id || "Select model"}
</span>
</div>
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
<ModelSelectorGroup
heading={selectedProvider?.name || "Models"}
>
{models.map((item) => (
<ModelSelectorItem
key={item.id}
onSelect={() => {
onModelChange(item.id);
onModelSelectorOpenChange(false);
}}
value={item.id}
>
{selectedProvider &&
renderProviderLogo(selectedProvider.id)}
<ModelSelectorName>
{item.name || item.id}
</ModelSelectorName>
<ModelSelectorLogoGroup>
{selectedProvider &&
renderProviderLogo(selectedProvider.id)}
</ModelSelectorLogoGroup>
{model === item.id ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="grid gap-2">
<Label
className="text-xs uppercase tracking-[0.16em] text-muted-foreground"
htmlFor="workspace-root"
>
Workspace
</Label>
<Input id="workspace-root" readOnly value={workspaceRoot} />
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<Toggle
checked={enableSpawn}
label="Subagents"
onChange={onEnableSpawnChange}
/>
<Toggle
checked={enableTeams}
label="Agent Teams"
onChange={onEnableTeamsChange}
/>
<Toggle
checked={autoApproveTools}
label="Auto-approves"
onChange={onAutoApproveToolsChange}
/>
</div>
</div>
);
}
function renderProviderLogo(providerId: string) {
return (
<ModelSelectorLogo className="size-3.5" provider={providerId || "openai"} />
);
}
function Toggle({
checked,
label,
onChange,
disabled,
}: {
checked: boolean;
label: string;
onChange: (value: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center justify-between rounded-lg border bg-background/60 px-3 py-2">
<Label className="text-sm" htmlFor={label}>
{label}
</Label>
<Switch
checked={checked}
id={label}
onCheckedChange={(value) => onChange(value)}
disabled={disabled}
/>
</div>
);
}
const ReasonLevel = {
None: "none",
Low: "low",
Medium: "medium",
High: "high",
} as const;
const reasonLevels = [
{ value: ReasonLevel.None, label: "Thinking Off", icon: SignalHigh },
{ value: ReasonLevel.Low, label: "Low", icon: SignalLow },
{ value: ReasonLevel.Medium, label: "Medium", icon: SignalMedium },
{ value: ReasonLevel.High, label: "High", icon: SignalHigh },
];
export function Composer({
autoApproveTools,
disabled = false,
enableSpawn,
enableTeams,
enableTools,
maxIterations,
model,
mode,
modelSelectorOpen,
models,
onAbort,
onAutoApproveToolsChange,
onEnableSpawnChange,
onEnableTeamsChange,
onEnableToolsChange,
onModeChange,
onMaxIterationsChange,
onModelChange,
onModelSelectorOpenChange,
onProviderChange,
onSend,
onSystemPromptChange,
onReasonLevelChange,
provider,
providers,
sending,
status,
systemPrompt,
reasonLevel,
workspaceRoot,
}: {
autoApproveTools: boolean;
disabled?: boolean;
enableSpawn: boolean;
enableTeams: boolean;
enableTools: boolean;
maxIterations: string;
model: string;
mode: "act" | "plan";
modelSelectorOpen: boolean;
models: WebviewProviderModel[];
onAbort: () => void;
onAutoApproveToolsChange: (value: boolean) => void;
onEnableSpawnChange: (value: boolean) => void;
onEnableTeamsChange: (value: boolean) => void;
onEnableToolsChange: (value: boolean) => void;
onModeChange: (value: "act" | "plan") => void;
onMaxIterationsChange: (value: string) => void;
onModelChange: (value: string) => void;
onModelSelectorOpenChange: (value: boolean) => void;
onProviderChange: (value: string) => void;
onSend: (input: {
prompt: string;
attachments?: WebviewChatAttachments;
attachmentCount: number;
}) => void;
onSystemPromptChange: (value: string) => void;
onReasonLevelChange: (value: WebviewReasonLevel) => void;
provider: string;
providers: ProviderOption[];
sending: boolean;
status: string;
systemPrompt: string;
reasonLevel: WebviewReasonLevel;
workspaceRoot: string;
}) {
const [settingsOpen, setSettingsOpen] = useState(false);
const controller = usePromptInputController();
const attachments = usePromptInputAttachments();
const selectedModel = models.find((item) => item.id === model);
const thinkingSupported = selectedModel?.supportsThinking === true;
const activeReasonLevel = thinkingSupported ? reasonLevel : ReasonLevel.None;
const reasonLevelOption = Math.max(
reasonLevels.findIndex((item) => item.value === activeReasonLevel),
0,
);
const ReasonIcon = reasonLevels[reasonLevelOption].icon;
return (
<div className="border-t bg-background">
<PromptInput
accept="image/*,.txt,.md,.json,.ts,.tsx,.js,.jsx"
globalDrop
className="rounded-none [&>[data-slot=input-group]]:border-0! [&>[data-slot=input-group]]:ring-0! [&>[data-slot=input-group]]:has-[[data-slot=input-group-control]:focus-visible]:border-0! [&>[data-slot=input-group]]:has-[[data-slot=input-group-control]:focus-visible]:ring-0!"
maxFiles={8}
multiple
onError={(error) => toast.error(error.message)}
onSubmit={async (message: PromptInputMessage) => {
const prompt = message.text.trim();
if (!prompt && !message.files.length) {
return;
}
let attachments: WebviewChatAttachments | undefined;
if (message.files.length > 0) {
const userImages = (
await Promise.all(
message.files.map((file) => toImageDataUrl(file.url)),
)
).filter((value): value is string => Boolean(value));
if (userImages.length > 0) {
attachments = { userImages };
}
if (userImages.length !== message.files.length) {
toast.warning(
"Only image attachments are currently sent in the VS Code chat runtime.",
);
}
}
if (!prompt && !attachments?.userImages?.length) {
return;
}
onSend({
prompt,
attachments,
attachmentCount: message.files.length,
});
}}
>
<PromptInputHeader>
<PromptAttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
disabled={disabled || status.includes("Failed")}
onChange={(event) =>
controller.textInput.setInput(event.target.value)
}
placeholder="Type @ for context and / for skills"
value={controller.textInput.value}
className="text-sm outline-none ring-0"
/>
</PromptInputBody>
<PromptInputFooter className="flex-col items-stretch gap-1 px-0">
{settingsOpen ? (
<ComposerSettings
autoApproveTools={autoApproveTools}
enableSpawn={enableSpawn}
enableTeams={enableTeams}
enableTools={enableTools}
maxIterations={maxIterations}
model={model}
modelSelectorOpen={modelSelectorOpen}
models={models}
onAutoApproveToolsChange={onAutoApproveToolsChange}
onEnableSpawnChange={onEnableSpawnChange}
onEnableTeamsChange={onEnableTeamsChange}
onEnableToolsChange={onEnableToolsChange}
onMaxIterationsChange={onMaxIterationsChange}
onModelChange={onModelChange}
onModelSelectorOpenChange={onModelSelectorOpenChange}
onProviderChange={onProviderChange}
onSystemPromptChange={onSystemPromptChange}
provider={provider}
providers={providers}
systemPrompt={systemPrompt}
workspaceRoot={workspaceRoot}
/>
) : null}
<div className="flex items-center justify-between gap-3">
<PromptInputTools className="shrink-0">
<PromptInputButton
disabled={disabled}
onClick={() => attachments.openFileDialog()}
type="button"
variant="ghost"
>
<PaperclipIcon className="size-3" />
</PromptInputButton>
<PromptInputButton
disabled={disabled}
onClick={() => setSettingsOpen((open) => !open)}
type="button"
variant={settingsOpen ? "default" : "ghost"}
>
<Settings2Icon className="size-3" />
<span>
{provider}:{model}
</span>
</PromptInputButton>
<PromptInputButton
disabled={disabled || !thinkingSupported}
onClick={() => {
const nextOption =
(reasonLevelOption + 1) % reasonLevels.length;
onReasonLevelChange(reasonLevels[nextOption].value);
}}
type="button"
title={reasonLevels[reasonLevelOption].label}
variant={
activeReasonLevel !== ReasonLevel.None ? "default" : "ghost"
}
>
<ReasonIcon className="size-3" />
</PromptInputButton>
<PromptInputButton
disabled={disabled}
onClick={() => onModeChange(mode === "act" ? "plan" : "act")}
type="button"
variant={mode === "plan" ? "default" : "ghost"}
className="hidden"
>
{mode === "act" ? (
<PlayIcon className="size-3" />
) : (
<HatGlassesIcon className="size-3" />
)}
{mode}
</PromptInputButton>
<Badge
className="rounded-sm px-3 py-1 text-xs hidden"
variant={status.includes("Error") ? "destructive" : "secondary"}
>
{status}
</Badge>
</PromptInputTools>
<div className="flex items-center gap-2">
{sending ? (
<Button onClick={onAbort} type="button" variant="destructive">
Abort
</Button>
) : null}
<PromptInputSubmit
disabled={disabled || status.includes("Failed")}
status={sending ? "submitted" : "ready"}
variant="ghost"
/>
</div>
</div>
</PromptInputFooter>
</PromptInput>
</div>
);
}
async function toImageDataUrl(
url: string | undefined,
): Promise<string | undefined> {
if (!url) {
return undefined;
}
if (url.startsWith("data:image/")) {
return url;
}
if (!url.startsWith("blob:")) {
return undefined;
}
try {
const response = await fetch(url);
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
return undefined;
}
return await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(typeof reader.result === "string" ? reader.result : undefined);
};
reader.onerror = () => resolve(undefined);
reader.readAsDataURL(blob);
});
} catch {
return undefined;
}
}
@@ -1,60 +0,0 @@
"use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button cursor-pointer inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground/50 dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -1,271 +0,0 @@
"use client";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-24 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
@@ -1,32 +0,0 @@
"use client";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
@@ -1,577 +0,0 @@
"use client";
import type {
ClineAccountBalance,
ClineAccountOrganization,
ClineAccountOrganizationBalance,
ClineAccountOrganizationUsageTransaction,
ClineAccountPaymentTransaction,
ClineAccountUsageTransaction,
ClineAccountUser,
} from "@cline/core";
import {
AlertCircle,
Building,
CreditCard,
ExternalLink,
Loader2,
LogOut,
Plus,
Receipt,
RefreshCw,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
function normalizeAccountViewError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("unsupported desktop command: cline_account")) {
return new Error(
"The desktop sidecar is running an older build that does not support account commands. Restart the sidecar or reload the app, then try again.",
);
}
return error instanceof Error ? error : new Error(message);
}
// ---------------------------------------------------------------------------
// Data fetching helpers via sidecar command
// ---------------------------------------------------------------------------
async function fetchAccountUser(): Promise<ClineAccountUser> {
return await desktopClient.invoke<ClineAccountUser>("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
}
async function fetchAccountBalance(): Promise<ClineAccountBalance> {
return await desktopClient.invoke<ClineAccountBalance>("cline_account", {
action: "clineAccount",
operation: "fetchBalance",
});
}
async function fetchAccountOrganizations(): Promise<
ClineAccountOrganization[]
> {
return await desktopClient.invoke<ClineAccountOrganization[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchUserOrganizations",
},
);
}
async function fetchOrganizationBalance(
organizationId: string,
): Promise<ClineAccountOrganizationBalance> {
return await desktopClient.invoke<ClineAccountOrganizationBalance>(
"cline_account",
{
action: "clineAccount",
operation: "fetchOrganizationBalance",
organizationId,
},
);
}
async function fetchUsageTransactions(): Promise<
ClineAccountUsageTransaction[]
> {
return await desktopClient.invoke<ClineAccountUsageTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchUsageTransactions",
},
);
}
async function fetchOrganizationUsageTransactions(
organizationId: string,
memberId?: string,
): Promise<ClineAccountOrganizationUsageTransaction[]> {
return await desktopClient.invoke<ClineAccountOrganizationUsageTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchOrganizationUsageTransactions",
organizationId,
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
},
);
}
async function fetchPaymentTransactions(): Promise<
ClineAccountPaymentTransaction[]
> {
return await desktopClient.invoke<ClineAccountPaymentTransaction[]>(
"cline_account",
{
action: "clineAccount",
operation: "fetchPaymentTransactions",
},
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function AccountView() {
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
"overview",
);
// Overview data
const [user, setUser] = useState<ClineAccountUser | null>(null);
const [balance, setBalance] = useState<ClineAccountBalance | null>(null);
const [organizationBalance, setOrganizationBalance] =
useState<ClineAccountOrganizationBalance | null>(null);
const [organizations, setOrganizations] = useState<
ClineAccountOrganization[]
>([]);
const [overviewLoading, setOverviewLoading] = useState(true);
const [overviewError, setOverviewError] = useState<string | null>(null);
// Usage data
const [usageTransactions, setUsageTransactions] = useState<
ClineAccountUsageTransaction[]
>([]);
const [usageLoading, setUsageLoading] = useState(false);
const [usageError, setUsageError] = useState<string | null>(null);
const [usageLoaded, setUsageLoaded] = useState(false);
const usageGenerationRef = useRef(0);
// Billing data
const [paymentTransactions, setPaymentTransactions] = useState<
ClineAccountPaymentTransaction[]
>([]);
const [billingLoading, setBillingLoading] = useState(false);
const [billingError, setBillingError] = useState<string | null>(null);
const [billingLoaded, setBillingLoaded] = useState(false);
const activeOrganization = organizations.find((org) => org.active) ?? null;
// -- Overview fetch --
const loadOverview = useCallback(async () => {
setOverviewLoading(true);
setOverviewError(null);
try {
const [userData, balanceData, orgsData] = await Promise.all([
fetchAccountUser(),
fetchAccountBalance(),
fetchAccountOrganizations(),
]);
const nextActiveOrganization =
orgsData.find((organization) => organization.active) ?? null;
const organizationBalanceData = nextActiveOrganization
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
: null;
setUser(userData);
setBalance(balanceData);
setOrganizationBalance(organizationBalanceData);
setOrganizations(orgsData);
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setOverviewLoading(false);
}
}, []);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
// -- Usage fetch (lazy on tab switch) --
const loadUsage = useCallback(async () => {
const generation = usageGenerationRef.current;
setUsageLoading(true);
setUsageError(null);
try {
const data = activeOrganization
? await fetchOrganizationUsageTransactions(
activeOrganization.organizationId,
activeOrganization.memberId,
)
: await fetchUsageTransactions();
if (usageGenerationRef.current !== generation) return;
setUsageTransactions(data);
setUsageLoaded(true);
} catch (err) {
if (usageGenerationRef.current !== generation) return;
const message = normalizeAccountViewError(err).message;
setUsageError(message);
} finally {
if (usageGenerationRef.current === generation) {
setUsageLoading(false);
}
}
}, [activeOrganization]);
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
useEffect(() => {
usageGenerationRef.current += 1;
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
}, [activeOrganization?.organizationId]);
useEffect(() => {
if (activeTab === "usage" && !usageLoaded) {
void loadUsage();
}
}, [activeTab, usageLoaded, loadUsage]);
// -- Billing fetch (lazy on tab switch) --
const loadBilling = useCallback(async () => {
setBillingLoading(true);
setBillingError(null);
try {
const data = await fetchPaymentTransactions();
setPaymentTransactions(data);
setBillingLoaded(true);
} catch (err) {
const message = normalizeAccountViewError(err).message;
setBillingError(message);
} finally {
setBillingLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === "billing" && !billingLoaded) {
void loadBilling();
}
}, [activeTab, billingLoaded, loadBilling]);
// -- Formatters --
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
};
const formatCreditBalance = (value: number, decimalPlaces = 2) => {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value / 1_000_000);
};
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance?.balance ?? null)
: (balance?.balance ?? null);
const tabs = ["overview", "usage", "billing"] as const;
// -- Shared error / loading UI --
const renderError = (message: string, onRetry: () => void) => (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
<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"
>
<RefreshCw className="h-4 w-4" />
Retry
</button>
</div>
);
const renderLoading = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account</h2>
<button
type="button"
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"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab}
{activeTab === tab && (
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError && renderError(overviewError, loadOverview)}
{!overviewLoading && !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">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<a
href="https://app.cline.bot/dashboard"
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
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"
>
<Plus className="h-3.5 w-3.5" />
Credit
</a>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
)}
{/* Organizations */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
target="_blank"
rel="noopener noreferrer"
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"
>
<Plus className="h-3.5 w-3.5" />
Create
</a>
</div>
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
<div className="divide-y divide-border">
{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"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
);
}
@@ -1,533 +0,0 @@
"use client";
import {
ArrowLeft,
ChevronDown,
Copy,
Eye,
EyeOff,
Plus,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
const CAPABILITY_OPTIONS = [
"streaming",
"tools",
"reasoning",
"vision",
"prompt-cache",
] as const;
type Capability = (typeof CAPABILITY_OPTIONS)[number];
export interface AddProviderPayload {
providerId: string;
name: string;
baseUrl: string;
apiKey?: string;
headers?: Record<string, string>;
timeoutMs?: number;
models: string[];
defaultModelId?: string;
modelsSourceUrl?: string;
capabilities?: Capability[];
}
interface NewProviderForm {
providerId: string;
name: string;
models: string[];
defaultModel: string;
apiKey: string;
baseUrl: string;
modelsSourceUrl: string;
headers: Record<string, string>;
timeoutMs: string;
capabilities: Capability[];
}
export function AddProviderContent({
onBack,
onSave,
existingProviderIds,
}: {
onBack: () => void;
onSave: (payload: AddProviderPayload) => Promise<void>;
existingProviderIds: string[];
}) {
const [form, setForm] = useState<NewProviderForm>({
providerId: "",
name: "",
models: [],
defaultModel: "",
apiKey: "",
baseUrl: "",
modelsSourceUrl: "",
headers: {},
timeoutMs: "",
capabilities: ["streaming", "tools"],
});
const [modelInput, setModelInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedProviderId = useMemo(
() => form.providerId.trim().toLowerCase().replace(/\s+/g, "-"),
[form.providerId],
);
const duplicateProviderId =
existingProviderIds.includes(normalizedProviderId);
const hasManualModels = form.models.length > 0;
const hasModelsSource = form.modelsSourceUrl.trim().length > 0;
const canSave =
normalizedProviderId.length > 0 &&
form.name.trim().length > 0 &&
form.baseUrl.trim().length > 0 &&
(hasManualModels || hasModelsSource) &&
!duplicateProviderId;
const handleAddModel = (e: React.KeyboardEvent<HTMLInputElement>) => {
if ((e.key === "Enter" || e.key === ",") && modelInput.trim()) {
e.preventDefault();
const value = modelInput.trim().replace(/,/g, "");
if (value && !form.models.includes(value)) {
setForm((prev) => ({
...prev,
models: [...prev.models, value],
defaultModel: prev.defaultModel || value,
}));
}
setModelInput("");
} else if (e.key === "Backspace" && !modelInput && form.models.length > 0) {
setForm((prev) => ({
...prev,
models: prev.models.slice(0, -1),
}));
}
};
const removeModel = (model: string) => {
setForm((prev) => {
const nextModels = prev.models.filter((m) => m !== model);
return {
...prev,
models: nextModels,
defaultModel:
prev.defaultModel === model
? (nextModels[0] ?? "")
: prev.defaultModel,
};
});
};
const toggleCapability = (cap: Capability) => {
setForm((prev) => ({
...prev,
capabilities: prev.capabilities.includes(cap)
? prev.capabilities.filter((c) => c !== cap)
: [...prev.capabilities, cap],
}));
};
const addHeader = () => {
setForm((prev) => ({ ...prev, headers: { ...prev.headers, "": "" } }));
};
const updateHeaderKey = (oldKey: string, newKey: string, idx: number) => {
const entries = Object.entries(form.headers);
const next: Record<string, string> = {};
entries.forEach(([key, value], index) => {
next[index === idx ? newKey : key] = value;
});
if (oldKey !== newKey) {
delete next[oldKey];
}
setForm((prev) => ({ ...prev, headers: next }));
};
const updateHeaderValue = (key: string, value: string) => {
setForm((prev) => ({
...prev,
headers: { ...prev.headers, [key]: value },
}));
};
const removeHeader = (key: string) => {
const next = { ...form.headers };
delete next[key];
setForm((prev) => ({ ...prev, headers: next }));
};
const handleSave = async () => {
if (!canSave || saving) {
return;
}
setSaving(true);
setError(null);
try {
await onSave({
providerId: normalizedProviderId,
name: form.name.trim(),
baseUrl: form.baseUrl.trim(),
apiKey: form.apiKey.trim() || undefined,
headers: Object.fromEntries(
Object.entries(form.headers)
.map(([key, value]) => [key.trim(), value])
.filter(([key]) => key.length > 0),
),
timeoutMs:
form.timeoutMs.trim().length > 0
? Number.parseInt(form.timeoutMs.trim(), 10)
: undefined,
models: form.models,
defaultModelId: form.defaultModel || form.models[0],
modelsSourceUrl: form.modelsSourceUrl.trim() || undefined,
capabilities:
form.capabilities.length > 0 ? form.capabilities : undefined,
});
} catch (saveError) {
setError(
saveError instanceof Error ? saveError.message : String(saveError),
);
} finally {
setSaving(false);
}
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center gap-3">
<Button
onClick={onBack}
variant="secondary"
className="rounded-md p-1.5 transition-colors"
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">
Add Provider
</h2>
</div>
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider Name
</Label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0
? "Type model ID and press Enter"
: ""
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
>
{cap.replace(/-/g, " ")}
</Button>
))}
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
)}
/>
</Button>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) =>
updateHeaderValue(key, e.target.value)
}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
</div>
</ScrollArea>
);
}
@@ -1,564 +0,0 @@
"use client";
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type ConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type ActiveConnector = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
};
type ConnectorChannelsResponse = {
available: ConnectorChannel[];
active: ActiveConnector[];
};
type ConnectorFormState = {
channelId: string;
values: Record<string, string>;
securityEnabled: boolean;
securityValues: Record<string, string>;
};
function connectorName(
connector: ActiveConnector,
channels: ConnectorChannel[],
): string {
return (
channels.find((channel) => channel.id === connector.type)?.name ??
connector.type
);
}
function connectorIdentity(connector: ActiveConnector): string {
if (connector.botUsername) {
return `@${connector.botUsername}`;
}
if (connector.userName) {
return connector.userName;
}
if (connector.applicationId) {
return connector.applicationId;
}
return `pid ${connector.pid}`;
}
function formatDateTime(value?: string): string {
if (!value) {
return "-";
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function isSecretField(
field: ConnectorField | ConnectorSecurityField,
): boolean {
const label = field.label.toLowerCase();
const key =
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
return (
label.includes("token") ||
label.includes("secret") ||
label.includes("key") ||
key.includes("token") ||
key.includes("secret") ||
key.includes("key")
);
}
function isMultilineField(field: ConnectorField): boolean {
const label = field.label.toLowerCase();
return label.includes("json") || field.flag.includes("credentials");
}
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
return {
channelId: channels[0]?.id ?? "",
values: {},
securityEnabled: false,
securityValues: {},
};
}
export function ChannelsContent() {
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [busyChannel, setBusyChannel] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [formState, setFormState] = useState<ConnectorFormState>({
channelId: "",
values: {},
securityEnabled: false,
securityValues: {},
});
const [formError, setFormError] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
null,
);
const selectedChannel = useMemo(
() => channels.find((channel) => channel.id === formState.channelId),
[channels, formState.channelId],
);
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
setChannels(response.available);
setActiveConnectors(response.active);
setFormState((prev) =>
prev.channelId ? prev : createFormState(response.available),
);
}, []);
const refreshChannels = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"list_connector_channels",
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, [applyResponse]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void refreshChannels();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [refreshChannels]);
const openAddDialog = () => {
setFormState(createFormState(channels));
setFormError(null);
setDialogOpen(true);
};
const updateFieldValue = (flag: string, value: string) => {
setFormState((prev) => ({
...prev,
values: { ...prev.values, [flag]: value },
}));
};
const updateSecurityFieldValue = (key: string, value: string) => {
setFormState((prev) => ({
...prev,
securityValues: { ...prev.securityValues, [key]: value },
}));
};
const startConnector = async () => {
if (!selectedChannel) {
setFormError("Choose a channel");
return;
}
for (const field of selectedChannel.fields) {
if (field.required && !formState.values[field.flag]?.trim()) {
setFormError(`${field.label} is required`);
return;
}
}
if (formState.securityEnabled && selectedChannel.security) {
for (const field of selectedChannel.security.fields) {
if (!formState.securityValues[field.key]?.trim()) {
setFormError(field.requiredMessage);
return;
}
}
}
setBusyChannel(selectedChannel.id);
setFormError(null);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"start_connector_channel",
{
channel: selectedChannel.id,
values: formState.values,
security: {
enabled: formState.securityEnabled,
values: formState.securityValues,
},
},
);
applyResponse(response);
setDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setFormError(message);
} finally {
setBusyChannel(null);
}
};
const stopConnector = async (connector: ActiveConnector) => {
setBusyChannel(connector.type);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"stop_connector_channel",
{ channel: connector.type },
);
applyResponse(response);
setRemoveTarget(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyChannel(null);
}
};
return (
<ScrollArea className="h-full">
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<p className="text-sm text-muted-foreground">
{activeConnectors.length} connected
</p>
</div>
<div className="flex items-center gap-2">
<Button
disabled={isLoading}
onClick={() => void refreshChannels()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw
className={cn("size-4", isLoading && "animate-spin")}
/>
</Button>
<Button
disabled={channels.length === 0}
onClick={openAddDialog}
size="sm"
type="button"
>
<Plus className="size-4" />
Add Channel
</Button>
</div>
</div>
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
<section className="overflow-hidden rounded-lg border bg-card">
<div className="grid gap-2 p-2.5">
{isLoading ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
Loading channels...
</p>
) : activeConnectors.length === 0 ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
No channels connected.
</p>
) : (
activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
</div>
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))
)}
</div>
</section>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Channel</DialogTitle>
<DialogDescription>
Start a connector channel for Cline Hub.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Channel</Label>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
setFormState({
channelId: value,
values: {},
securityEnabled: false,
securityValues: {},
});
}}
value={formState.channelId}
>
<SelectTrigger>
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedChannel?.fields.map((field) => (
<div className="grid gap-2" key={field.flag}>
<Label>
{field.label}
{field.required ? (
<span className="text-destructive"> *</span>
) : null}
</Label>
{isMultilineField(field) ? (
<Textarea
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
rows={5}
value={formState.values[field.flag] ?? ""}
/>
) : (
<Input
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.values[field.flag] ?? ""}
/>
)}
</div>
))}
{selectedChannel?.security ? (
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm">Restrict access</Label>
<Switch
checked={formState.securityEnabled}
onCheckedChange={(checked: boolean) =>
setFormState((prev) => ({
...prev,
securityEnabled: checked,
}))
}
/>
</div>
{formState.securityEnabled
? selectedChannel.security.fields.map((field) => (
<div className="grid gap-2" key={field.key}>
<Label>{field.label}</Label>
<Input
onChange={(event) =>
updateSecurityFieldValue(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.securityValues[field.key] ?? ""}
/>
</div>
))
: null}
</div>
) : null}
{formError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
) : null}
</div>
<DialogFooter>
<Button
disabled={busyChannel !== null}
onClick={() => setDialogOpen(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={busyChannel !== null || !selectedChannel}
onClick={() => void startConnector()}
type="button"
>
{busyChannel ? "Starting..." : "Add Channel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={removeTarget !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setRemoveTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
<AlertDialogDescription>
Confirm that you want to stop the active{" "}
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyChannel !== null}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={busyChannel !== null || !removeTarget}
onClick={() => {
if (removeTarget) {
void stopConnector(removeTarget);
}
}}
variant="destructive"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,859 +0,0 @@
"use client";
import { Circle, Minus, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
interface McpServer {
name: string;
transportType: McpTransportType;
disabled: boolean;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
metadata?: unknown;
}
interface McpServersResponse {
settingsPath: string;
hasSettingsFile: boolean;
servers: McpServer[];
}
interface McpServerUpsertInput {
name: string;
previousName?: string;
transportType: McpTransportType;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
disabled?: boolean;
metadata?: unknown;
}
type McpServerFormState = {
name: string;
previousName: string;
transportType: McpTransportType;
command: string;
argsText: string;
cwd: string;
envEntries: Array<{ id: string; key: string; value: string }>;
url: string;
headersText: string;
disabled: boolean;
metadataText: string;
};
function splitCsv(text: string): string[] {
return text
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function parseKeyValuePairs(text: string): Record<string, string> | undefined {
const pairs = splitCsv(text);
if (pairs.length === 0) {
return undefined;
}
const out: Record<string, string> = {};
for (const pair of pairs) {
const idx = pair.indexOf("=");
if (idx <= 0) {
continue;
}
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (!key) {
continue;
}
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function stringifyKeyValuePairs(input?: Record<string, string>): string {
if (!input) {
return "";
}
return Object.entries(input)
.map(([key, value]) => `${key}=${value}`)
.join(", ");
}
function stringifyRedactedKeyValuePairs(
input?: Record<string, string>,
): string {
if (!input) {
return "";
}
return Object.keys(input)
.map((key) => `${key}=[REDACTED]`)
.join(", ");
}
function createEnvEntries(
input?: Record<string, string>,
): Array<{ id: string; key: string; value: string }> {
if (!input || Object.keys(input).length === 0) {
return [{ id: crypto.randomUUID(), key: "", value: "" }];
}
return Object.entries(input).map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value,
}));
}
function createServerFormState(existing?: McpServer): McpServerFormState {
return {
name: existing?.name ?? "",
previousName: existing?.name ?? "",
transportType: existing?.transportType ?? "stdio",
command: existing?.command ?? "",
argsText: existing?.args?.join(", ") ?? "",
cwd: existing?.cwd ?? "",
envEntries: createEnvEntries(existing?.env),
url: existing?.url ?? "",
headersText: stringifyKeyValuePairs(existing?.headers),
disabled: existing?.disabled ?? false,
metadataText:
existing?.metadata === undefined
? ""
: JSON.stringify(existing.metadata, null, 2),
};
}
export function McpServersContent() {
const [servers, setServers] = useState<McpServer[]>([]);
const [settingsPath, setSettingsPath] = useState("");
const [hasSettingsFile, setHasSettingsFile] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isOpeningSettingsFile, setIsOpeningSettingsFile] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [busyServerName, setBusyServerName] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
const [formState, setFormState] = useState<McpServerFormState>(() =>
createServerFormState(),
);
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
const applyResponse = useCallback((response: McpServersResponse) => {
setServers(response.servers);
setSettingsPath(response.settingsPath);
setHasSettingsFile(response.hasSettingsFile);
}, []);
const refreshServers = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response =
await desktopClient.invoke<McpServersResponse>("list_mcp_servers");
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, [applyResponse]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void refreshServers();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [refreshServers]);
const toggleServer = async (server: McpServer, disabled: boolean) => {
setBusyServerName(server.name);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<McpServersResponse>(
"set_mcp_server_disabled",
{
name: server.name,
disabled,
},
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyServerName(null);
}
};
const upsertServer = async (input: McpServerUpsertInput) => {
setBusyServerName(input.previousName ?? input.name);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<McpServersResponse>(
"upsert_mcp_server",
{
input,
},
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
throw error;
} finally {
setBusyServerName(null);
}
};
const deleteServer = async (serverName: string) => {
setBusyServerName(serverName);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<McpServersResponse>(
"delete_mcp_server",
{
name: serverName,
},
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyServerName(null);
}
};
const buildServerInput = useCallback((form: McpServerFormState) => {
const name = form.name.trim();
if (!name) {
throw new Error("Server name is required.");
}
const env = form.envEntries.reduce<Record<string, string>>((acc, entry) => {
const key = entry.key.trim();
if (!key) {
return acc;
}
acc[key] = entry.value;
return acc;
}, {});
const metadataText = form.metadataText.trim();
const metadata =
metadataText.length > 0 ? JSON.parse(metadataText) : undefined;
if (form.transportType === "stdio") {
const command = form.command.trim();
if (!command) {
throw new Error("Command is required for stdio transport.");
}
const args = splitCsv(form.argsText);
return {
name,
previousName: form.previousName.trim() || undefined,
transportType: form.transportType,
command,
args: args.length > 0 ? args : undefined,
cwd: form.cwd.trim() || undefined,
env: Object.keys(env).length > 0 ? env : undefined,
disabled: form.disabled,
metadata,
} satisfies McpServerUpsertInput;
}
const url = form.url.trim();
if (!url) {
throw new Error("URL is required for sse and streamableHttp transport.");
}
return {
name,
previousName: form.previousName.trim() || undefined,
transportType: form.transportType,
url,
headers: parseKeyValuePairs(form.headersText),
disabled: form.disabled,
metadata,
} satisfies McpServerUpsertInput;
}, []);
const openCreateDialog = () => {
setEditorMode("create");
setFormState(createServerFormState());
setFormErrorMessage(null);
setEditorOpen(true);
};
const openEditDialog = (server: McpServer) => {
setEditorMode("edit");
setFormState(createServerFormState(server));
setFormErrorMessage(null);
setEditorOpen(true);
};
const handleSaveServer = async () => {
setFormErrorMessage(null);
try {
const input = buildServerInput(formState);
await upsertServer(input);
setEditorOpen(false);
} catch (error) {
setFormErrorMessage(
error instanceof Error ? error.message : String(error),
);
}
};
const openSettingsFile = async () => {
setIsOpeningSettingsFile(true);
setErrorMessage(null);
try {
const openedPath = await desktopClient.invoke<string>(
"open_mcp_settings_file",
);
if (openedPath.trim().length > 0) {
setSettingsPath(openedPath);
setHasSettingsFile(true);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsOpeningSettingsFile(false);
}
};
const sortedServers = useMemo(
() =>
[...servers].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
),
[servers],
);
const updateEnvEntry = (
id: string,
field: "key" | "value",
value: string,
) => {
setFormState((current) => ({
...current,
envEntries: current.envEntries.map((entry) =>
entry.id === id ? { ...entry, [field]: value } : entry,
),
}));
};
const addEnvEntry = () => {
setFormState((current) => ({
...current,
envEntries: [
...current.envEntries,
{ id: crypto.randomUUID(), key: "", value: "" },
],
}));
};
const removeEnvEntry = (id: string) => {
setFormState((current) => ({
...current,
envEntries:
current.envEntries.length === 1
? [{ id: crypto.randomUUID(), key: "", value: "" }]
: current.envEntries.filter((entry) => entry.id !== id),
}));
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
MCP Servers
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
From settings file
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void refreshServers()}
disabled={isLoading}
>
<RefreshCw
className={cn("h-4 w-4", isLoading && "animate-spin")}
/>
</Button>
<Button size="sm" onClick={openCreateDialog}>
<Plus className="h-4 w-4" />
Add MCP Server
</Button>
</div>
</div>
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
<p className="mb-6 text-xs text-muted-foreground">
{hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."}
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">
Command:
</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers &&
Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">
Headers:
</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<Dialog
open={editorOpen}
onOpenChange={(open) => {
setEditorOpen(open);
if (!open) {
setFormErrorMessage(null);
}
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"}
</DialogTitle>
<DialogDescription>
Update the MCP server stored in{" "}
<code className="font-mono">
{settingsPath || "cline_mcp_settings.json"}
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="mcp-name">Server name</Label>
<Input
id="mcp-name"
value={formState.name}
onChange={(event) =>
setFormState((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="github"
/>
</div>
<div className="grid gap-2">
<Label>Transport type</Label>
<Select
value={formState.transportType}
onValueChange={(value) =>
setFormState((current) => ({
...current,
transportType: value as McpTransportType,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select transport" />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">sse</SelectItem>
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
</SelectContent>
</Select>
</div>
{formState.transportType === "stdio" ? (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-command">Command</Label>
<Input
id="mcp-command"
value={formState.command}
onChange={(event) =>
setFormState((current) => ({
...current,
command: event.target.value,
}))
}
placeholder="npx"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-args">Args</Label>
<Textarea
id="mcp-args"
value={formState.argsText}
onChange={(event) =>
setFormState((current) => ({
...current,
argsText: event.target.value,
}))
}
placeholder="-y, @modelcontextprotocol/server-github"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-cwd">Working directory</Label>
<Input
id="mcp-cwd"
value={formState.cwd}
onChange={(event) =>
setFormState((current) => ({
...current,
cwd: event.target.value,
}))
}
placeholder="/path/to/project"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label>Environment variables</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addEnvEntry}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2">
{formState.envEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeEnvEntry(entry.id)}
aria-label={`Remove env var ${entry.key || "row"}`}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<Input
value={entry.key}
onChange={(event) =>
updateEnvEntry(entry.id, "key", event.target.value)
}
placeholder="KEY"
/>
<Input
type="password"
value={entry.value}
onChange={(event) =>
updateEnvEntry(
entry.id,
"value",
event.target.value,
)
}
placeholder="VALUE"
/>
</div>
))}
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-url">Server URL</Label>
<Input
id="mcp-url"
value={formState.url}
onChange={(event) =>
setFormState((current) => ({
...current,
url: event.target.value,
}))
}
placeholder="https://example.com/mcp"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-headers">Headers</Label>
<Textarea
id="mcp-headers"
value={formState.headersText}
onChange={(event) =>
setFormState((current) => ({
...current,
headersText: event.target.value,
}))
}
placeholder="Authorization=Bearer token"
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
<Textarea
id="mcp-metadata"
value={formState.metadataText}
onChange={(event) =>
setFormState((current) => ({
...current,
metadataText: event.target.value,
}))
}
placeholder='{"key":"value"}'
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Enabled</p>
<p className="text-xs text-muted-foreground">
Disable the server without removing it from settings.
</p>
</div>
<Switch
checked={!formState.disabled}
onCheckedChange={(enabled) =>
setFormState((current) => ({
...current,
disabled: !enabled,
}))
}
aria-label="Enable MCP server"
/>
</div>
{formErrorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formErrorMessage}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditorOpen(false)}
disabled={busyServerName !== null}
>
Cancel
</Button>
<Button
onClick={() => void handleSaveServer()}
disabled={busyServerName !== null}
>
{busyServerName !== null
? "Saving..."
: editorMode === "edit"
? "Save changes"
: "Add server"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete MCP Server</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? `Delete MCP server "${deleteTarget.name}" from settings?`
: "Delete this MCP server from settings?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyServerName !== null}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={busyServerName !== null || !deleteTarget}
onClick={() => {
if (deleteTarget) {
void deleteServer(deleteTarget.name);
setDeleteTarget(null);
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
);
}
@@ -1,524 +0,0 @@
"use client";
import {
ArrowLeft,
Copy,
Eye,
EyeOff,
FileIcon,
ImageIcon,
Link as LinkIcon,
Loader2,
PlusCircle,
RefreshCw,
Search,
Settings2,
Star,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import type {
Provider,
ProviderConfigField,
ProviderConfigFieldPrimitive,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
// -----------------------------------------------------------
// Provider LIST content (the grid of all providers)
// -----------------------------------------------------------
function getInitialConfigValues(
provider: Provider,
): Record<string, ProviderConfigFieldPrimitive> {
const values: Record<string, ProviderConfigFieldPrimitive> = {
...(provider.configValues ?? {}),
};
if (provider.apiKey !== undefined && values.apiKey === undefined) {
values.apiKey = provider.apiKey;
}
if (provider.baseUrl !== undefined && values.baseUrl === undefined) {
values.baseUrl = provider.baseUrl;
}
for (const field of provider.configFields ?? []) {
if (values[field.path] === undefined && field.defaultValue !== undefined) {
values[field.path] = field.defaultValue;
}
}
return values;
}
function fieldValueToString(value: ProviderConfigFieldPrimitive | undefined) {
if (value === undefined || value === null) return "";
return String(value);
}
function coerceFieldValue(
field: ProviderConfigField,
value: string | boolean,
): ProviderConfigFieldPrimitive {
if (field.type === "boolean") {
return Boolean(value);
}
if (typeof value === "boolean") {
return value;
}
if (field.type === "select") {
const option = field.options?.find((item) => String(item.value) === value);
if (option) {
return option.value;
}
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
if (field.type === "number") {
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return trimmed;
}
export function ProviderListContent({
providers,
onToggle,
onConfigure,
onAddProvider,
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
}) {
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
</div>
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
<div
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
key={prov.id}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{prov.name}
</p>
<p className="text-xs text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
</p>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
</div>
))}
</div>
</div>
</ScrollArea>
);
}
export function ProviderDetailContent({
provider,
onBack,
onUpdate,
onLoadModels,
modelsLoading = false,
modelsError,
onOAuthLogin,
oauthLoginPending = false,
}: {
provider: Provider;
onBack: () => void;
onUpdate: (updates: ProviderSettingsUpdate) => void;
onLoadModels?: () => void;
modelsLoading?: boolean;
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
Record<string, ProviderConfigFieldPrimitive>
>(() => getInitialConfigValues(provider));
const [modelSearchState, setModelSearchState] = useState<{
providerId: string;
value: string;
} | null>(null);
const [copiedModelState, setCopiedModelState] = useState<{
modelId: string;
providerId: string;
} | null>(null);
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
const configFields = provider.configFields ?? [];
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
const modelList = provider.modelList ?? [];
const modelSearch =
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
const copiedModelId =
copiedModelState?.providerId === provider.id
? copiedModelState.modelId
: null;
const modelSearchQuery = modelSearch.trim().toLowerCase();
const filteredModelList = modelSearchQuery
? modelList.filter(
(model) =>
model.name.toLowerCase().includes(modelSearchQuery) ||
model.id.toLowerCase().includes(modelSearchQuery),
)
: modelList;
useEffect(
() => () => {
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
},
[],
);
const commitField = (
field: ProviderConfigField,
rawValue: string | boolean,
) => {
const value = coerceFieldValue(field, rawValue);
const nextConfigValues = {
...localConfigValues,
[field.path]: value,
};
setLocalConfigValues(nextConfigValues);
const updates: ProviderSettingsUpdate = {
configValues: { [field.path]: value },
};
if (field.path === "apiKey") {
updates.apiKey = fieldValueToString(value);
}
if (field.path === "baseUrl") {
updates.baseUrl = fieldValueToString(value);
}
onUpdate(updates);
};
const copyModelId = (modelId: string) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(modelId).then(() => {
setCopiedModelState({ modelId, providerId: provider.id });
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
copiedModelTimeoutRef.current = window.setTimeout(
() => setCopiedModelState(null),
1600,
);
});
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label="Back to providers"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">
{provider.name}
</h2>
</div>
{configFields.length > 0 ? (
<section className="mb-8">
<div className="flex flex-col gap-5">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) =>
commitField(field, checked)
}
/>
</div>
) : field.type === "select" ? (
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
value={valueText}
>
<option value="">Not set</option>
{field.options?.map((option) => (
<option
key={String(option.value)}
value={String(option.value)}
>
{option.label}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground outline-none border-0"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
...current,
[field.path]: event.target.value,
}))
}
placeholder={field.placeholder}
spellCheck={false}
type={
isSecret && !isShown
? "password"
: field.type === "number"
? "number"
: field.type === "url"
? "url"
: "text"
}
value={valueText}
/>
{isSecret ? (
<>
<Button
aria-label={
isShown ? "Hide secret" : "Show secret"
}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
setShownSecrets((current) => ({
...current,
[field.path]: !isShown,
}))
}
variant="ghost"
>
{isShown ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
aria-label={`Copy ${field.label}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
navigator.clipboard.writeText(valueText)
}
variant="ghost"
>
<Copy className="h-4 w-4" />
</Button>
</>
) : null}
</div>
)}
</div>
);
})}
</div>
</section>
) : null}
{!apiKeyValue && !provider.oauthAccessTokenPresent && onOAuthLogin ? (
<div className="mb-8">
<Button
className="inline-flex items-center gap-2 w-full"
disabled={oauthLoginPending}
onClick={onOAuthLogin}
variant="default"
>
{oauthLoginPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
<span>Login via Browser</span>
</Button>
</div>
) : null}
{provider.oauthAccessTokenPresent ? (
<p className="mb-8 text-xs text-muted-foreground">
OAuth is connected. Manual credentials remain available when this
provider supports them.
</p>
) : null}
{/* Models section */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<div className="flex items-center gap-1">
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
disabled={modelsLoading}
onClick={onLoadModels}
variant="ghost"
>
<RefreshCw
className={cn("size-3", modelsLoading && "animate-spin")}
/>
</Button>
</div>
</div>
{modelsError ? (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-destructive">{modelsError}</p>
</div>
) : modelList.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search models"
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
onChange={(event) =>
setModelSearchState({
providerId: provider.id,
value: event.target.value,
})
}
placeholder="Search models by name or ID"
spellCheck={false}
value={modelSearch}
/>
</div>
{filteredModelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
{filteredModelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
<div className="min-w-0 flex-1 font-mono">
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
<span className="truncate">{model.name}</span>
{/* Capability icons */}
{model.supportsAttachments && (
<div title="File Support">
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
{model.supportsVision && (
<div title="Image Support">
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
</div>
<button
aria-label={`Copy model ID ${model.id}`}
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => copyModelId(model.id)}
title="Copy model ID"
type="button"
>
<span className="min-w-0 truncate">{model.id}</span>
<Copy className="size-3 shrink-0" />
{copiedModelId === model.id ? (
<span className="shrink-0 text-foreground">
Copied
</span>
) : null}
</button>
</div>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
No models match "{modelSearch.trim()}".
</p>
</div>
)}
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
{modelsLoading
? "Loading models..."
: "No models available. Click refresh to load models."}
</p>
</div>
)}
</section>
</div>
</ScrollArea>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
@@ -1,632 +0,0 @@
"use client";
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
import type {
Provider,
ProviderCatalogResponse,
ProviderModelsResponse,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { RulesView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
ProviderListContent,
} from "./provider-list-view";
import { RoutineSchedulesContent } from "./routine-view";
import { toSettingsPatch } from "./settings-patch";
// -----------------------------------------------------------
// Settings nav categories
// -----------------------------------------------------------
const navCategories = [
"General",
"Providers",
"Customizations",
"MCP",
"Channels",
"Schedules",
"Account",
] as const;
export type SettingsSection = (typeof navCategories)[number];
type Theme = "dark" | "light";
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
};
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
let providerCatalogCache: {
providers: Provider[];
fetchedAt: number;
} | null = null;
// -----------------------------------------------------------
// Component
// -----------------------------------------------------------
export function SettingsView({
initialSection = "General",
onClose,
onNavigateSection,
onThemeChange,
theme,
}: {
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const [providersExpanded, setProvidersExpanded] = useState(true);
const [providers, setProviders] = useState<Provider[]>(
() => providerCatalogCache?.providers ?? [],
);
const [providersLoading, setProvidersLoading] = useState(
() => !providerCatalogCache,
);
const [providerCatalogError, setProviderCatalogError] = useState<
string | null
>(null);
const [modelsLoadingByProvider, setModelsLoadingByProvider] = useState<
Record<string, boolean>
>({});
const [modelsErrorByProvider, setModelsErrorByProvider] = useState<
Record<string, string | null>
>({});
const [oauthSigningProviderId, setOauthSigningProviderId] = useState<
string | null
>(null);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
null,
);
const [addingProvider, setAddingProvider] = useState(false);
const setProvidersWithCache = useCallback(
(next: Provider[] | ((prev: Provider[]) => Provider[])) => {
setProviders((prev) => {
const resolved =
typeof next === "function"
? (next as (prev: Provider[]) => Provider[])(prev)
: next;
providerCatalogCache = {
providers: resolved,
fetchedAt: Date.now(),
};
return resolved;
});
},
[],
);
const loadProviderCatalog = useCallback(async () => {
const now = Date.now();
if (
providerCatalogCache &&
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
) {
setProviders(providerCatalogCache.providers);
setProvidersLoading(false);
setProviderCatalogError(null);
return;
}
setProvidersLoading(true);
setProviderCatalogError(null);
try {
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
"list_provider_catalog",
);
setProvidersWithCache(payload.providers);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setProviderCatalogError(message);
setProviders([]);
} finally {
setProvidersLoading(false);
}
}, [setProvidersWithCache]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
id: string,
updates: {
enabled?: boolean;
apiKey?: string;
baseUrl?: string;
configValues?: ProviderSettingsUpdate["configValues"];
},
) => {
try {
await desktopClient.invoke("save_provider_settings", {
provider: id,
enabled: updates.enabled,
api_key: updates.apiKey,
base_url: updates.baseUrl,
settings: updates.configValues
? toSettingsPatch(updates.configValues)
: undefined,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save provider settings for ${id}: ${message}`);
}
},
[],
);
const toggleProvider = useCallback(
(id: string) => {
setProvidersWithCache((prev) =>
prev.map((p) => {
if (p.id !== id) {
return p;
}
const nextEnabled = !p.enabled;
void persistProviderSettings(id, { enabled: nextEnabled });
return { ...p, enabled: nextEnabled };
}),
);
},
[persistProviderSettings, setProvidersWithCache],
);
const updateProvider = useCallback(
(id: string, updates: ProviderSettingsUpdate) => {
setProvidersWithCache((prev) =>
prev.map((p) =>
p.id === id
? {
...p,
...updates,
configValues: updates.configValues
? {
...(p.configValues ?? {}),
...updates.configValues,
}
: p.configValues,
}
: p,
),
);
void persistProviderSettings(id, {
apiKey: updates.apiKey,
baseUrl: updates.baseUrl,
configValues: updates.configValues,
});
},
[persistProviderSettings, setProvidersWithCache],
);
const loadProviderModels = useCallback(
async (id: string) => {
setModelsLoadingByProvider((prev) => ({ ...prev, [id]: true }));
setModelsErrorByProvider((prev) => ({ ...prev, [id]: null }));
try {
const payload = await desktopClient.invoke<ProviderModelsResponse>(
"list_provider_models",
{
provider: id,
},
);
setProvidersWithCache((prev) =>
prev.map((provider) =>
provider.id === id
? {
...provider,
modelList: payload.models,
models: payload.models.length,
}
: provider,
),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setModelsErrorByProvider((prev) => ({ ...prev, [id]: message }));
} finally {
setModelsLoadingByProvider((prev) => ({ ...prev, [id]: false }));
}
},
[setProvidersWithCache],
);
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProvider = selectedProviderId
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
try {
const result = await desktopClient.invoke<{
provider: string;
accessToken: string;
}>("run_provider_oauth_login", {
provider: id,
});
setProvidersWithCache((prev) =>
prev.map((provider) =>
provider.id === id
? {
...provider,
enabled: true,
oauthAccessTokenPresent: result.accessToken.trim().length > 0,
}
: provider,
),
);
setSelectedProviderId(id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to sign in to ${id}: ${message}`);
} finally {
setOauthSigningProviderId(null);
}
};
const openProviderDetail = (id: string) => {
setActiveNav("Providers");
onNavigateSection?.("Providers");
setSelectedProviderId(id);
};
useEffect(() => {
if (!selectedProviderId) {
return;
}
const selected = providers.find(
(provider) => provider.id === selectedProviderId,
);
if (!selected || (selected.modelList?.length ?? 0) > 0) {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderModels(selectedProviderId);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderModels, providers, selectedProviderId]);
const backToProviderList = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(false);
};
const saveNewProvider = useCallback(
async (payload: AddProviderPayload) => {
await desktopClient.invoke("add_provider", {
provider_id: payload.providerId,
name: payload.name,
base_url: payload.baseUrl,
api_key: payload.apiKey,
headers: payload.headers,
timeout_ms: payload.timeoutMs,
models: payload.models,
default_model_id: payload.defaultModelId,
models_source_url: payload.modelsSourceUrl,
capabilities: payload.capabilities,
});
await loadProviderCatalog();
setAddingProvider(false);
setSelectedProviderId(payload.providerId);
},
[loadProviderCatalog],
);
const openAddProvider = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(true);
};
const selectSection = (section: SettingsSection) => {
setActiveNav(section);
onNavigateSection?.(section);
setSelectedProviderId(null);
setAddingProvider(false);
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold text-foreground">Settings</h1>
<Button
aria-label="Close settings"
className="justify-start"
onClick={onClose}
variant="ghost"
>
<X className="size-3" />
</Button>
</div>
{/* Body */}
<div className="flex flex-1 overflow-hidden">
{/* Settings sidebar nav */}
<nav className="w-56 shrink-0 border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-3">
{navCategories.map((cat) => {
if (cat === "Providers") {
return (
<div key={cat}>
<Button
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeNav === "Providers"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
onClick={() => {
selectSection("Providers");
setProvidersExpanded((p) => !p);
}}
variant="ghost"
>
<span>Providers</span>
{providersExpanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</Button>
{providersExpanded && (
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
{enabledProviders.map((prov) => (
<Button
className={cn(
"justify-start",
selectedProviderId === prov.id
? "bg-accent/80 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
)}
disabled={oauthSigningProviderId === prov.id}
key={prov.id}
onClick={() => openProviderDetail(prov.id)}
variant="ghost"
>
<span className="truncate">{prov.name}</span>
</Button>
))}
</div>
)}
</div>
);
}
return (
<Button
className={cn(
"justify-start",
activeNav === cat && !selectedProviderId
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
selectSection(cat);
}}
variant="ghost"
>
{cat}
</Button>
);
})}
</div>
</ScrollArea>
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent
onThemeChange={onThemeChange}
theme={theme}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
</div>
</div>
);
}
function GeneralSettingsContent({
onThemeChange,
theme,
}: {
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
const [telemetryError, setTelemetryError] = useState<string | null>(null);
const loadGlobalSettings = useCallback(async () => {
setTelemetryLoading(true);
setTelemetryError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
} finally {
setTelemetryLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadGlobalSettings();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadGlobalSettings]);
const updateTelemetryOptOut = async (nextValue: boolean) => {
const previousValue = telemetryOptOut;
setTelemetryOptOut(nextValue);
setTelemetrySaving(true);
setTelemetryError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"set_telemetry_opt_out",
{
telemetry_opt_out: nextValue,
},
);
setTelemetryOptOut(settings.telemetryOptOut);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryOptOut(previousValue);
setTelemetryError(message);
} finally {
setTelemetrySaving(false);
}
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">General</h2>
</div>
<section className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Theme</p>
<p className="mt-1 text-xs text-muted-foreground">
Use the light or dark Cline Hub interface.
</p>
</div>
<div className="flex items-center gap-2 max-[720px]:justify-start">
<Button
onClick={() => onThemeChange("dark")}
type="button"
variant={theme === "dark" ? "default" : "outline"}
>
<Moon className="size-4" />
Dark
</Button>
<Button
onClick={() => onThemeChange("light")}
type="button"
variant={theme === "light" ? "default" : "outline"}
>
<Sun className="size-4" />
Light
</Button>
</div>
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Telemetry</p>
<p className="mt-1 text-xs text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update telemetry setting: {telemetryError}
</p>
) : null}
</div>
<Switch
aria-label="Telemetry opt-out"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
/>
</div>
</section>
</div>
</ScrollArea>
);
}
@@ -1,66 +0,0 @@
"use client";
import type { WebviewOutboundMessage } from "../../../webview-protocol";
import { postToHost } from "../vscode";
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
const REQUEST_TIMEOUT_MS = 120_000;
class HubDesktopClient {
private requestCounter = 0;
private readonly pending = new Map<string, PendingRequest>();
constructor() {
if (typeof window !== "undefined") {
window.addEventListener("message", (event) => {
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
});
}
}
private handleMessage(event: MessageEvent<WebviewOutboundMessage>) {
const message = event.data;
if (
!message ||
typeof message !== "object" ||
message.type !== "desktopCommandResult"
) {
return;
}
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(message.id);
if (message.ok) {
pending.resolve(message.result);
return;
}
pending.reject(new Error(message.error));
}
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timed out waiting for desktop command: ${command}`));
}, REQUEST_TIMEOUT_MS);
this.pending.set(id, {
resolve: (value) => resolve(value as T),
reject,
timeoutId,
});
postToHost({ type: "desktopCommand", id, command, args });
});
}
}
export const desktopClient = new HubDesktopClient();
@@ -1,70 +0,0 @@
export interface ProviderModel {
id: string;
name: string;
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
}
export type ProviderConfigFieldType =
| "text"
| "password"
| "url"
| "number"
| "select"
| "boolean";
export type ProviderConfigFieldPrimitive = string | number | boolean | null;
export interface ProviderConfigFieldOption {
label: string;
value: Exclude<ProviderConfigFieldPrimitive, null>;
}
export interface ProviderConfigField {
path: string;
label: string;
type: ProviderConfigFieldType;
description?: string;
placeholder?: string;
required?: boolean;
secret?: boolean;
options?: ProviderConfigFieldOption[];
defaultValue?: ProviderConfigFieldPrimitive;
}
export interface Provider {
id: string;
name: string;
models: number | null;
color: string;
letter: string;
enabled: boolean;
apiKey?: string;
oauthAccessTokenPresent?: boolean;
baseUrl?: string;
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
configValues?: Record<string, ProviderConfigFieldPrimitive>;
modelList?: ProviderModel[];
}
export interface ProviderSettingsUpdate {
apiKey?: string;
baseUrl?: string;
configValues?: Record<string, ProviderConfigFieldPrimitive>;
}
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
}
export interface ProviderModelsResponse {
providerId: string;
models: ProviderModel[];
}
-124
View File
@@ -1,124 +0,0 @@
import type {
WebviewInboundMessage,
WebviewOutboundMessage,
} from "../../webview-protocol";
type VsCodeApi = {
postMessage(message: WebviewInboundMessage): void;
getState(): unknown;
setState(state: unknown): void;
};
declare global {
interface Window {
acquireVsCodeApi?: () => VsCodeApi;
}
}
let cachedApi: VsCodeApi | undefined;
let browserSocket: WebSocket | undefined;
const pendingMessages: WebviewInboundMessage[] = [];
const stateKey = "cline-hub-webview-state";
function dispatchHostMessage(message: WebviewOutboundMessage): void {
window.dispatchEvent(new MessageEvent("message", { data: message }));
}
function createBrowserSocket(): WebSocket {
if (
browserSocket &&
(browserSocket.readyState === WebSocket.OPEN ||
browserSocket.readyState === WebSocket.CONNECTING)
) {
return browserSocket;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams();
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
if (roomSecret) {
params.set("roomSecret", roomSecret);
}
const query = params.toString();
browserSocket = new WebSocket(
`${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`,
);
browserSocket.addEventListener("open", () => {
for (const message of pendingMessages.splice(0)) {
browserSocket?.send(JSON.stringify(message));
}
});
browserSocket.addEventListener("message", (event) => {
try {
dispatchHostMessage(
JSON.parse(String(event.data)) as WebviewOutboundMessage,
);
} catch {
dispatchHostMessage({
type: "error",
text: "Received an invalid message from the Cline Hub server.",
});
}
});
browserSocket.addEventListener("close", () => {
dispatchHostMessage({
type: "status",
text: "Disconnected from the Cline Hub server.",
});
});
browserSocket.addEventListener("error", () => {
dispatchHostMessage({
type: "error",
text: "Failed to connect to the Cline Hub server.",
});
});
return browserSocket;
}
function createBrowserApi(): VsCodeApi {
return {
postMessage(message) {
const socket = createBrowserSocket();
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
return;
}
pendingMessages.push(message);
},
getState() {
try {
const raw = window.localStorage.getItem(stateKey);
return raw ? JSON.parse(raw) : undefined;
} catch {
return undefined;
}
},
setState(state) {
try {
window.localStorage.setItem(stateKey, JSON.stringify(state ?? {}));
} catch {
// Browser persistence is best-effort.
}
},
};
}
export function getVsCodeApi(): VsCodeApi | undefined {
if (cachedApi) {
return cachedApi;
}
if (typeof window.acquireVsCodeApi === "function") {
cachedApi = window.acquireVsCodeApi();
return cachedApi;
}
cachedApi = createBrowserApi();
return cachedApi;
}
export function postToHost(message: WebviewInboundMessage): void {
getVsCodeApi()?.postMessage(message);
}
export type { WebviewOutboundMessage };
-21
View File
@@ -1,21 +0,0 @@
{
"extends": "../../sdk/packages/tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["bun", "node"],
"paths": {
"@cline/core": ["../../sdk/packages/core/src/index.ts"],
"@cline/core/*": [
"../../sdk/packages/core/src/*",
"../../sdk/packages/core/src/*/index.ts"
],
"@cline/shared": ["../../sdk/packages/shared/src/index.ts"],
"@cline/shared/*": [
"../../sdk/packages/shared/src/*",
"../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["src/**/*.ts"],
"exclude": ["src/webview/**"]
}
@@ -1,23 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../sdk/packages/shared/src/*",
"../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["sidecar/**/*.ts", "scripts/**/*.ts", "global.d.ts", "bun.mts"],
"exclude": ["node_modules", "webview"]
}
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -1,464 +0,0 @@
"use client";
import {
ArrowLeft,
Copy,
Eye,
EyeOff,
Link as LinkIcon,
Loader2,
Paperclip,
PlusCircle,
RefreshCw,
Settings2,
Star,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import type {
Provider,
ProviderConfigField,
ProviderConfigFieldPrimitive,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
// -----------------------------------------------------------
// Provider LIST content (the grid of all providers)
// -----------------------------------------------------------
function getInitialConfigValues(
provider: Provider,
): Record<string, ProviderConfigFieldPrimitive> {
const values: Record<string, ProviderConfigFieldPrimitive> = {
...(provider.configValues ?? {}),
};
if (provider.apiKey !== undefined && values.apiKey === undefined) {
values.apiKey = provider.apiKey;
}
if (provider.baseUrl !== undefined && values.baseUrl === undefined) {
values.baseUrl = provider.baseUrl;
}
for (const field of provider.configFields ?? []) {
if (values[field.path] === undefined && field.defaultValue !== undefined) {
values[field.path] = field.defaultValue;
}
}
return values;
}
function fieldValueToString(value: ProviderConfigFieldPrimitive | undefined) {
if (value === undefined || value === null) return "";
return String(value);
}
function coerceFieldValue(
field: ProviderConfigField,
value: string | boolean,
): ProviderConfigFieldPrimitive {
if (field.type === "boolean") {
return Boolean(value);
}
if (typeof value === "boolean") {
return value;
}
if (field.type === "select") {
const option = field.options?.find((item) => String(item.value) === value);
if (option) {
return option.value;
}
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
if (field.type === "number") {
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return trimmed;
}
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
export function ProviderListContent({
providers,
onToggle,
onConfigure,
onAddProvider,
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
}) {
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
</div>
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
<div
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
key={prov.id}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{prov.name}
</p>
<p className="text-xs text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
</p>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
</div>
))}
</div>
</div>
</ScrollArea>
);
}
export function ProviderDetailContent({
provider,
onBack,
onUpdate,
onLoadModels,
modelsLoading = false,
modelsError,
onOAuthLogin,
oauthLoginPending = false,
}: {
provider: Provider;
onBack: () => void;
onUpdate: (updates: ProviderSettingsUpdate) => void;
onLoadModels?: () => void;
modelsLoading?: boolean;
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
Record<string, ProviderConfigFieldPrimitive>
>(() => getInitialConfigValues(provider));
useEffect(() => {
setLocalConfigValues(getInitialConfigValues(provider));
}, [provider]);
const configFields = provider.configFields ?? [];
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
const commitField = (
field: ProviderConfigField,
rawValue: string | boolean,
) => {
const value = coerceFieldValue(field, rawValue);
const nextConfigValues = {
...localConfigValues,
[field.path]: value,
};
setLocalConfigValues(nextConfigValues);
const updates: ProviderSettingsUpdate = {
configValues: { [field.path]: value },
};
if (field.path === "apiKey") {
updates.apiKey = fieldValueToString(value);
}
if (field.path === "baseUrl") {
updates.baseUrl = fieldValueToString(value);
}
onUpdate(updates);
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label="Back to providers"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">
{provider.name}
</h2>
</div>
{configFields.length > 0 ? (
<section className="mb-8">
<div className="flex flex-col gap-5">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<Switch
checked={Boolean(value)}
onCheckedChange={(checked) =>
commitField(field, checked)
}
/>
</div>
) : field.type === "select" ? (
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
value={valueText}
>
<option value="">Not set</option>
{field.options?.map((option) => (
<option
key={String(option.value)}
value={String(option.value)}
>
{option.label}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
...current,
[field.path]: event.target.value,
}))
}
placeholder={field.placeholder}
spellCheck={false}
type={
isSecret && !isShown
? "password"
: field.type === "number"
? "number"
: field.type === "url"
? "url"
: "text"
}
value={valueText}
/>
{isSecret ? (
<>
<Button
aria-label={
isShown ? "Hide secret" : "Show secret"
}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
setShownSecrets((current) => ({
...current,
[field.path]: !isShown,
}))
}
variant="ghost"
>
{isShown ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
aria-label={`Copy ${field.label}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
navigator.clipboard.writeText(valueText)
}
variant="ghost"
>
<Copy className="h-4 w-4" />
</Button>
</>
) : null}
</div>
)}
</div>
);
})}
</div>
</section>
) : null}
{!apiKeyValue && !provider.oauthAccessTokenPresent && onOAuthLogin ? (
<div className="mb-8">
<Button
className="inline-flex items-center gap-2 w-full"
disabled={oauthLoginPending}
onClick={onOAuthLogin}
variant="default"
>
{oauthLoginPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
<span>Login via Browser</span>
</Button>
</div>
) : null}
{provider.oauthAccessTokenPresent ? (
<p className="mb-8 text-xs text-muted-foreground">
OAuth is connected. Manual credentials remain available when this
provider supports them.
</p>
) : null}
{/* Models section */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<div className="flex items-center gap-1">
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
disabled={modelsLoading}
onClick={onLoadModels}
variant="ghost"
>
<RefreshCw
className={cn("size-3", modelsLoading && "animate-spin")}
/>
</Button>
</div>
</div>
{modelsError ? (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-destructive">{modelsError}</p>
</div>
) : provider.modelList && provider.modelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
{provider.modelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
{/* Model name */}
<span className="flex-1 text-sm text-foreground font-mono">
<div className="flex items-center gap-1.5">
{model.name}
{/* Capability icons */}
{model.supportsAttachments && (
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
)}
{model.supportsVision && (
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
</span>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
{modelsLoading
? "Loading models..."
: "No models available. Click refresh to load models."}
</p>
</div>
)}
</section>
</div>
</ScrollArea>
);
}
@@ -1,85 +0,0 @@
export const MODEL_SELECTION_STORAGE_KEY = "cline.code.model-selection.v1";
export type ModelSelectionStorage = {
lastProvider: string;
lastModelByProvider: Record<string, string>;
};
function sanitizeStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value).filter(
([key, entry]) =>
typeof key === "string" &&
typeof entry === "string" &&
entry.trim().length > 0,
),
);
}
export function parseModelSelectionStorage(
raw: string | null,
): ModelSelectionStorage {
const empty: ModelSelectionStorage = {
lastProvider: "",
lastModelByProvider: {},
};
if (!raw) {
return empty;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return empty;
}
const shaped = parsed as {
lastProvider?: unknown;
lastModelByProvider?: unknown;
};
if ("lastProvider" in shaped || "lastModelByProvider" in shaped) {
return {
lastProvider:
typeof shaped.lastProvider === "string"
? shaped.lastProvider.trim()
: "",
lastModelByProvider: sanitizeStringRecord(shaped.lastModelByProvider),
};
}
return {
lastProvider: "",
lastModelByProvider: sanitizeStringRecord(parsed),
};
} catch {
return empty;
}
}
export function readModelSelectionStorageFromWindow(): ModelSelectionStorage {
if (typeof window === "undefined") {
return {
lastProvider: "",
lastModelByProvider: {},
};
}
return parseModelSelectionStorage(
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
);
}
export function writeModelSelectionStorageToWindow(
value: ModelSelectionStorage,
): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(
MODEL_SELECTION_STORAGE_KEY,
JSON.stringify(value),
);
}
@@ -1,12 +0,0 @@
const PROVIDER_ID_ALIASES: Record<string, string> = {
openai: "openai-native",
google: "gemini",
};
export function normalizeProviderId(providerId: string): string {
const trimmed = providerId.trim();
if (!trimmed) {
return "";
}
return PROVIDER_ID_ALIASES[trimmed] ?? trimmed;
}
@@ -1,68 +0,0 @@
"use client";
import { desktopClient } from "@/lib/desktop-client";
import type {
Provider,
ProviderCatalogResponse,
ProviderModel,
ProviderModelsResponse,
} from "@/lib/provider-schema";
export type ProviderModelCatalog = {
providers: Provider[];
enabledProviderIds: string[];
providerModels: Record<string, string[]>;
providerReasoningModels: Record<string, string[]>;
};
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? []).map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) => model.supportsReasoning)
.map((model) => model.id);
}
export function buildProviderModelCatalog(
providers: Provider[],
): ProviderModelCatalog {
return {
providers,
enabledProviderIds: providers
.filter((provider) => provider.enabled)
.map((provider) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
provider.id,
toModelIds(provider.modelList),
]),
),
providerReasoningModels: Object.fromEntries(
providers.map((provider) => [
provider.id,
toReasoningModelIds(provider.modelList),
]),
),
};
}
export async function loadProviderModelCatalog(): Promise<ProviderModelCatalog> {
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
"list_provider_catalog",
);
return buildProviderModelCatalog(payload.providers ?? []);
}
export async function loadProviderModels(
providerId: string,
): Promise<ProviderModel[]> {
const payload = await desktopClient.invoke<ProviderModelsResponse>(
"list_provider_models",
{
provider: providerId,
},
);
return payload.models ?? [];
}
@@ -1,70 +0,0 @@
export interface ProviderModel {
id: string;
name: string;
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
}
export type ProviderConfigFieldType =
| "text"
| "password"
| "url"
| "number"
| "select"
| "boolean";
export type ProviderConfigFieldPrimitive = string | number | boolean | null;
export interface ProviderConfigFieldOption {
label: string;
value: Exclude<ProviderConfigFieldPrimitive, null>;
}
export interface ProviderConfigField {
path: string;
label: string;
type: ProviderConfigFieldType;
description?: string;
placeholder?: string;
required?: boolean;
secret?: boolean;
options?: ProviderConfigFieldOption[];
defaultValue?: ProviderConfigFieldPrimitive;
}
export interface Provider {
id: string;
name: string;
models: number | null;
color: string;
letter: string;
enabled: boolean;
apiKey?: string;
oauthAccessTokenPresent?: boolean;
baseUrl?: string;
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
configValues?: Record<string, ProviderConfigFieldPrimitive>;
modelList?: ProviderModel[];
}
export interface ProviderSettingsUpdate {
apiKey?: string;
baseUrl?: string;
configValues?: Record<string, ProviderConfigFieldPrimitive>;
}
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
}
export interface ProviderModelsResponse {
providerId: string;
models: ProviderModel[];
}
@@ -1,28 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@cline/agents": ["../../../../sdk/packages/agents/src/index.ts"],
"@cline/core": ["../../../../sdk/packages/core/src/index.ts"],
"@cline/llms": ["../../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../../sdk/packages/shared/src/*",
"../../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
-35
View File
@@ -1,35 +0,0 @@
{
"extends": "../../../sdk/packages/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"paths": {
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
"@cline/agents/*": [
"../../../sdk/packages/agents/src/*",
"../../../sdk/packages/agents/src/*/index.ts"
],
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
"@cline/core/hub/daemon-entry": [
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
],
"@cline/core/*": [
"../../../sdk/packages/core/src/*",
"../../../sdk/packages/core/src/*/index.ts"
],
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
"@cline/shared/storage": [
"../../../sdk/packages/shared/src/storage/index.ts"
],
"@cline/shared/db": ["../../../sdk/packages/shared/src/db/index.ts"],
"@cline/shared/*": [
"../../../sdk/packages/shared/src/*",
"../../../sdk/packages/shared/src/*/index.ts"
]
}
},
"include": ["sidecar/**/*.ts"]
}
@@ -1,29 +0,0 @@
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webview</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="92px" height="96px" viewBox="0 0 92 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Group Copy 2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="icon-copy" transform="translate(-34, -40)" fill="#24292F">
<g id="Group-Copy-2" transform="translate(34, 40.5)">
<g id="Group-3-Copy-4" transform="translate(0, 0)">
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" id="Combined-Shape" fill-rule="nonzero"></path>
<circle id="Oval" cx="45.7349843" cy="11" r="11"></circle>
</g>
<rect id="Rectangle-Copy" stroke="#24292F" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"></rect>
<rect id="Rectangle-Copy-2" stroke="#24292F" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"></rect>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,241 +0,0 @@
"use client";
import { UsersIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
Task,
TaskContent,
TaskItem,
TaskTrigger,
} from "@/components/ai-elements/task";
export type TeamToolEvent = {
id: string;
name: string;
state: "input-available" | "output-available" | "output-error";
input?: unknown;
output?: unknown;
error?: string;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: undefined;
}
function prefixForState(state: TeamToolEvent["state"]): string {
if (state === "output-error") {
return "Failed";
}
if (state === "output-available") {
return "Done";
}
return "Running";
}
function summarizeTeamTool(event: TeamToolEvent): ReactNode {
const input = asRecord(event.input);
const output = asRecord(event.output);
const statePrefix = prefixForState(event.state);
switch (event.name) {
case "team_spawn_teammate":
return `${statePrefix} spawn teammate ${asString(input?.agentId) ?? asString(output?.agentId) ?? "agent"}`;
case "team_shutdown_teammate":
return `${statePrefix} shutdown teammate ${asString(input?.agentId) ?? asString(output?.agentId) ?? "agent"}`;
case "team_status":
return `${statePrefix} fetch team status`;
case "team_task": {
const action = asString(input?.action);
if (action === "create") {
return `${statePrefix} create task ${asString(output?.taskId) ?? ""}${asString(input?.title) ? `: ${asString(input?.title)}` : ""}`.trim();
}
if (action === "list") {
return `${statePrefix} list team tasks`;
}
if (action === "claim") {
return `${statePrefix} claim task ${asString(input?.taskId) ?? asString(output?.taskId) ?? ""}`.trim();
}
if (action === "complete") {
return `${statePrefix} complete task ${asString(input?.taskId) ?? asString(output?.taskId) ?? ""}`.trim();
}
if (action === "block") {
return `${statePrefix} block task ${asString(input?.taskId) ?? asString(output?.taskId) ?? ""}`.trim();
}
return `${statePrefix} update team task`;
}
case "team_run_task": {
const agentId =
asString(input?.agentId) ?? asString(output?.agentId) ?? "agent";
const mode = asString(output?.mode) ?? asString(input?.runMode);
const status = asString(output?.status);
const task = asString(input?.task);
const suffix = task ? `: ${task}` : "";
const action =
mode === "async" ? "queue" : status === "joined" ? "join" : "run";
const state = status ? ` (${status})` : "";
return `${statePrefix} ${action} task with ${agentId}${state}${suffix}`;
}
case "team_cancel_run":
return `${statePrefix} cancel run ${asString(input?.runId) ?? asString(output?.runId) ?? ""}`.trim();
case "team_list_runs":
return `${statePrefix} list teammate runs`;
case "team_await_run":
return `${statePrefix} await run ${asString(input?.runId) ?? ""}`.trim();
case "team_await_all_runs":
return `${statePrefix} await all active runs`;
case "team_send_message":
return `${statePrefix} message ${asString(input?.toAgentId) ?? asString(output?.toAgentId) ?? "agent"}${asString(input?.subject) ? `: ${asString(input?.subject)}` : ""}`;
case "team_broadcast":
return `${statePrefix} broadcast${asString(input?.subject) ? `: ${asString(input?.subject)}` : ""}`;
case "team_read_mailbox":
return `${statePrefix} read mailbox`;
case "team_mission_log":
return `${statePrefix} log ${asString(input?.kind) ?? "update"}${asString(input?.summary) ? `: ${asString(input?.summary)}` : ""}`;
case "team_cleanup":
return `${statePrefix} clean up team runtime`;
case "team_create_outcome":
return `${statePrefix} create outcome${asString(input?.title) ? `: ${asString(input?.title)}` : ""}`;
case "team_attach_outcome_fragment":
return `${statePrefix} attach fragment to ${asString(input?.section) ?? "section"}`;
case "team_review_outcome_fragment":
return `${statePrefix} ${input?.approved === false ? "reject" : "review"} fragment ${asString(input?.fragmentId) ?? ""}`.trim();
case "team_finalize_outcome":
return `${statePrefix} finalize outcome ${asString(input?.outcomeId) ?? asString(output?.outcomeId) ?? ""}`.trim();
case "team_list_outcomes":
return `${statePrefix} list outcomes`;
default:
return `${statePrefix} ${event.name.replace(/^team_/, "").replaceAll("_", " ")}`;
}
}
function describeTeamTool(event: TeamToolEvent): string | undefined {
if (event.error) {
return event.error;
}
const input = asRecord(event.input);
const output = asRecord(event.output);
switch (event.name) {
case "team_task": {
const action = asString(input?.action);
if (action === "create") {
return asString(input?.description);
}
if (action === "block") {
return asString(input?.reason);
}
if (action === "list") {
const tasks = Array.isArray(output?.tasks)
? output.tasks.length
: undefined;
return typeof tasks === "number"
? `${tasks} task${tasks === 1 ? "" : "s"}`
: undefined;
}
return undefined;
}
case "team_run_task":
return (
asString(output?.message) ??
asString(output?.runId) ??
asString(output?.text)
);
case "team_send_message":
case "team_broadcast":
return asString(input?.body);
case "team_mission_log":
return asString(input?.nextAction) ?? asString(input?.summary);
case "team_attach_outcome_fragment":
return asString(input?.content);
case "team_status": {
const members = Array.isArray(output?.members)
? output.members.length
: undefined;
const tasks = Array.isArray(output?.tasks)
? output.tasks.length
: undefined;
const runs = Array.isArray(output?.runs) ? output.runs.length : undefined;
const parts = [
typeof members === "number" ? `${members} members` : undefined,
typeof tasks === "number" ? `${tasks} tasks` : undefined,
typeof runs === "number" ? `${runs} runs` : undefined,
].filter(Boolean);
return parts.join(" • ") || undefined;
}
case "team_list_runs": {
const runs = Array.isArray(event.output) ? event.output : undefined;
if (!runs?.length) {
return "No runs";
}
return `${runs.length} run${runs.length === 1 ? "" : "s"}`;
}
case "team_read_mailbox": {
const messages = Array.isArray(event.output) ? event.output : undefined;
if (!messages?.length) {
return "No messages";
}
return `${messages.length} message${messages.length === 1 ? "" : "s"}`;
}
case "team_create_outcome": {
const sections = asStringArray(input?.requiredSections);
return sections?.length ? sections.join(", ") : undefined;
}
default:
return undefined;
}
}
export default function TeamTasks({
className,
defaultOpen = true,
events,
...props
}: Omit<ComponentProps<typeof Task>, "children"> & {
events: TeamToolEvent[];
}) {
if (events.length === 0) {
return null;
}
const title =
events.length === 1 ? "Team activity" : `Team activity (${events.length})`;
return (
<Task className={className} defaultOpen={defaultOpen} {...props}>
<TaskTrigger title={title}>
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<UsersIcon className="size-4" />
<p className="text-sm">{title}</p>
</div>
</TaskTrigger>
<TaskContent>
{events.map((event) => {
const description = describeTeamTool(event);
return (
<TaskItem className="space-y-1" key={event.id}>
<div>{summarizeTeamTool(event)}</div>
{description ? (
<div className="line-clamp-3 text-xs text-muted-foreground/90">
{description}
</div>
) : null}
</TaskItem>
);
})}
</TaskContent>
</Task>
);
}
@@ -1,141 +0,0 @@
"use client";
import type { Tool } from "ai";
import { BotIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { memo } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type AgentProps = ComponentProps<"div">;
export const Agent = memo(({ className, ...props }: AgentProps) => (
<div
className={cn("not-prose w-full rounded-md border", className)}
{...props}
/>
));
export type AgentHeaderProps = ComponentProps<"div"> & {
name: string;
model?: string;
};
export const AgentHeader = memo(
({ className, name, model, ...props }: AgentHeaderProps) => (
<div
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<BotIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{name}</span>
{model && (
<Badge className="font-mono text-xs" variant="secondary">
{model}
</Badge>
)}
</div>
</div>
),
);
export type AgentContentProps = ComponentProps<"div">;
export const AgentContent = memo(
({ className, ...props }: AgentContentProps) => (
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
),
);
export type AgentInstructionsProps = ComponentProps<"div"> & {
children: string;
};
export const AgentInstructions = memo(
({ className, children, ...props }: AgentInstructionsProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">
Instructions
</span>
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
<p>{children}</p>
</div>
</div>
),
);
export type AgentToolsProps = ComponentProps<typeof Accordion>;
export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
<div className={cn("space-y-2", className)}>
<span className="font-medium text-muted-foreground text-sm">Tools</span>
<Accordion className="rounded-md border" {...props} />
</div>
));
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
tool: Tool;
};
export const AgentTool = memo(
({ className, tool, value, ...props }: AgentToolProps) => {
const schema =
"jsonSchema" in tool && tool.jsonSchema
? tool.jsonSchema
: tool.inputSchema;
return (
<AccordionItem
className={cn("border-b last:border-b-0", className)}
value={value}
{...props}
>
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
{tool.description ?? "No description"}
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(schema, null, 2)} language="json" />
</div>
</AccordionContent>
</AccordionItem>
);
},
);
export type AgentOutputProps = ComponentProps<"div"> & {
schema: string;
};
export const AgentOutput = memo(
({ className, schema, ...props }: AgentOutputProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">
Output Schema
</span>
<div className="rounded-md bg-muted/50">
<CodeBlock code={schema} language="typescript" />
</div>
</div>
),
);
Agent.displayName = "Agent";
AgentHeader.displayName = "AgentHeader";
AgentContent.displayName = "AgentContent";
AgentInstructions.displayName = "AgentInstructions";
AgentTools.displayName = "AgentTools";
AgentTool.displayName = "AgentTool";
AgentOutput.displayName = "AgentOutput";
@@ -1,148 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className,
)}
{...props}
/>
);
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className,
)}
{...props}
/>
);
export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className,
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className,
)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);
@@ -1,425 +0,0 @@
"use client";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData,
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean,
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className,
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant],
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className,
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className,
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove],
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className,
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard> & {
openDelay?: number;
closeDelay?: number;
};
export const AttachmentHoverCard = ({ ...props }: AttachmentHoverCardProps) => (
<HoverCard {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps,
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className,
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
@@ -1,255 +0,0 @@
"use client";
import type { Experimental_SpeechResult as SpeechResult } from "ai";
import {
MediaControlBar,
MediaController,
MediaDurationDisplay,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react";
import type { ComponentProps, CSSProperties } from "react";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { cn } from "@/lib/utils";
export type AudioPlayerProps = Omit<
ComponentProps<typeof MediaController>,
"audio"
>;
export const AudioPlayer = ({
children,
style,
...props
}: AudioPlayerProps) => (
<MediaController
audio
data-slot="audio-player"
style={
{
"--media-background-color": "transparent",
"--media-button-icon-height": "1rem",
"--media-button-icon-width": "1rem",
"--media-control-background": "transparent",
"--media-control-hover-background": "var(--color-accent)",
"--media-control-padding": "0",
"--media-font": "var(--font-sans)",
"--media-font-size": "10px",
"--media-icon-color": "currentColor",
"--media-preview-time-background": "var(--color-background)",
"--media-preview-time-border-radius": "var(--radius-md)",
"--media-preview-time-text-shadow": "none",
"--media-primary-color": "var(--color-primary)",
"--media-range-bar-color": "var(--color-primary)",
"--media-range-track-background": "var(--color-secondary)",
"--media-secondary-color": "var(--color-secondary)",
"--media-text-color": "var(--color-foreground)",
"--media-tooltip-arrow-display": "none",
"--media-tooltip-background": "var(--color-background)",
"--media-tooltip-border-radius": "var(--radius-md)",
...style,
} as CSSProperties
}
{...props}
>
{children}
</MediaController>
);
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
(
| {
data: SpeechResult["audio"];
}
| {
src: string;
}
);
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
// oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
<audio
data-slot="audio-player-element"
slot="media"
src={
"src" in props
? props.src
: `data:${props.data.mediaType};base64,${props.data.base64}`
}
{...props}
/>
);
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
export const AudioPlayerControlBar = ({
children,
...props
}: AudioPlayerControlBarProps) => (
<MediaControlBar data-slot="audio-player-control-bar" {...props}>
<ButtonGroup orientation="horizontal">{children}</ButtonGroup>
</MediaControlBar>
);
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
export const AudioPlayerPlayButton = ({
className,
...props
}: AudioPlayerPlayButtonProps) => (
<Button
size="icon-sm"
variant="outline"
render={
<MediaPlayButton
className={cn("bg-transparent", className)}
data-slot="audio-player-play-button"
{...props}
/>
}
></Button>
);
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<
typeof MediaSeekBackwardButton
>;
export const AudioPlayerSeekBackwardButton = ({
seekOffset = 10,
...props
}: AudioPlayerSeekBackwardButtonProps) => (
<Button
size="icon-sm"
variant="outline"
render={
<MediaSeekBackwardButton
data-slot="audio-player-seek-backward-button"
seekOffset={seekOffset}
{...props}
/>
}
></Button>
);
export type AudioPlayerSeekForwardButtonProps = ComponentProps<
typeof MediaSeekForwardButton
>;
export const AudioPlayerSeekForwardButton = ({
seekOffset = 10,
...props
}: AudioPlayerSeekForwardButtonProps) => (
<Button
size="icon-sm"
variant="outline"
render={
<MediaSeekForwardButton
data-slot="audio-player-seek-forward-button"
seekOffset={seekOffset}
{...props}
/>
}
></Button>
);
export type AudioPlayerTimeDisplayProps = ComponentProps<
typeof MediaTimeDisplay
>;
export const AudioPlayerTimeDisplay = ({
className,
...props
}: AudioPlayerTimeDisplayProps) => (
<ButtonGroupText
className="bg-transparent"
render={
<MediaTimeDisplay
className={cn("tabular-nums", className)}
data-slot="audio-player-time-display"
{...props}
/>
}
></ButtonGroupText>
);
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
export const AudioPlayerTimeRange = ({
className,
...props
}: AudioPlayerTimeRangeProps) => (
<ButtonGroupText
className="bg-transparent"
render={
<MediaTimeRange
className={cn("", className)}
data-slot="audio-player-time-range"
{...props}
/>
}
></ButtonGroupText>
);
export type AudioPlayerDurationDisplayProps = ComponentProps<
typeof MediaDurationDisplay
>;
export const AudioPlayerDurationDisplay = ({
className,
...props
}: AudioPlayerDurationDisplayProps) => (
<ButtonGroupText
className="bg-transparent"
render={
<MediaDurationDisplay
className={cn("tabular-nums", className)}
data-slot="audio-player-duration-display"
{...props}
/>
}
></ButtonGroupText>
);
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
export const AudioPlayerMuteButton = ({
className,
...props
}: AudioPlayerMuteButtonProps) => (
<ButtonGroupText
className="bg-transparent"
render={
<MediaMuteButton
className={cn("", className)}
data-slot="audio-player-mute-button"
{...props}
/>
}
></ButtonGroupText>
);
export type AudioPlayerVolumeRangeProps = ComponentProps<
typeof MediaVolumeRange
>;
export const AudioPlayerVolumeRange = ({
className,
...props
}: AudioPlayerVolumeRangeProps) => (
<ButtonGroupText
className="bg-transparent"
render={
<MediaVolumeRange
className={cn("", className)}
data-slot="audio-player-volume-range"
{...props}
/>
}
></ButtonGroupText>
);
@@ -1,26 +0,0 @@
import type { ReactFlowProps } from "@xyflow/react";
import { Background, ReactFlow } from "@xyflow/react";
import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css";
type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};
const deleteKeyCode = ["Backspace", "Delete"];
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={deleteKeyCode}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
);
@@ -1,222 +0,0 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import type { LucideIcon } from "lucide-react";
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface ChainOfThoughtContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null,
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought",
);
}
return context;
};
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen],
);
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
},
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
},
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
const stepStatusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => (
<div
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
),
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
),
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
),
);
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
},
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
),
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
@@ -1,73 +0,0 @@
"use client";
import type { LucideProps } from "lucide-react";
import { BookmarkIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
export const Checkpoint = ({
className,
children,
...props
}: CheckpointProps) => (
<div
className={cn(
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
className,
)}
{...props}
>
{children}
<Separator />
</div>
);
export type CheckpointIconProps = LucideProps;
export const CheckpointIcon = ({
className,
children,
...props
}: CheckpointIconProps) =>
children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
);
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const CheckpointTrigger = ({
children,
variant = "ghost",
size = "sm",
tooltip,
...props
}: CheckpointTriggerProps) =>
tooltip ? (
<Tooltip>
<TooltipTrigger
render={
<Button size={size} type="button" variant={variant} {...props} />
}
>
{children}
</TooltipTrigger>
<TooltipContent align="start" side="bottom">
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
);
@@ -1,558 +0,0 @@
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)!"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none",
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
],
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg],
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens],
);
return (
<pre
className={cn(
"dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)! m-0 p-4 text-sm",
className,
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers &&
"[counter-increment:line_0] [counter-reset:line]",
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className,
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-sm border bg-background text-foreground",
className,
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className,
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens],
);
// Async highlighting — keyed by identity-stable memo so stale tokens are
// discarded without reading a ref during render or setState in effect body.
const asyncKey = useMemo(() => ({ code, language }), [code, language]);
const [asyncState, setAsyncState] = useState<{
key: { code: string; language: string };
tokens: TokenizedCode | null;
}>({ key: asyncKey, tokens: null });
const asyncTokens = asyncState.key === asyncKey ? asyncState.tokens : null;
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncState({ key: asyncKey, tokens: result });
}
});
return () => {
cancelled = true;
};
}, [code, language, asyncKey]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout,
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps,
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className,
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps,
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps,
) => <SelectItem {...props} />;
@@ -1,462 +0,0 @@
"use client";
import {
CheckIcon,
CopyIcon,
FileIcon,
GitCommitIcon,
MinusIcon,
PlusIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type CommitProps = ComponentProps<typeof Collapsible>;
export const Commit = ({ className, children, ...props }: CommitProps) => (
<Collapsible
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children}
</Collapsible>
);
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
export const CommitHeader = ({
className,
children,
...props
}: CommitHeaderProps) => (
<CollapsibleTrigger
{...props}
render={
<div
className={cn(
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
className,
)}
/>
}
>
{children}
</CollapsibleTrigger>
);
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
export const CommitHash = ({
className,
children,
...props
}: CommitHashProps) => (
<span className={cn("font-mono text-xs", className)} {...props}>
<GitCommitIcon className="mr-1 inline-block size-3" />
{children}
</span>
);
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
export const CommitMessage = ({
className,
children,
...props
}: CommitMessageProps) => (
<span className={cn("font-medium text-sm", className)} {...props}>
{children}
</span>
);
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
export const CommitMetadata = ({
className,
children,
...props
}: CommitMetadataProps) => (
<div
className={cn(
"flex items-center gap-2 text-muted-foreground text-xs",
className,
)}
{...props}
>
{children}
</div>
);
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
export const CommitSeparator = ({
className,
children,
...props
}: CommitSeparatorProps) => (
<span className={className} {...props}>
{children ?? "•"}
</span>
);
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitInfo = ({
className,
children,
...props
}: CommitInfoProps) => (
<div className={cn("flex flex-1 flex-col", className)} {...props}>
{children}
</div>
);
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
export const CommitAuthor = ({
className,
children,
...props
}: CommitAuthorProps) => (
<div className={cn("flex items-center", className)} {...props}>
{children}
</div>
);
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
initials: string;
};
export const CommitAuthorAvatar = ({
initials,
className,
...props
}: CommitAuthorAvatarProps) => (
<Avatar className={cn("size-8", className)} {...props}>
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
);
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
date: Date;
};
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
});
const formatRelativeDate = (date: Date) => {
const days = Math.round(
(date.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
);
return relativeTimeFormat.format(days, "day");
};
export const CommitTimestamp = ({
date,
className,
children,
...props
}: CommitTimestampProps) => {
const [formatted, setFormatted] = useState("");
const updateFormatted = useCallback(() => {
setFormatted(formatRelativeDate(date));
}, [date]);
useEffect(() => {
updateFormatted();
}, [updateFormatted]);
return (
<time
className={cn("text-xs", className)}
dateTime={date.toISOString()}
{...props}
>
{children ?? formatted}
</time>
);
};
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
export const CommitActions = ({
className,
children,
...props
}: CommitActionsProps) => (
// biome-ignore lint/a11y/useSemanticElements: fieldset would break layout styling
<div
className={cn("flex items-center gap-1", className)}
onClick={handleActionsClick}
onKeyDown={handleActionsKeyDown}
role="group"
{...props}
>
{children}
</div>
);
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
hash: string;
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CommitCopyButton = ({
hash,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CommitCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(hash);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout,
);
}
} catch (error) {
onError?.(error as Error);
}
}, [hash, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("size-7 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
export const CommitContent = ({
className,
children,
...props
}: CommitContentProps) => (
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
{children}
</CollapsibleContent>
);
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFiles = ({
className,
children,
...props
}: CommitFilesProps) => (
<div className={cn("space-y-1", className)} {...props}>
{children}
</div>
);
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
export const CommitFile = ({
className,
children,
...props
}: CommitFileProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
className,
)}
{...props}
>
{children}
</div>
);
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileInfo = ({
className,
children,
...props
}: CommitFileInfoProps) => (
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
{children}
</div>
);
const fileStatusStyles = {
added: "text-green-600 dark:text-green-400",
deleted: "text-red-600 dark:text-red-400",
modified: "text-yellow-600 dark:text-yellow-400",
renamed: "text-blue-600 dark:text-blue-400",
};
const fileStatusLabels = {
added: "A",
deleted: "D",
modified: "M",
renamed: "R",
};
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: "added" | "modified" | "deleted" | "renamed";
};
export const CommitFileStatus = ({
status,
className,
children,
...props
}: CommitFileStatusProps) => (
<span
className={cn(
"font-medium font-mono text-xs",
fileStatusStyles[status],
className,
)}
{...props}
>
{children ?? fileStatusLabels[status]}
</span>
);
export type CommitFileIconProps = ComponentProps<typeof FileIcon>;
export const CommitFileIcon = ({
className,
...props
}: CommitFileIconProps) => (
<FileIcon
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
{...props}
/>
);
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
export const CommitFilePath = ({
className,
children,
...props
}: CommitFilePathProps) => (
<span className={cn("truncate font-mono text-xs", className)} {...props}>
{children}
</span>
);
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileChanges = ({
className,
children,
...props
}: CommitFileChangesProps) => (
<div
className={cn(
"flex shrink-0 items-center gap-1 font-mono text-xs",
className,
)}
{...props}
>
{children}
</div>
);
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number;
};
export const CommitFileAdditions = ({
count,
className,
children,
...props
}: CommitFileAdditionsProps) => {
if (count <= 0) {
return null;
}
return (
<span
className={cn("text-green-600 dark:text-green-400", className)}
{...props}
>
{children ?? (
<>
<PlusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
);
};
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number;
};
export const CommitFileDeletions = ({
count,
className,
children,
...props
}: CommitFileDeletionsProps) => {
if (count <= 0) {
return null;
}
return (
<span
className={cn("text-red-600 dark:text-red-400", className)}
{...props}
>
{children ?? (
<>
<MinusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
);
};
@@ -1,174 +0,0 @@
"use client";
import type { ToolUIPart } from "ai";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useMemo } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ToolUIPartApproval =
| {
id: string;
approved?: never;
reason?: never;
}
| {
id: string;
approved: boolean;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: false;
reason?: string;
}
| undefined;
interface ConfirmationContextValue {
approval: ToolUIPartApproval;
state: ToolUIPart["state"];
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
null,
);
const useConfirmation = () => {
const context = useContext(ConfirmationContext);
if (!context) {
throw new Error("Confirmation components must be used within Confirmation");
}
return context;
};
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval;
state: ToolUIPart["state"];
};
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
if (!approval || state === "input-streaming" || state === "input-available") {
return null;
}
return (
<ConfirmationContext.Provider value={contextValue}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
);
};
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
);
export interface ConfirmationRequestProps {
children?: ReactNode;
}
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return children;
};
export interface ConfirmationAcceptedProps {
children?: ReactNode;
}
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation();
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export interface ConfirmationRejectedProps {
children?: ReactNode;
}
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation();
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationActionsProps = ComponentProps<"div">;
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return (
<div
className={cn("flex items-center justify-end gap-2 self-end", className)}
{...props}
/>
);
};
export type ConfirmationActionProps = ComponentProps<typeof Button>;
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
);
@@ -1,28 +0,0 @@
import type { ConnectionLineComponent } from "@xyflow/react";
const HALF = 0.5;
export const Connection: ConnectionLineComponent = ({
fromX,
fromY,
toX,
toY,
}) => (
<g>
<path
className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none"
stroke="var(--color-ring)"
strokeWidth={1}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="var(--color-ring)"
strokeWidth={1}
/>
</g>
);
@@ -1,409 +0,0 @@
"use client";
import type { LanguageModelUsage } from "ai";
import type { ComponentProps } from "react";
import { createContext, useContext, useMemo } from "react";
import { getUsage } from "tokenlens";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
const PERCENT_MAX = 100;
const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2;
type ModelId = string;
interface ContextSchema {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: ModelId;
}
const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => {
const context = useContext(ContextContext);
if (!context) {
throw new Error("Context components must be used within Context");
}
return context;
};
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => {
const contextValue = useMemo(
() => ({ maxTokens, modelId, usage, usedTokens }),
[maxTokens, modelId, usage, usedTokens],
);
return (
<ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
};
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent);
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
/>
</svg>
);
};
export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
style: "percent",
}).format(usedPercent);
return (
<HoverCardTrigger>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">
{renderedPercent}
</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
);
};
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props}
/>
);
export type ContextContentHeaderProps = ComponentProps<"div">;
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const displayPct = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
style: "percent",
}).format(usedPercent);
const used = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(usedTokens);
const total = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(maxTokens);
return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
);
};
export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
);
export type ContextContentFooterProps = ComponentProps<"div">;
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue();
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined;
const totalCost = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(costUSD ?? 0);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className,
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
);
};
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number;
costText?: string;
}) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span>
) : null}
</span>
);
export type ContextInputUsageProps = ComponentProps<"div">;
export const ContextInputUsage = ({
className,
children,
...props
}: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0;
if (children) {
return children;
}
if (!inputTokens) {
return null;
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined;
const inputCostText = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(inputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
);
};
export type ContextOutputUsageProps = ComponentProps<"div">;
export const ContextOutputUsage = ({
className,
children,
...props
}: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0;
if (children) {
return children;
}
if (!outputTokens) {
return null;
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined;
const outputCostText = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(outputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
);
};
export type ContextReasoningUsageProps = ComponentProps<"div">;
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue();
const reasoningTokens = usage?.outputTokenDetails?.reasoningTokens ?? 0;
if (children) {
return children;
}
if (!reasoningTokens) {
return null;
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined;
const reasoningCostText = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(reasoningCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
);
};
export type ContextCacheUsageProps = ComponentProps<"div">;
export const ContextCacheUsage = ({
className,
children,
...props
}: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue();
const cacheTokens = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
if (children) {
return children;
}
if (!cacheTokens) {
return null;
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined;
const cacheCostText = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(cacheCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More