mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d6301884 | ||
|
|
d71f097656 | ||
|
|
6539f4deea | ||
|
|
036fc75b1f | ||
|
|
6fc40127a6 | ||
|
|
110138b540 | ||
|
|
3497391c5a | ||
|
|
9154a54a0e | ||
|
|
7d004f8dc7 | ||
|
|
432e00eaa6 | ||
|
|
095385b985 |
@@ -0,0 +1,50 @@
|
||||
name: desktop-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dmg-background:
|
||||
name: Test DMG background tooling
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/examples/desktop-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# The suite only uses Bun/Node built-ins and committed artwork, so it does
|
||||
# not need a workspace dependency install or macOS runner.
|
||||
- name: Test DMG background tooling
|
||||
run: bun run test:dmg-background
|
||||
@@ -88,6 +88,7 @@ apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
|
||||
|
||||
const workspaceDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of workspaceDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
@@ -43,3 +52,39 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
it("includes git remotes and the latest commit for Cline requests", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
execFileSync("git", ["init"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
|
||||
writeFileSync(join(cwd, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd });
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
|
||||
expect(prompt).toContain(commit);
|
||||
});
|
||||
|
||||
it("includes parseable metadata outside a project", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).toContain(JSON.stringify(cwd));
|
||||
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
|
||||
expect(prompt).not.toContain("associatedRemoteUrls");
|
||||
expect(prompt).not.toContain("latestGitCommitHash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.18
|
||||
|
||||
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
|
||||
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
|
||||
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
|
||||
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
|
||||
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
|
||||
- "Run now" now hands you into the session it starts
|
||||
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
|
||||
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
|
||||
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
|
||||
- Fixed quitting the app beach-balling for several seconds
|
||||
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
|
||||
- Fixed hover cards flashing closed and reopening when clicked
|
||||
- The macOS DMG install window now has custom Cline artwork and layout
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
|
||||
## 0.0.17
|
||||
|
||||
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
|
||||
|
||||
@@ -17,6 +17,38 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
|
||||
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
|
||||
`background.gen.tiff` Finder actually renders is a gitignored build artifact
|
||||
regenerated from them on every build.
|
||||
|
||||
1. The current source artwork is `640x400`. Export `background.png` at 1x and
|
||||
`background@2x.png` at 2x.
|
||||
2. Currently the app icons are centered at `(140, 200)` and
|
||||
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
|
||||
and `applicationFolderPosition` to reposition the app icons.
|
||||
3. Build with `bun run build:binary`. Before compiling, the build validates
|
||||
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
|
||||
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
|
||||
expected 1x and 2x representations. Run `bun run dmg:background` to do just
|
||||
that step, e.g. to sanity-check new artwork without a full build. The DMG
|
||||
is written beneath `src-tauri/target/release/bundle/dmg/`.
|
||||
|
||||
Run `bun run test:dmg-background` for the cross-platform checks covering the
|
||||
committed PNG dimensions and TIFF validation logic.
|
||||
|
||||
The configured `640x432` Finder window is intentionally 32 points taller than
|
||||
the `640x400` background. That extra height matches the Finder chrome in the
|
||||
currently verified packaged layout; re-check it after material macOS or Finder
|
||||
changes. The project deliberately uses a multi-resolution TIFF even though
|
||||
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
|
||||
both the 1x and 2x representations from a single background file. Re-check the
|
||||
packaged DMG after upgrading Tauri in case its background validation changes.
|
||||
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.17",
|
||||
"version": "0.0.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -15,6 +15,8 @@
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"dmg:background": "bun run scripts/dmg-background.ts",
|
||||
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseTiffInfo,
|
||||
readPngDimensions,
|
||||
validateTiffRepresentations,
|
||||
} from "./dmg-background";
|
||||
|
||||
const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg");
|
||||
|
||||
const EXPECTED_REPRESENTATIONS = [
|
||||
{ width: 640, height: 400, dpiX: 72, dpiY: 72 },
|
||||
{ width: 1280, height: 800, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const TIFF_INFO = `Directory at 0x1
|
||||
Image Width: 640 Image Length: 400
|
||||
Resolution: 72, 72
|
||||
Resolution Unit: pixels/inch
|
||||
Directory at 0x2
|
||||
Image Width: 1280 Image Length: 800
|
||||
Resolution: 144, 144
|
||||
Resolution Unit: pixels/inch
|
||||
`;
|
||||
|
||||
describe("parseTiffInfo", () => {
|
||||
test("reads the dimensions and DPI of every TIFF representation", () => {
|
||||
expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS);
|
||||
});
|
||||
|
||||
test("rejects representations without pixel-per-inch resolution", () => {
|
||||
expect(() =>
|
||||
parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")),
|
||||
).toThrow(/could not parse TIFF representation/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMG source artwork", () => {
|
||||
test("has the expected 1x and 2x dimensions", async () => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(path.join(DMG_ROOT, "background.png")),
|
||||
readPngDimensions(path.join(DMG_ROOT, "background@2x.png")),
|
||||
]);
|
||||
|
||||
expect(dimensions1x).toEqual({ width: 640, height: 400 });
|
||||
expect(dimensions2x).toEqual({ width: 1280, height: 800 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTiffRepresentations", () => {
|
||||
test("accepts the expected representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects the wrong number of representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)),
|
||||
).toThrow(/exactly two image representations/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation dimensions", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
EXPECTED_REPRESENTATIONS[0],
|
||||
{ ...EXPECTED_REPRESENTATIONS[1], width: 1279 },
|
||||
]),
|
||||
).toThrow(/must be 1280x800/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation DPI", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
{ ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 },
|
||||
EXPECTED_REPRESENTATIONS[1],
|
||||
]),
|
||||
).toThrow(/must be 72x72 DPI/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type Dimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TiffRepresentation = Dimensions & {
|
||||
dpiX: number;
|
||||
dpiY: number;
|
||||
};
|
||||
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg");
|
||||
const BACKGROUND_1X = path.join(DMG_ROOT, "background.png");
|
||||
const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png");
|
||||
// Gitignored build artifact; only the PNG sources are committed.
|
||||
const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff");
|
||||
|
||||
const EXPECTED_1X = { width: 640, height: 400 };
|
||||
const EXPECTED_2X = { width: 1280, height: 800 };
|
||||
const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [
|
||||
{ ...EXPECTED_1X, dpiX: 72, dpiY: 72 },
|
||||
{ ...EXPECTED_2X, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
|
||||
// PNG stores its big-endian width and height in the fixed IHDR fields at
|
||||
// byte offsets 16 and 20, so dimensions can be checked without an image library.
|
||||
export const readPngDimensions = async (
|
||||
filePath: string,
|
||||
): Promise<Dimensions> => {
|
||||
const contents = await readFile(filePath);
|
||||
const hasPngSignature = PNG_SIGNATURE.every(
|
||||
(byte, index) => contents[index] === byte,
|
||||
);
|
||||
if (
|
||||
contents.length < 24 ||
|
||||
!hasPngSignature ||
|
||||
contents.toString("ascii", 12, 16) !== "IHDR"
|
||||
) {
|
||||
throw new Error(`${filePath} is not a valid PNG with an IHDR header`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: contents.readUInt32BE(16),
|
||||
height: contents.readUInt32BE(20),
|
||||
};
|
||||
};
|
||||
|
||||
const assertDimensions = (
|
||||
label: string,
|
||||
actual: Dimensions,
|
||||
expected: Dimensions,
|
||||
): void => {
|
||||
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||
throw new Error(
|
||||
`${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// tiffutil prints one "Directory at ..." block for each image representation
|
||||
// embedded in the TIFF.
|
||||
export const parseTiffInfo = (output: string): TiffRepresentation[] =>
|
||||
output
|
||||
.split(/(?=Directory at )/)
|
||||
.filter((block) => block.startsWith("Directory at "))
|
||||
.map((block) => {
|
||||
const dimensions = block.match(
|
||||
/Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/,
|
||||
);
|
||||
const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/);
|
||||
if (
|
||||
!dimensions ||
|
||||
!resolution ||
|
||||
!block.includes("Resolution Unit: pixels/inch")
|
||||
) {
|
||||
throw new Error(`could not parse TIFF representation:\n${block}`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: Number(dimensions[1]),
|
||||
height: Number(dimensions[2]),
|
||||
dpiX: Number(resolution[1]),
|
||||
dpiY: Number(resolution[2]),
|
||||
};
|
||||
});
|
||||
|
||||
export const validateTiffRepresentations = (
|
||||
representations: TiffRepresentation[],
|
||||
label = "TIFF",
|
||||
): void => {
|
||||
const sortedRepresentations = [...representations].sort(
|
||||
(left, right) => left.width - right.width,
|
||||
);
|
||||
|
||||
if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) {
|
||||
throw new Error(
|
||||
`${label} must contain exactly two image representations, got ${sortedRepresentations.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) {
|
||||
const actual = sortedRepresentations[index];
|
||||
assertDimensions(`${label} representation ${index + 1}`, actual, expected);
|
||||
if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) {
|
||||
throw new Error(
|
||||
`${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const assertTiffRepresentations = async (filePath: string): Promise<void> => {
|
||||
const representations = parseTiffInfo(
|
||||
await $`tiffutil -info ${filePath}`.quiet().text(),
|
||||
);
|
||||
validateTiffRepresentations(representations, filePath);
|
||||
};
|
||||
|
||||
const assertSourceDimensions = async (): Promise<void> => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(BACKGROUND_1X),
|
||||
readPngDimensions(BACKGROUND_2X),
|
||||
]);
|
||||
assertDimensions("background.png", dimensions1x, EXPECTED_1X);
|
||||
assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X);
|
||||
};
|
||||
|
||||
const generateTiff = async (outputPath: string): Promise<void> => {
|
||||
// Finder's .DS_Store references one background file. A multi-representation
|
||||
// TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover
|
||||
// a separate @2x companion beside that referenced file.
|
||||
await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet();
|
||||
await assertTiffRepresentations(outputPath);
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (process.argv.length > 2) {
|
||||
throw new Error("usage: bun run dmg:background");
|
||||
}
|
||||
if (process.platform !== "darwin") {
|
||||
// Runs from beforeBuildCommand on every platform, but only macOS builds
|
||||
// bundle a DMG and only macOS ships tiffutil.
|
||||
console.log("Skipping DMG background generation on non-macOS host.");
|
||||
return;
|
||||
}
|
||||
|
||||
await assertSourceDimensions();
|
||||
// Generate and validate in scratch space so the configured build artifact is
|
||||
// replaced only after tiffutil has produced a complete, verified TIFF.
|
||||
const scratchRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "cline-dmg-background-"),
|
||||
);
|
||||
const generatedTiff = path.join(scratchRoot, "background.tiff");
|
||||
try {
|
||||
await generateTiff(generatedTiff);
|
||||
await copyFile(generatedTiff, BACKGROUND_TIFF);
|
||||
console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`);
|
||||
} finally {
|
||||
await rm(scratchRoot, { force: true, recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installPlugin } from "@cline/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
@@ -9,6 +10,15 @@ import {
|
||||
} from "./marketplace";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
// Marketplace plugin installs run in-process through @cline/core (spawning a
|
||||
// `cline` binary fails with 'Executable not found in $PATH: "cline"' in the
|
||||
// packaged app). Stub only installPlugin; everything else stays real.
|
||||
vi.mock(import("@cline/core"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
installPlugin: vi.fn(),
|
||||
}));
|
||||
const installPluginMock = vi.mocked(installPlugin);
|
||||
|
||||
const GOAL_ENTRY = {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
@@ -23,6 +33,13 @@ beforeEach(async () => {
|
||||
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
|
||||
previousClineDir = process.env.CLINE_DIR;
|
||||
process.env.CLINE_DIR = tempClineDir;
|
||||
installPluginMock.mockReset().mockImplementation(async (options) => ({
|
||||
source: options.source,
|
||||
installPath: goalInstallDir(),
|
||||
entryPaths: [],
|
||||
mcpSyncFailures: [],
|
||||
mcpOAuthCandidates: [],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -43,57 +60,49 @@ function goalInstallDir(): string {
|
||||
}
|
||||
|
||||
describe("official plugin install detection", () => {
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the CLI, so the UI flipped the
|
||||
// entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "install exploded",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
|
||||
).rejects.toThrow(/Plugin install failed/);
|
||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes --force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without --force the CLI refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
it("installs plugins in-process through @cline/core", async () => {
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass --force for a clean first install", async () => {
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the installer, so the UI flipped
|
||||
// the entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
installPluginMock.mockRejectedValueOnce(new Error("install exploded"));
|
||||
|
||||
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }),
|
||||
).rejects.toThrow(/install exploded/);
|
||||
expect(installPluginMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
|
||||
it("passes force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without force the installer refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still short-circuits when the directory contains a plugin module", async () => {
|
||||
@@ -111,22 +120,51 @@ describe("official plugin install detection", () => {
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Goal is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(installPluginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers MCP servers in-process, honoring the -- args separator", async () => {
|
||||
const settingsPath = join(tempClineDir, "cline_mcp_settings.json");
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
try {
|
||||
const result = await installMarketplaceEntry({
|
||||
entry: {
|
||||
id: "aikido",
|
||||
type: "mcp",
|
||||
name: "Aikido",
|
||||
install: {
|
||||
args: ["aikido", "--", "npx", "-y", "@aikidosec/mcp@1.0.9"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Aikido.",
|
||||
});
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers: Record<string, { transport?: unknown }>;
|
||||
};
|
||||
expect(settings.mcpServers.aikido?.transport).toEqual({
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@aikidosec/mcp@1.0.9"],
|
||||
});
|
||||
} finally {
|
||||
if (previousSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes partial install directories from the installed entries list", async () => {
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
installPlugin as installCorePlugin,
|
||||
installMcpServer,
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
parseMcpInstallArgs,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
@@ -457,18 +460,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
@@ -823,7 +814,6 @@ async function installSkill(
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
@@ -840,41 +830,36 @@ async function installPlugin(
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
// Reclaim a leftover directory from a failed or interrupted install:
|
||||
// without --force the CLI refuses to replace the existing path and
|
||||
// every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
...(installState === "partial" ? ["--force"] : []),
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
// Install in-process instead of shelling out to a `cline` binary: the
|
||||
// packaged desktop app cannot assume a CLI install exists on the user's
|
||||
// PATH (GUI apps inherit launchd's minimal PATH on macOS), which surfaced
|
||||
// as 'Executable not found in $PATH: "cline"' in the marketplace UI.
|
||||
//
|
||||
// force reclaims a leftover directory from a failed or interrupted
|
||||
// install: without it the installer refuses to replace the existing path
|
||||
// and every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
const result = await installCorePlugin({
|
||||
source: installArgs[0] ?? "",
|
||||
force: installState === "partial",
|
||||
});
|
||||
const warnings = result.mcpSyncFailures.map(
|
||||
(failure) =>
|
||||
`Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
details: {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
} as JsonRecord,
|
||||
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,45 +870,26 @@ export async function installMarketplaceEntry(
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
// Register the server in-process; this only writes MCP settings, so
|
||||
// there is no reason to depend on a `cline` binary being on PATH.
|
||||
const result = installMcpServer(
|
||||
parseMcpInstallArgs(entry.install.args ?? []),
|
||||
);
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
details: result as unknown as JsonRecord,
|
||||
output:
|
||||
result.warnings.length > 0 ? result.warnings.join("\n") : undefined,
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
return installPlugin(entry);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -275,36 +275,18 @@ impl DesktopBackendState {
|
||||
*guard = true;
|
||||
}
|
||||
|
||||
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
|
||||
if let Some(endpoint) = endpoint_guard.as_ref() {
|
||||
request_desktop_backend_shutdown(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
// The sidecar bounds its own graceful shutdown with
|
||||
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
|
||||
// itself; wait past that window before escalating to kill so
|
||||
// an active session can finish persisting.
|
||||
for _ in 0..70 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(100)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
// Quit runs this on the main thread (on macOS inside
|
||||
// applicationWillTerminate:, where blocking beach-balls the
|
||||
// app), so signal the sidecar and return without waiting.
|
||||
// SIGTERM triggers its own bounded graceful shutdown
|
||||
// (SHUTDOWN_TIMEOUT_MS in sidecar/index.ts), after which it
|
||||
// exits itself, finishing session persistence as an orphan.
|
||||
#[cfg(unix)]
|
||||
let _ = Command::new("kill").arg(child.id().to_string()).status();
|
||||
#[cfg(not(unix))]
|
||||
let _ = child.kill();
|
||||
}
|
||||
*process_guard = None;
|
||||
}
|
||||
@@ -353,51 +335,6 @@ fn resolve_workspace_root(launch_cwd: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_desktop_backend_shutdown(endpoint: &str) {
|
||||
let trimmed = endpoint.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
|
||||
let url = format!("{base}/shutdown");
|
||||
let timeout_seconds = "2";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
|
||||
url.replace('\'', "''"),
|
||||
timeout_seconds
|
||||
),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = Command::new("curl")
|
||||
.args([
|
||||
"-fsS",
|
||||
"--connect-timeout",
|
||||
timeout_seconds,
|
||||
"--max-time",
|
||||
timeout_seconds,
|
||||
"-X",
|
||||
"POST",
|
||||
&url,
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
|
||||
let launch_cwd = PathBuf::from(&context.launch_cwd);
|
||||
let candidates = [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.17",
|
||||
"version": "0.0.18",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
"devUrl": "http://localhost:3125",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"beforeBuildCommand": "bun run dmg:background && bun run build",
|
||||
"frontendDist": "../webview/out"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -48,7 +48,22 @@
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true
|
||||
"hardenedRuntime": true,
|
||||
"dmg": {
|
||||
"background": "dmg/background.gen.tiff",
|
||||
"windowSize": {
|
||||
"width": 640,
|
||||
"height": 432
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 140,
|
||||
"y": 200
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 500,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,18 @@ function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
async function switchToProjectSort(): Promise<void> {
|
||||
// The sort control is a direct toggle: one click flips to project mode.
|
||||
await click(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Project"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
function sessionIsVisible(title: string): boolean {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].some(
|
||||
(button) => button.querySelector("span")?.textContent === title,
|
||||
@@ -224,6 +236,23 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(
|
||||
sessionRow("alpha session 1").querySelector('[aria-label="Scheduled"]'),
|
||||
).not.toBeNull();
|
||||
// The clock leads the row: it renders before the title text.
|
||||
// The innermost matching span is the title itself (the outer flex
|
||||
// span also carries the title text plus the icon).
|
||||
const scheduledTitle = [
|
||||
...sessionRow("alpha session 1").querySelectorAll("span"),
|
||||
]
|
||||
.filter((span) => span.textContent === "alpha session 1")
|
||||
.pop();
|
||||
expect(scheduledTitle).toBeDefined();
|
||||
expect(
|
||||
(
|
||||
sessionRow("alpha session 1").querySelector(
|
||||
'[aria-label="Scheduled"]',
|
||||
) as Element
|
||||
).compareDocumentPosition(scheduledTitle as Element) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
sessionRow("alpha session 1").querySelector('[aria-label="Pinned"]'),
|
||||
).toBeNull();
|
||||
@@ -237,12 +266,102 @@ describe("AgentSidebar session organization", () => {
|
||||
sessionRow("alpha session 3").querySelector('[aria-label="Scheduled"]'),
|
||||
).toBeNull();
|
||||
|
||||
// The old Pinned/Scheduled/Tasks category sections are gone.
|
||||
expect(container.textContent).not.toContain("Scheduled");
|
||||
expect(container.textContent).not.toContain("Tasks");
|
||||
// The default time view groups these rows under category sections.
|
||||
expect(buttonWithText("Pinned")).toBeDefined();
|
||||
expect(buttonWithText("Scheduled")).toBeDefined();
|
||||
expect(buttonWithText("Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("pins sessions to the top of their project group", async () => {
|
||||
it("defaults to Pinned, Scheduled, and Tasks sections sorted by time", async () => {
|
||||
const pinned = { ...makeThread("alpha", 1), pinned: true };
|
||||
const scheduled = { ...makeThread("beta", 1), isScheduled: true };
|
||||
const regular = makeThread("gamma", 1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory(
|
||||
[regular, scheduled, pinned],
|
||||
vi.fn(),
|
||||
)}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Sections appear in Pinned, Scheduled, Tasks order.
|
||||
const pinnedHeader = buttonWithText("Pinned");
|
||||
const scheduledHeader = buttonWithText("Scheduled");
|
||||
const tasksHeader = buttonWithText("Tasks");
|
||||
expect(
|
||||
pinnedHeader.compareDocumentPosition(scheduledHeader) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
scheduledHeader.compareDocumentPosition(tasksHeader) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(sessionIsVisible("alpha session 1")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(true);
|
||||
expect(sessionIsVisible("gamma session 1")).toBe(true);
|
||||
|
||||
// Collapsing a section hides only its own rows.
|
||||
await click(scheduledHeader);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(false);
|
||||
expect(sessionIsVisible("alpha session 1")).toBe(true);
|
||||
expect(sessionIsVisible("gamma session 1")).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes a session through the row's hover trash button", async () => {
|
||||
const deleteThread = vi.fn(async () => undefined);
|
||||
const sessionHistory = makeSessionHistory([makeThread("alpha", 1)], vi.fn());
|
||||
(sessionHistory as { deleteThread: unknown }).deleteThread = deleteThread;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// The trash affordance is a sibling of the row button (buttons cannot
|
||||
// nest) and opens the same confirmation dialog as the context menu.
|
||||
const deleteButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Delete alpha session 1"]',
|
||||
);
|
||||
expect(deleteButton).not.toBeNull();
|
||||
expect(deleteButton?.closest("button")).toBe(deleteButton);
|
||||
await click(deleteButton as HTMLButtonElement);
|
||||
|
||||
const confirm = await vi.waitFor(() => {
|
||||
const button = [...document.body.querySelectorAll("button")].find(
|
||||
(candidate) => candidate.textContent === "Delete",
|
||||
);
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
expect(document.body.textContent).toContain("Delete session?");
|
||||
await click(confirm);
|
||||
expect(deleteThread).toHaveBeenCalledWith("alpha-1");
|
||||
});
|
||||
|
||||
it("pins sessions to the top of their project group in project sort", async () => {
|
||||
const pinned = { ...makeThread("alpha", 3), pinned: true };
|
||||
const threads = [makeThread("alpha", 1), makeThread("alpha", 2), pinned];
|
||||
|
||||
@@ -262,8 +381,11 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await switchToProjectSort();
|
||||
|
||||
// The pinned session leads its project group despite being the oldest
|
||||
// entry in history order, and carries the pin icon inline.
|
||||
// entry in history order, and carries the pin icon inline; project
|
||||
// sort has no Pinned section header.
|
||||
const pinnedRow = sessionRow("alpha session 3");
|
||||
expect(pinnedRow.querySelector('[aria-label="Pinned"]')).not.toBeNull();
|
||||
for (const title of ["alpha session 1", "alpha session 2"]) {
|
||||
@@ -463,12 +585,12 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("always groups sessions by project and scopes expansion to one project", async () => {
|
||||
it("defaults to a time-sorted list and groups by project after switching sort", async () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
...Array.from({ length: 35 }, (_, index) =>
|
||||
makeThread("alpha", index + 1),
|
||||
),
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
...Array.from({ length: 35 }, (_, index) =>
|
||||
makeThread("beta", index + 1),
|
||||
),
|
||||
];
|
||||
@@ -495,19 +617,33 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The sort toggle is gone: grouping is always by project.
|
||||
expect(container.querySelector('[aria-label^="Sort sessions"]')).toBeNull();
|
||||
// The default view is a flat time-sorted list showing the first page
|
||||
// of 30 rows.
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]'),
|
||||
).not.toBeNull();
|
||||
expect(sessionIsVisible("alpha session 30")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 31")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(false);
|
||||
|
||||
// The first page grows purely from already-loaded sessions (70 loaded,
|
||||
// 60 requested), so no history fetch is needed.
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(sessionIsVisible("alpha session 31")).toBe(true);
|
||||
expect(loadMoreSessions).not.toHaveBeenCalled();
|
||||
expect(loadOlderSessions).not.toHaveBeenCalled();
|
||||
|
||||
await switchToProjectSort();
|
||||
expect(container.textContent).toContain("alpha");
|
||||
expect(container.textContent).toContain("beta");
|
||||
expect(sessionIsVisible("alpha session 10")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 10")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 30")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 31")).toBe(false);
|
||||
expect(sessionIsVisible("alpha session 31")).toBe(false);
|
||||
|
||||
// Expanding one project leaves the others' pagination untouched.
|
||||
await click(buttonWithText("Show more in alpha"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
expect(sessionIsVisible("alpha session 31")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 31")).toBe(false);
|
||||
expect(loadMoreSessions).not.toHaveBeenCalled();
|
||||
|
||||
// The trailing Show more button grows the loaded history window.
|
||||
@@ -822,6 +958,61 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Customize");
|
||||
});
|
||||
|
||||
it("shows Installed and Marketplace sub-tabs under the open Customize row", async () => {
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
const renderSidebar = async (section: "Customize" | "Marketplace") => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onSettingsSectionChange={onSettingsSectionChange}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection={section}
|
||||
view="settings"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await renderSidebar("Customize");
|
||||
const actionsNav = container.querySelector(
|
||||
'[aria-label="Sidebar actions"]',
|
||||
) as ParentNode;
|
||||
const installedRow = buttonWithText("Installed", actionsNav);
|
||||
const marketplaceRow = buttonWithText("Marketplace", actionsNav);
|
||||
const customizeRow = buttonWithText("Customize", actionsNav);
|
||||
|
||||
// The active sub-tab carries the full selected background; the parent
|
||||
// Customize row stays marked with a subtler highlight so the two
|
||||
// simultaneous highlights read differently.
|
||||
expect(installedRow.getAttribute("aria-current")).toBe("page");
|
||||
expect(installedRow.className.split(" ")).toContain("bg-surface-hover");
|
||||
expect(customizeRow.className.split(" ")).toContain(
|
||||
"bg-surface-hover-lighter",
|
||||
);
|
||||
expect(customizeRow.className.split(" ")).not.toContain(
|
||||
"bg-surface-hover",
|
||||
);
|
||||
// Sub-tabs are indented under the parent row.
|
||||
expect(installedRow.className.split(" ")).toContain("pl-8!");
|
||||
|
||||
await click(marketplaceRow);
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Marketplace");
|
||||
await renderSidebar("Marketplace");
|
||||
expect(
|
||||
buttonWithText("Marketplace", actionsNav).getAttribute("aria-current"),
|
||||
).toBe("page");
|
||||
expect(
|
||||
buttonWithText("Installed", actionsNav).getAttribute("aria-current"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the New row only while the new-task page is active", async () => {
|
||||
const renderSidebar = async (newTaskActive: boolean) => {
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CircleUserRound,
|
||||
Clock3,
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Mic,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Store,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -76,6 +78,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
CUSTOMIZATION_SECTION_LABELS,
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
@@ -111,6 +114,8 @@ type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type SessionCategory = "pinned" | "scheduled" | "tasks";
|
||||
type DesktopProcessContext = {
|
||||
appVersion?: unknown;
|
||||
hub?: {
|
||||
@@ -144,8 +149,20 @@ const SETTINGS_SECTION_ICONS = {
|
||||
Schedules: Clock3,
|
||||
Account: CircleUserRound,
|
||||
Customize: Blocks,
|
||||
Marketplace: Store,
|
||||
} satisfies Record<SettingsSection, typeof Settings>;
|
||||
|
||||
// The Customize section is the installed inventory, so its nav row reads
|
||||
// "Installed" (it sits under a "Customize" group header / next to the
|
||||
// Marketplace row, which supplies the context).
|
||||
function settingsSectionLabel(section: SettingsSection): string {
|
||||
return (
|
||||
CUSTOMIZATION_SECTION_LABELS[
|
||||
section as keyof typeof CUSTOMIZATION_SECTION_LABELS
|
||||
] ?? section
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSectionNavigation({
|
||||
activeSection,
|
||||
collapsed,
|
||||
@@ -160,11 +177,12 @@ function SettingsSectionNavigation({
|
||||
const hasConnectedProvider = useHasConnectedProvider();
|
||||
const renderSectionButton = (section: SettingsSection) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
const label = settingsSectionLabel(section);
|
||||
const disabled = section === "Voice" && hasConnectedProvider === false;
|
||||
const button = (
|
||||
<Button
|
||||
aria-current={activeSection === section ? "page" : undefined}
|
||||
aria-label={section}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
@@ -175,12 +193,12 @@ function SettingsSectionNavigation({
|
||||
disabled={disabled}
|
||||
key={disabled ? undefined : section}
|
||||
onClick={() => onSelect(section)}
|
||||
title={section}
|
||||
title={label}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{section}</span> : null}
|
||||
{!collapsed ? <span className="truncate">{label}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
if (!disabled) {
|
||||
@@ -213,8 +231,10 @@ function SettingsSectionNavigation({
|
||||
</p>
|
||||
) : null}
|
||||
{/* Schedules and Customize already have dedicated rows at the top of
|
||||
the expanded sidebar, so the section nav skips them there. The
|
||||
collapsed sidebar has no action rows and keeps both reachable. */}
|
||||
the expanded sidebar (Customize's Installed/Marketplace sub-tabs
|
||||
render under that row), so the section nav skips them there.
|
||||
The collapsed sidebar has no action rows and keeps them
|
||||
reachable. */}
|
||||
{SETTINGS_SECTIONS.filter(
|
||||
(section) => collapsed || section !== "Schedules",
|
||||
).map(renderSectionButton)}
|
||||
@@ -284,7 +304,17 @@ export function AgentSidebar({
|
||||
const activeThread = activeSessionId ?? "";
|
||||
const [filter, setFilter] = useState<FilterOption>("All");
|
||||
const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES);
|
||||
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [showMoreCount, setShowMoreCount] = useState(
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
);
|
||||
const [scheduledVisibleCount, setScheduledVisibleCount] = useState(
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
);
|
||||
const [collapsedSections, setCollapsedSections] = useState<
|
||||
Set<SessionCategory>
|
||||
>(() => new Set());
|
||||
// Drives the gradient fade under the Sessions header once the list is
|
||||
// scrolled, so rows fade out instead of clipping against the header.
|
||||
const [sessionListScrolled, setSessionListScrolled] = useState(false);
|
||||
@@ -452,6 +482,62 @@ export function AgentSidebar({
|
||||
[deleteHistoryThread],
|
||||
);
|
||||
|
||||
const pinnedThreads = useMemo(
|
||||
() => filteredThreads.filter((t) => t.pinned),
|
||||
[filteredThreads],
|
||||
);
|
||||
const scheduledThreads = useMemo(
|
||||
() => filteredThreads.filter((t) => !t.pinned && t.isScheduled),
|
||||
[filteredThreads],
|
||||
);
|
||||
const taskThreads = useMemo(
|
||||
() => filteredThreads.filter((t) => !t.pinned && !t.isScheduled),
|
||||
[filteredThreads],
|
||||
);
|
||||
// Category headers only appear once there is something to categorize;
|
||||
// a lone "Tasks" header over the whole list would be noise.
|
||||
const showCategorySections =
|
||||
pinnedThreads.length > 0 || scheduledThreads.length > 0;
|
||||
const showTimeShowMore =
|
||||
taskThreads.length > showMoreCount ||
|
||||
(filter === "All" && mayHaveMoreSessions);
|
||||
// A failed fetch leaves the task count and has-more state unchanged, which
|
||||
// are exactly the conditions the page-fill effect fires on; without this
|
||||
// halt it would retry a failing request (and re-toast the error) forever.
|
||||
// The next explicit "Show more" click clears the halt to retry.
|
||||
const pageFillFailedRef = useRef(false);
|
||||
// A "Show more" click can outpace the loaded history: showMoreCount counts
|
||||
// only Tasks rows while the backend limit counts all sessions, and a
|
||||
// fetched batch can consist entirely of pinned or scheduled sessions. Keep
|
||||
// growing the history window until the requested Tasks page fills or
|
||||
// history runs out, so every click makes visible progress. The
|
||||
// isLoadingMore dependency retriggers the check after each fetch settles.
|
||||
useEffect(() => {
|
||||
if (
|
||||
sortMode !== "time" ||
|
||||
filter !== "All" ||
|
||||
isLoadingMore ||
|
||||
!mayHaveMoreSessions ||
|
||||
showMoreCount <= INITIAL_VISIBLE_THREAD_COUNT ||
|
||||
taskThreads.length >= showMoreCount ||
|
||||
pageFillFailedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void loadOlderSessions().then((loaded) => {
|
||||
if (!loaded) {
|
||||
pageFillFailedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
filter,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions,
|
||||
showMoreCount,
|
||||
sortMode,
|
||||
taskThreads.length,
|
||||
]);
|
||||
// Pinned threads lead the concatenation, and groupThreadsByProject keeps
|
||||
// insertion order, so each project group reads pinned-by-recency first,
|
||||
// then the rest by recency.
|
||||
@@ -463,6 +549,14 @@ export function AgentSidebar({
|
||||
]),
|
||||
[filteredThreads],
|
||||
);
|
||||
const toggleSection = useCallback((section: SessionCategory) => {
|
||||
setCollapsedSections((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const toggleProject = useCallback((project: string) => {
|
||||
setCollapsedProjects((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -497,6 +591,8 @@ export function AgentSidebar({
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
setFilter(value as FilterOption);
|
||||
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setProjectVisibleCounts({});
|
||||
}}
|
||||
value={filter}
|
||||
@@ -514,6 +610,8 @@ export function AgentSidebar({
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
setSourceFilter(value);
|
||||
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setProjectVisibleCounts({});
|
||||
}}
|
||||
value={sourceFilter}
|
||||
@@ -532,6 +630,31 @@ export function AgentSidebar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
// A single click flips straight to the other mode (a dropdown here would
|
||||
// cost an extra click for a two-option choice); the icon shows the mode
|
||||
// that is currently active.
|
||||
const sortToggle = (
|
||||
<Button
|
||||
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground hover:bg-surface-hover hover:text-sidebar-foreground"
|
||||
onClick={() =>
|
||||
setSortMode((current) => (current === "time" ? "project" : "time"))
|
||||
}
|
||||
size="icon"
|
||||
title={
|
||||
sortMode === "time"
|
||||
? "Sorted by time — click to group by project"
|
||||
: "Grouped by project — click to sort by time"
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
{sortMode === "time" ? (
|
||||
<Clock3 className="size-3.5" />
|
||||
) : (
|
||||
<FolderTree className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
const threadItem = (thread: Thread) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
@@ -559,6 +682,38 @@ export function AgentSidebar({
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
);
|
||||
const customizeSectionOpen =
|
||||
view === "settings" &&
|
||||
(CUSTOMIZATION_SECTIONS as readonly SettingsSection[]).includes(
|
||||
settingsSection,
|
||||
);
|
||||
const timeShowMoreButton = (
|
||||
<Button
|
||||
className="px-2!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
// Raising the page size is enough: the page-fill effect fetches
|
||||
// older history whenever loaded tasks cannot fill the page. An
|
||||
// explicit click also retries after a failed fetch halted it.
|
||||
pageFillFailedRef.current = false;
|
||||
setShowMoreCount(showMoreCount + INITIAL_VISIBLE_THREAD_COUNT);
|
||||
}}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground">
|
||||
@@ -632,6 +787,12 @@ export function AgentSidebar({
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-64 p-3"
|
||||
// Clicking the trigger counts as a pointer-down outside the
|
||||
// card, which dismisses it — and the button's focus event
|
||||
// then reopens it, so the card flashes on every click.
|
||||
// Suppress the dismissal; the card still closes on pointer
|
||||
// leave like any hover card.
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
side="bottom"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
@@ -727,11 +888,8 @@ export function AgentSidebar({
|
||||
<Button
|
||||
aria-label="Customize"
|
||||
className={cn(
|
||||
view === "settings" &&
|
||||
(
|
||||
CUSTOMIZATION_SECTIONS as readonly SettingsSection[]
|
||||
).includes(settingsSection) &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
customizeSectionOpen &&
|
||||
"bg-surface-hover-lighter text-sidebar-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Customize")}
|
||||
title="Customize Cline with plugins, rules, and more"
|
||||
@@ -741,6 +899,30 @@ export function AgentSidebar({
|
||||
<Blocks className="size-4 shrink-0" />
|
||||
<span className="truncate">Customize</span>
|
||||
</Button>
|
||||
{customizeSectionOpen
|
||||
? CUSTOMIZATION_SECTIONS.map((section) => (
|
||||
<Button
|
||||
aria-current={
|
||||
settingsSection === section ? "page" : undefined
|
||||
}
|
||||
aria-label={settingsSectionLabel(section)}
|
||||
className={cn(
|
||||
"pl-8!",
|
||||
settingsSection === section &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => openSettingsSection(section)}
|
||||
title={settingsSectionLabel(section)}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<span className="truncate">
|
||||
{settingsSectionLabel(section)}
|
||||
</span>
|
||||
</Button>
|
||||
))
|
||||
: null}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
@@ -785,9 +967,10 @@ export function AgentSidebar({
|
||||
onClick={openSessions}
|
||||
type="button"
|
||||
>
|
||||
Sessions
|
||||
{sortMode === "time" ? "Sessions" : "Projects"}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{sortToggle}
|
||||
{filterMenu}
|
||||
</div>
|
||||
</div>
|
||||
@@ -822,65 +1005,135 @@ export function AgentSidebar({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectGroups.map((project) => {
|
||||
const visibleCount =
|
||||
projectVisibleCounts[project.id] ??
|
||||
INITIAL_VISIBLE_THREAD_COUNT;
|
||||
return (
|
||||
<ProjectSection
|
||||
collapsed={collapsedProjects.has(project.id)}
|
||||
key={project.id}
|
||||
label={project.label}
|
||||
onToggle={() => toggleProject(project.id)}
|
||||
>
|
||||
{project.threads
|
||||
.slice(0, visibleCount)
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
<Button
|
||||
className="max-w-full pl-2!"
|
||||
onClick={() => showMoreForProject(project.id)}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
{sortMode === "time" ? (
|
||||
showCategorySections ? (
|
||||
<>
|
||||
{pinnedThreads.length > 0 ? (
|
||||
<CategorySection
|
||||
collapsed={collapsedSections.has("pinned")}
|
||||
count={pinnedThreads.length}
|
||||
label="Pinned"
|
||||
onToggle={() => toggleSection("pinned")}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
Show more in {project.label}
|
||||
</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
{pinnedThreads.map(threadItem)}
|
||||
</CategorySection>
|
||||
) : null}
|
||||
</ProjectSection>
|
||||
);
|
||||
})}
|
||||
{scheduledThreads.length > 0 ? (
|
||||
<CategorySection
|
||||
collapsed={collapsedSections.has("scheduled")}
|
||||
count={scheduledThreads.length}
|
||||
label="Scheduled"
|
||||
onToggle={() => toggleSection("scheduled")}
|
||||
>
|
||||
{scheduledThreads
|
||||
.slice(0, scheduledVisibleCount)
|
||||
.map(threadItem)}
|
||||
{scheduledThreads.length >
|
||||
scheduledVisibleCount ? (
|
||||
<Button
|
||||
className="px-2!"
|
||||
onClick={() =>
|
||||
setScheduledVisibleCount(
|
||||
(current) =>
|
||||
current +
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</CategorySection>
|
||||
) : null}
|
||||
{taskThreads.length > 0 || showTimeShowMore ? (
|
||||
<CategorySection
|
||||
collapsed={collapsedSections.has("tasks")}
|
||||
count={taskThreads.length}
|
||||
label="Tasks"
|
||||
onToggle={() => toggleSection("tasks")}
|
||||
>
|
||||
{taskThreads
|
||||
.slice(0, showMoreCount)
|
||||
.map(threadItem)}
|
||||
{showTimeShowMore ? timeShowMoreButton : null}
|
||||
</CategorySection>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
taskThreads.slice(0, showMoreCount).map(threadItem)
|
||||
)
|
||||
) : (
|
||||
projectGroups.map((project) => {
|
||||
const visibleCount =
|
||||
projectVisibleCounts[project.id] ??
|
||||
INITIAL_VISIBLE_THREAD_COUNT;
|
||||
return (
|
||||
<ProjectSection
|
||||
collapsed={collapsedProjects.has(project.id)}
|
||||
key={project.id}
|
||||
label={project.label}
|
||||
onToggle={() => toggleProject(project.id)}
|
||||
>
|
||||
{project.threads
|
||||
.slice(0, visibleCount)
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
<Button
|
||||
className="max-w-full pl-2!"
|
||||
onClick={() => showMoreForProject(project.id)}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
Show more in {project.label}
|
||||
</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ProjectSection>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{projectGroups.length === 0 && (
|
||||
{(sortMode === "time"
|
||||
? filteredThreads.length === 0
|
||||
: projectGroups.length === 0) && (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">
|
||||
No sessions found in history.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filter === "All" && mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="px-2!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{sortMode === "time" &&
|
||||
!showCategorySections &&
|
||||
showTimeShowMore &&
|
||||
timeShowMoreButton}
|
||||
{sortMode === "project" &&
|
||||
filter === "All" &&
|
||||
mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="px-2!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -1035,6 +1288,46 @@ export function AgentSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
label,
|
||||
count,
|
||||
collapsed,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1 min-w-0">
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground hover:bg-surface-hover-lighter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={onToggle}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
<span className="ml-auto pr-1 text-xs tabular-nums text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed ? (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">{children}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({
|
||||
label,
|
||||
collapsed,
|
||||
@@ -1149,42 +1442,75 @@ function ThreadItem({
|
||||
>
|
||||
<ContextMenuTrigger asChild>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
|
||||
isActive
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-surface-hover",
|
||||
)}
|
||||
disabled={pending}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-sm text-muted-foreground">
|
||||
{statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
{thread.isScheduled ? (
|
||||
<Clock3 aria-label="Scheduled" className="size-3" />
|
||||
) : null}
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3 fill-current" />
|
||||
) : null}
|
||||
<span>{thread.time}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/* The delete affordance is a sibling of the row button
|
||||
(buttons cannot nest), overlaid where the timestamp
|
||||
sits; group/row hover swaps the two and keeps the
|
||||
row's hover background while the pointer is on the
|
||||
trash button. */}
|
||||
<div className="group/row relative min-w-0">
|
||||
<button
|
||||
className={cn(
|
||||
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
|
||||
isActive
|
||||
? "bg-surface-hover text-sidebar-foreground"
|
||||
: "text-sidebar-foreground/80 group-hover/row:bg-surface-hover",
|
||||
)}
|
||||
disabled={pending}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{thread.isScheduled ? (
|
||||
<Clock3
|
||||
aria-label="Scheduled"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-sm text-muted-foreground">
|
||||
{statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3 fill-current" />
|
||||
) : null}
|
||||
<span className="group-hover/row:invisible">
|
||||
{thread.time}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
aria-label={`Delete ${title}`}
|
||||
className="absolute top-1/2 right-1 size-6 -translate-y-1/2 justify-center px-0 text-muted-foreground opacity-0 group-hover/row:opacity-100 hover:text-destructive focus-visible:opacity-100"
|
||||
disabled={pending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
size="icon"
|
||||
title="Delete session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
avoidCollisions={false}
|
||||
className="w-72 p-3"
|
||||
// Same flash-on-click suppression as the logo hover card: a
|
||||
// click on the row is a pointer-down outside the card, and the
|
||||
// dismiss + refocus cycle makes the card blink.
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
|
||||
@@ -823,6 +823,107 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(writeText).toHaveBeenCalledWith("Original prompt");
|
||||
});
|
||||
|
||||
it("hides runtime steering notes from the transcript", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "steer-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content:
|
||||
"[SYSTEM] This run is not complete until you call one of these terminal completion tools: submit_and_exit.",
|
||||
createdAt: 2,
|
||||
meta: { userRunSpan: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
// Steering nudges are machinery talking to the model — not rendered
|
||||
// at all, and never as a user bubble.
|
||||
expect(container.textContent).toContain("tell me the current time");
|
||||
expect(container.textContent).not.toContain("[SYSTEM]");
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you call",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a genuine user prompt that happens to start with [SYSTEM]", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "[SYSTEM] is a prefix I typed myself, explain it",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Only injected reminders (userRunSpan 0) are steering; a person's
|
||||
// own prompt stays visible.
|
||||
expect(container.textContent).toContain(
|
||||
"is a prefix I typed myself, explain it",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps steering notes hidden inside the expanded work block", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-prompt",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "steer-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "[SYSTEM] This run is not complete until you finish.",
|
||||
createdAt: 2,
|
||||
meta: { userRunSpan: 0 },
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "run_commands",
|
||||
input: {},
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "It is 12:28 PM PT.",
|
||||
createdAt: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
// The steering note is working-rows machinery grouped with the run,
|
||||
// and stays hidden even when the work block is expanded.
|
||||
expect(container.textContent).toContain("It is 12:28 PM PT.");
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you finish.",
|
||||
);
|
||||
|
||||
const trigger = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((button) => button.textContent?.includes("Worked"));
|
||||
expect(trigger).toBeDefined();
|
||||
await act(async () => trigger?.click());
|
||||
expect(container.textContent).not.toContain(
|
||||
"This run is not complete until you finish.",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts folded system-displayed runs before an editable user message", async () => {
|
||||
const onEditMessage = vi.fn(async () => undefined);
|
||||
await renderMessages(
|
||||
@@ -1641,17 +1742,18 @@ describe("ChatMessages work collapse", () => {
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["cancelled", "failed", "error"] as const)(
|
||||
"keeps an interrupted run's rows visible even with partial trailing text (%s)",
|
||||
async (status) => {
|
||||
// Stop can land mid-answer, leaving partial assistant text after the
|
||||
// tool calls; the run still must not fold into a summary.
|
||||
await renderMessages(completedRun, { status });
|
||||
it.each([
|
||||
"cancelled",
|
||||
"failed",
|
||||
"error",
|
||||
] as const)("keeps an interrupted run's rows visible even with partial trailing text (%s)", async (status) => {
|
||||
// Stop can land mid-answer, leaving partial assistant text after the
|
||||
// tool calls; the run still must not fold into a summary.
|
||||
await renderMessages(completedRun, { status });
|
||||
|
||||
expect(container.querySelector(".cline-chat-work")).toBeNull();
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-work")).toBeNull();
|
||||
expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages thinking indicator", () => {
|
||||
|
||||
@@ -29,6 +29,24 @@ export type ChatRenderItem =
|
||||
items: ChatRenderItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime steering notes injected into the conversation as user-role
|
||||
* messages (completion-tool reminders, team-obligation nudges). They are
|
||||
* machinery talking to the model, not the person talking, so the transcript
|
||||
* renders them as subtle system rows and folds them into the run's working
|
||||
* span instead of showing user bubbles.
|
||||
*/
|
||||
export function isSystemSteeringMessage(message: ChatMessage): boolean {
|
||||
return (
|
||||
message.role === "user" &&
|
||||
// Injected reminders carry userRunSpan 0 (they are not user turns);
|
||||
// requiring it keeps a person's genuine prompt that happens to start
|
||||
// with "[SYSTEM]" visible and turn-counted.
|
||||
message.meta?.userRunSpan === 0 &&
|
||||
message.content.trimStart().startsWith("[SYSTEM]")
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMessageReasoning(message: ChatMessage): boolean {
|
||||
return Boolean(message.reasoning?.trim() || message.reasoningRedacted);
|
||||
}
|
||||
@@ -66,7 +84,8 @@ export function buildUserRunCountMap(
|
||||
|
||||
for (const message of messages) {
|
||||
const userRunSpan =
|
||||
message.meta?.userRunSpan ?? (message.role === "user" ? 1 : 0);
|
||||
message.meta?.userRunSpan ??
|
||||
(message.role === "user" && !isSystemSteeringMessage(message) ? 1 : 0);
|
||||
const storedRunCount =
|
||||
message.meta?.runCount ?? message.meta?.checkpoint?.runCount;
|
||||
if (storedRunCount !== undefined) {
|
||||
@@ -119,8 +138,9 @@ export type CollapseWorkOptions = {
|
||||
*/
|
||||
function isCollapsibleWorkItem(item: ChatRenderItem): boolean {
|
||||
if (item.type === "tools") return true;
|
||||
if (item.type !== "message") return false;
|
||||
if (isSystemSteeringMessage(item.message)) return true;
|
||||
return (
|
||||
item.type === "message" &&
|
||||
item.message.role === "assistant" &&
|
||||
!item.message.images?.length &&
|
||||
!item.message.media?.length
|
||||
@@ -177,7 +197,11 @@ export function collapseCompletedWork(
|
||||
let lastUserIndex = -1;
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
const item = items[index];
|
||||
if (item.type === "message" && item.message.role === "user") {
|
||||
if (
|
||||
item.type === "message" &&
|
||||
item.message.role === "user" &&
|
||||
!isSystemSteeringMessage(item.message)
|
||||
) {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
@@ -195,7 +219,9 @@ export function collapseCompletedWork(
|
||||
// message is the run's answer and stays visible below the summary.
|
||||
const last = span.at(-1);
|
||||
const answer =
|
||||
last?.type === "message" && last.message.content.trim()
|
||||
last?.type === "message" &&
|
||||
last.message.role === "assistant" &&
|
||||
last.message.content.trim()
|
||||
? last
|
||||
: undefined;
|
||||
// A span is settled once a later user message exists. The trailing span
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../../ui/markdown";
|
||||
import { formatChatMessageContent } from "../message-content";
|
||||
import { isSystemSteeringMessage } from "./group-messages";
|
||||
import { ReasoningBlock } from "./reasoning-block";
|
||||
|
||||
function AssistantImageCarousel({
|
||||
@@ -217,6 +218,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
const checkpoint = message.meta?.checkpoint;
|
||||
// Runtime steering notes (completion nudges in scheduled/automation runs,
|
||||
// team-obligation reminders) are user-role messages the machinery sends to
|
||||
// the model, not something the person said or needs to read — hide them
|
||||
// from the transcript entirely. Grouping still treats them as working-row
|
||||
// machinery (never a turn boundary, an answer, or a run-count increment).
|
||||
if (isSystemSteeringMessage(message)) {
|
||||
return null;
|
||||
}
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
|
||||
+4
-4
@@ -12,8 +12,8 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
normalizeWorkspacePath,
|
||||
@@ -399,9 +399,9 @@ function BranchPicker({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={branchListRef}
|
||||
>
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
|
||||
ref={branchListRef}
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
|
||||
@@ -358,7 +358,10 @@ export function WorkspaceSelector({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div ref={workspaceListRef} className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
|
||||
<div
|
||||
ref={workspaceListRef}
|
||||
className="flex flex-col gap-0.5 max-h-28 overflow-y-auto"
|
||||
>
|
||||
{filteredWorkspaces.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{looksLikeFolderPath(search)
|
||||
@@ -459,7 +462,10 @@ export function WorkspaceSelector({
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Branches
|
||||
</div>
|
||||
<div ref={branchListRef} className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
<div
|
||||
ref={branchListRef}
|
||||
className="flex flex-col gap-0.5 max-h-36 overflow-y-auto"
|
||||
>
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">
|
||||
No branches found
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Blocks,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Puzzle,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Star,
|
||||
Store,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -107,7 +109,7 @@ const primitivePageDetails = {
|
||||
const directoryPageDetails: MarketplacePageDetails = {
|
||||
title: "Marketplace",
|
||||
description:
|
||||
"Browse and install plugins, MCP servers, and skills from the Cline marketplace.",
|
||||
"A curated set of plugins, MCP servers, and skills from the Cline community.",
|
||||
emptyInstalled: "Nothing installed yet.",
|
||||
emptyCatalog: "No marketplace entries match the current filters.",
|
||||
icon: Store,
|
||||
@@ -643,6 +645,7 @@ export function MarketplaceView({
|
||||
defaultTypeFilter,
|
||||
installedItems,
|
||||
onInstalledItemsChanged,
|
||||
onOpenInstalled,
|
||||
primitive,
|
||||
variant = "full",
|
||||
}: {
|
||||
@@ -651,6 +654,8 @@ export function MarketplaceView({
|
||||
defaultTypeFilter?: MarketplacePrimitiveType;
|
||||
installedItems?: MarketplaceLocalInstalledItem[];
|
||||
onInstalledItemsChanged?: () => void | Promise<void>;
|
||||
/** Renders an Installed button in the directory page header. */
|
||||
onOpenInstalled?: () => void;
|
||||
/** When omitted, the view spans every catalog type (directory variant). */
|
||||
primitive?: MarketplacePrimitiveType;
|
||||
variant?: MarketplaceViewVariant;
|
||||
@@ -956,7 +961,7 @@ export function MarketplaceView({
|
||||
|
||||
const typeFilterChips =
|
||||
variant === "directory" && !primitive ? (
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<Button
|
||||
aria-pressed={typeFilter === null}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
@@ -991,31 +996,30 @@ export function MarketplaceView({
|
||||
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) => (current === tag.id ? null : tag.id))
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
{/* Clearing belongs with what it clears: the control appears at
|
||||
the end of the chip row only while a tag is active. */}
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
className="shrink-0 self-start md:self-auto"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
<X className="size-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1123,16 +1127,17 @@ export function MarketplaceView({
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
catalog?.generatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updated{" "}
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(catalog.generatedAt))}
|
||||
</p>
|
||||
) : null
|
||||
onOpenInstalled ? (
|
||||
<Button
|
||||
onClick={onOpenInstalled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Blocks className="size-4" />
|
||||
Installed
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1202,10 +1207,28 @@ export function MarketplaceView({
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={
|
||||
typeFilterChips || marketplaceTagFilters ? (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters}
|
||||
</div>
|
||||
variant === "directory" ? (
|
||||
// Light rules separate the filter tiers from each
|
||||
// other and from the results below.
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-px bg-border/70"
|
||||
/>
|
||||
{marketplaceTagFilters}
|
||||
</>
|
||||
) : null}
|
||||
<div aria-hidden="true" className="h-px bg-border/70" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{typeFilterChips}
|
||||
{marketplaceTagFilters}
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Store } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
@@ -9,11 +10,10 @@ import { CustomizationSectionView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
* Unified Customize hub: one page for everything that extends Cline —
|
||||
* skills, MCP servers, plugins, rules, hooks, and tools — as sub-tabs with
|
||||
* live counts. Tabs with a marketplace catalog (skills, MCP, plugins) show
|
||||
* installed items followed by an inline browsable marketplace section, so
|
||||
* there is no separate Marketplace page.
|
||||
* Unified Customize hub: the installed inventory of everything that extends
|
||||
* Cline — skills, MCP servers, plugins, rules, hooks, and tools — as sub-tabs
|
||||
* with live counts. Browsing happens on the dedicated Marketplace page,
|
||||
* reached from the sidebar or the header button here.
|
||||
*/
|
||||
|
||||
type CustomizeTab = "skills" | "mcp" | "plugins" | "rules" | "hooks" | "tools";
|
||||
@@ -43,7 +43,11 @@ function asCount(value: unknown): number {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
export function CustomizeView() {
|
||||
export function CustomizeView({
|
||||
onOpenMarketplace,
|
||||
}: {
|
||||
onOpenMarketplace?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<CustomizeTab>("skills");
|
||||
const [counts, setCounts] = useState<TabCounts>({});
|
||||
|
||||
@@ -78,7 +82,20 @@ export function CustomizeView() {
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Extend what Cline can do and change how it works. Manage what's installed and browse the marketplace for more options."
|
||||
actions={
|
||||
onOpenMarketplace ? (
|
||||
<Button
|
||||
onClick={onOpenMarketplace}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Store className="size-4" />
|
||||
Marketplace
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
description="Extend what Cline can do and change how it works. Manage what's installed, or browse the marketplace for more options."
|
||||
title="Customize"
|
||||
/>
|
||||
|
||||
@@ -125,20 +142,21 @@ export function CustomizeView() {
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="full"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Skills"
|
||||
/>
|
||||
) : tab === "mcp" ? (
|
||||
<McpServersContent
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
/>
|
||||
) : tab === "plugins" ? (
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="full"
|
||||
marketplaceVariant="installed"
|
||||
onInventoryChanged={handleInventoryChanged}
|
||||
section="Plugins"
|
||||
/>
|
||||
|
||||
@@ -209,10 +209,13 @@ function createServerFormState(existing?: McpServer): McpServerFormState {
|
||||
|
||||
export function McpServersContent({
|
||||
chrome = "page",
|
||||
marketplaceVariant = "full",
|
||||
onInventoryChanged,
|
||||
}: {
|
||||
/** "embedded" renders without the page frame/header for use inside the Plugins hub. */
|
||||
chrome?: "page" | "embedded";
|
||||
/** Which marketplace sections the embedded MarketplaceView shows. */
|
||||
marketplaceVariant?: "full" | "installed";
|
||||
/** Invoked whenever the server list is (re)loaded or mutated. */
|
||||
onInventoryChanged?: () => void;
|
||||
} = {}) {
|
||||
@@ -814,7 +817,7 @@ export function McpServersContent({
|
||||
installedItems={installedItems}
|
||||
onInstalledItemsChanged={() => refreshServers()}
|
||||
primitive="mcp"
|
||||
variant="full"
|
||||
variant={marketplaceVariant}
|
||||
/>
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from "@cline/shared/browser";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -528,6 +527,15 @@ export function RoutineSchedulesContent({
|
||||
// rejected synchronously — two rapid clicks can both fire before React
|
||||
// re-renders the disabled state, and state alone can't distinguish them.
|
||||
const busyScheduleIdsRef = useRef<Set<string>>(new Set());
|
||||
// Guards the run-now follow-up: an auto-navigation into the started
|
||||
// session should not fire from a page the user already left.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
const beginScheduleAction = (scheduleId: string): boolean => {
|
||||
if (busyScheduleIdsRef.current.has(scheduleId)) {
|
||||
return false;
|
||||
@@ -551,6 +559,7 @@ export function RoutineSchedulesContent({
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const [showAllViewingRuns, setShowAllViewingRuns] = useState(false);
|
||||
const [viewingSchedule, setViewingSchedule] =
|
||||
useState<RoutineSchedule | null>(null);
|
||||
const [schedulePendingDelete, setSchedulePendingDelete] =
|
||||
@@ -774,6 +783,7 @@ export function RoutineSchedulesContent({
|
||||
lastExecutions,
|
||||
fetchedAt: now,
|
||||
};
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
@@ -825,17 +835,62 @@ export function RoutineSchedulesContent({
|
||||
setScheduleTriggering(schedule.scheduleId, true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await desktopClient.invoke("trigger_routine_schedule", {
|
||||
const reply = await desktopClient.invoke<{
|
||||
execution?: RoutineExecution | null;
|
||||
}>("trigger_routine_schedule", {
|
||||
schedule_id: schedule.scheduleId,
|
||||
});
|
||||
// A reply without an execution means no run was enqueued (the
|
||||
// schedule may have been disabled or deleted since the page
|
||||
// loaded) — say so instead of confirming a start.
|
||||
if (!reply?.execution) {
|
||||
toast({
|
||||
title: "Run not started",
|
||||
description: `"${schedule.name}" did not queue a run — the schedule may be disabled or deleted.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
await refreshSchedules({ force: true, showLoading: false });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Run started",
|
||||
description: `"${schedule.name}" was queued to run now.`,
|
||||
});
|
||||
await refreshSchedules({ force: true, showLoading: false });
|
||||
window.setTimeout(() => {
|
||||
void refreshSchedules({ force: true, showLoading: false });
|
||||
}, 1_000);
|
||||
// The trigger queues the run and returns before the runner starts
|
||||
// the agent session, so the session id usually is not attached
|
||||
// yet. Poll the overview (which also keeps the page's run status
|
||||
// fresh) until it appears, then jump into the session.
|
||||
// Only ever follow the execution the trigger itself named; matching
|
||||
// "the schedule's newest execution" could open a previous run's
|
||||
// session when the trigger failed to enqueue one.
|
||||
const executionId = reply.execution.executionId ?? null;
|
||||
let sessionId = reply.execution.sessionId?.trim() || null;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (
|
||||
!sessionId &&
|
||||
executionId &&
|
||||
mountedRef.current &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
const overview = await refreshSchedules({
|
||||
force: true,
|
||||
showLoading: false,
|
||||
});
|
||||
const executions = [
|
||||
...(overview?.activeExecutions ?? []),
|
||||
...(overview?.lastExecutions ?? []),
|
||||
];
|
||||
const match = executions.find(
|
||||
(execution) => execution.executionId === executionId,
|
||||
);
|
||||
sessionId = match?.sessionId?.trim() || null;
|
||||
if (!sessionId) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
if (sessionId && mountedRef.current) {
|
||||
await onOpenSession?.(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
@@ -1093,6 +1148,13 @@ export function RoutineSchedulesContent({
|
||||
);
|
||||
}, [schedules]);
|
||||
|
||||
// Collapse the runs list back to the recent-three preview whenever a
|
||||
// different schedule's details are opened.
|
||||
const viewingScheduleId = viewingSchedule?.scheduleId ?? null;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: viewingScheduleId is the reset trigger, not a value the effect reads
|
||||
useEffect(() => {
|
||||
setShowAllViewingRuns(false);
|
||||
}, [viewingScheduleId]);
|
||||
const viewingExecutions = useMemo(() => {
|
||||
if (!viewingSchedule) {
|
||||
return [];
|
||||
@@ -1164,10 +1226,35 @@ export function RoutineSchedulesContent({
|
||||
const upcoming = upcomingRuns.find(
|
||||
(item) => item.scheduleId === schedule.scheduleId,
|
||||
);
|
||||
// The whole card opens the details dialog; clicks on the row's
|
||||
// own controls (all button elements, including the Radix
|
||||
// switch) are excluded via closest().
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: The card contains nested action buttons and a switch, so the wrapper cannot be a native button.
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
className="cursor-pointer rounded-lg border border-border px-5 py-4 transition-colors hover:bg-surface-hover-lighter"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
(event.target as HTMLElement).closest(
|
||||
"button,a,input,textarea,[role='menuitem']",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setViewingSchedule(schedule);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setViewingSchedule(schedule);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
@@ -1193,25 +1280,13 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => setViewingSchedule(schedule)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit schedule</TooltipContent>
|
||||
@@ -1220,15 +1295,16 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() => void triggerSchedule(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{triggeringScheduleIds.has(schedule.scheduleId) ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1238,7 +1314,8 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? `Pause ${schedule.name}`
|
||||
@@ -1253,9 +1330,9 @@ export function RoutineSchedulesContent({
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1269,12 +1346,13 @@ export function RoutineSchedulesContent({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete schedule</TooltipContent>
|
||||
@@ -1408,124 +1486,121 @@ export function RoutineSchedulesContent({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="flex max-h-[85vh] flex-col sm:max-w-2xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Full configuration for this schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{viewingSchedule && (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="runs">
|
||||
Runs
|
||||
{viewingExecutions.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{viewingExecutions.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
className="mt-4 flex flex-col gap-3"
|
||||
value="overview"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Schedule:</span>{" "}
|
||||
{formatScheduleTrigger(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Mode:</span>{" "}
|
||||
{viewingSchedule.mode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Enabled:</span>{" "}
|
||||
{viewingSchedule.enabled ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.nextRunAt)}
|
||||
</p>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Schedule:</span>{" "}
|
||||
{formatScheduleTrigger(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Mode:</span>{" "}
|
||||
{viewingSchedule.mode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(viewingSchedule)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Enabled:</span>{" "}
|
||||
{viewingSchedule.enabled ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(viewingSchedule.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
{/* The JSON block scrolls internally past its cap so it
|
||||
cannot push the runs below it out of easy reach. */}
|
||||
<pre className="max-h-64 shrink-0 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{JSON.stringify(viewingSchedule, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Runs</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewingExecutions.length} result
|
||||
{viewingExecutions.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{viewingExecutions.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No runs yet.
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{JSON.stringify(viewingSchedule, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-4" value="runs">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Runs</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewingExecutions.length} result
|
||||
{viewingExecutions.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{viewingExecutions.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No runs yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{viewingExecutions.map((execution) => {
|
||||
const status = execution.status?.toLowerCase() ?? "";
|
||||
const succeeded = ["success", "completed"].includes(
|
||||
status,
|
||||
);
|
||||
const failed = ["failed", "timeout", "aborted"].includes(
|
||||
status,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
disabled={!execution.sessionId || !onOpenSession}
|
||||
key={execution.executionId}
|
||||
onClick={() => {
|
||||
if (execution.sessionId) {
|
||||
void onOpenSession?.(execution.sessionId);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{succeeded ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
) : failed ? (
|
||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium capitalize">
|
||||
{execution.status || "Unknown result"}
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{(showAllViewingRuns
|
||||
? viewingExecutions
|
||||
: viewingExecutions.slice(0, 3)
|
||||
).map((execution) => {
|
||||
const status = execution.status?.toLowerCase() ?? "";
|
||||
const succeeded = ["success", "completed"].includes(status);
|
||||
const failed = ["failed", "timeout", "aborted"].includes(
|
||||
status,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
disabled={!execution.sessionId || !onOpenSession}
|
||||
key={execution.executionId}
|
||||
onClick={() => {
|
||||
if (execution.sessionId) {
|
||||
void onOpenSession?.(execution.sessionId);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{succeeded ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
) : failed ? (
|
||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium capitalize">
|
||||
{execution.status || "Unknown result"}
|
||||
</span>
|
||||
{execution.errorMessage && (
|
||||
<span className="block truncate text-xs text-destructive">
|
||||
{execution.errorMessage}
|
||||
</span>
|
||||
{execution.errorMessage && (
|
||||
<span className="block truncate text-xs text-destructive">
|
||||
{execution.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatExecutionTimestamp(execution)}
|
||||
</span>
|
||||
{execution.sessionId && onOpenSession && (
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatExecutionTimestamp(execution)}
|
||||
</span>
|
||||
{execution.sessionId && onOpenSession && (
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showAllViewingRuns && viewingExecutions.length > 3 ? (
|
||||
<Button
|
||||
className="self-start text-muted-foreground"
|
||||
onClick={() => setShowAllViewingRuns(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Show all {viewingExecutions.length} runs
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1593,7 +1668,7 @@ export function RoutineSchedulesContent({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-name">Name</Label>
|
||||
<Input
|
||||
id="routine-name"
|
||||
@@ -1611,7 +1686,7 @@ export function RoutineSchedulesContent({
|
||||
<div className="sm:col-span-2 space-y-3">
|
||||
<Label>Schedule</Label>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border p-3">
|
||||
<div className="min-w-32 flex-1">
|
||||
<div className="min-w-32 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-schedule-type">Frequency</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
@@ -1639,7 +1714,7 @@ export function RoutineSchedulesContent({
|
||||
</Select>
|
||||
</div>
|
||||
{createForm.scheduleType === "once" && (
|
||||
<div className="min-w-40 flex-1">
|
||||
<div className="min-w-40 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-date">Date</Label>
|
||||
<Input
|
||||
id="routine-date"
|
||||
@@ -1675,7 +1750,7 @@ export function RoutineSchedulesContent({
|
||||
</div>
|
||||
)}
|
||||
{createForm.scheduleType === "weekly" && (
|
||||
<div className="min-w-44 flex-[1.4]">
|
||||
<div className="min-w-44 flex-[1.4] space-y-2">
|
||||
<Label>Days</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1718,7 +1793,7 @@ export function RoutineSchedulesContent({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-32 flex-1">
|
||||
<div className="min-w-32 flex-1 space-y-2">
|
||||
<Label htmlFor="routine-time">Time</Label>
|
||||
<Input
|
||||
id="routine-time"
|
||||
@@ -1751,7 +1826,7 @@ export function RoutineSchedulesContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-prompt">Prompt</Label>
|
||||
<Textarea
|
||||
id="routine-prompt"
|
||||
@@ -1766,7 +1841,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Combobox
|
||||
items={availableProviders}
|
||||
@@ -1810,7 +1885,7 @@ export function RoutineSchedulesContent({
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Combobox
|
||||
items={availableModelsForProvider}
|
||||
@@ -1841,7 +1916,7 @@ export function RoutineSchedulesContent({
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-workspace">Workspace</Label>
|
||||
<Input
|
||||
id="routine-workspace"
|
||||
@@ -1855,7 +1930,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="sm:col-span-2 space-y-2">
|
||||
<Label htmlFor="routine-system-prompt">
|
||||
System prompt (optional)
|
||||
</Label>
|
||||
@@ -1872,7 +1947,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routine-timeout">
|
||||
Timeout seconds (optional)
|
||||
</Label>
|
||||
@@ -1889,7 +1964,7 @@ export function RoutineSchedulesContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routine-tags">
|
||||
Tags (comma-separated, optional)
|
||||
</Label>
|
||||
|
||||
@@ -15,9 +15,20 @@ const ALL_SETTINGS_SECTIONS = [
|
||||
] as const;
|
||||
|
||||
// Customize is the unified hub for everything that extends Cline — skills,
|
||||
// MCP servers, plugins, rules, hooks, and tools — each as a sub-tab with
|
||||
// inline marketplace browsing where a catalog exists.
|
||||
const ALL_CUSTOMIZATION_SECTIONS = ["Customize"] as const;
|
||||
// MCP servers, plugins, rules, hooks, and tools. "Customize" is the installed
|
||||
// inventory (labeled "Installed" in the sidebar group); "Marketplace" is the
|
||||
// dedicated browse-and-install directory.
|
||||
const ALL_CUSTOMIZATION_SECTIONS = ["Customize", "Marketplace"] as const;
|
||||
|
||||
// Sidebar labels for the Customize group: the Customize section shows what is
|
||||
// installed, so its row reads "Installed" next to the Marketplace row.
|
||||
export const CUSTOMIZATION_SECTION_LABELS: Record<
|
||||
(typeof ALL_CUSTOMIZATION_SECTIONS)[number],
|
||||
string
|
||||
> = {
|
||||
Customize: "Installed",
|
||||
Marketplace: "Marketplace",
|
||||
};
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof ALL_SETTINGS_SECTIONS)[number]
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarketplaceView } from "../marketplace-view";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
@@ -577,7 +578,14 @@ export function SettingsView({
|
||||
onOpenModelProviders={() => onNavigateSection("Models")}
|
||||
/>
|
||||
) : activeNav === "Customize" ? (
|
||||
<CustomizeView />
|
||||
<CustomizeView
|
||||
onOpenMarketplace={() => onNavigateSection("Marketplace")}
|
||||
/>
|
||||
) : activeNav === "Marketplace" ? (
|
||||
<MarketplaceView
|
||||
onOpenInstalled={() => onNavigateSection("Customize")}
|
||||
variant="directory"
|
||||
/>
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
|
||||
@@ -270,6 +270,7 @@ export function VoiceInputContent({
|
||||
defaultTranscriptionModel(selectedEntry.models)?.id ===
|
||||
model.id;
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: the model picker is a styled radiogroup of buttons; aria-checked + role convey the semantics, and an <input type="radio"> would need a full restyle.
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { resolveCredentialError } from "./helpers";
|
||||
import { inferHydratedChatStatus, resolveCredentialError } from "./helpers";
|
||||
|
||||
function makeConfig(overrides: Partial<ChatSessionConfig>): ChatSessionConfig {
|
||||
return {
|
||||
@@ -53,3 +53,30 @@ describe("resolveCredentialError", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferHydratedChatStatus", () => {
|
||||
it("treats an assistant-answered running record as completed", () => {
|
||||
// The stale-record heuristic: a "running" record whose transcript
|
||||
// ends on an assistant answer is read as a session that died without
|
||||
// a status flip. (The stale-stream poll deliberately bypasses this
|
||||
// via mapSessionRecordStatus — see use-chat-session.)
|
||||
expect(
|
||||
inferHydratedChatStatus("running", [
|
||||
{
|
||||
id: "u",
|
||||
sessionId: "s",
|
||||
role: "user",
|
||||
content: "prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
sessionId: "s",
|
||||
role: "assistant",
|
||||
content: "answer",
|
||||
createdAt: 2,
|
||||
},
|
||||
]),
|
||||
).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,3 +221,16 @@ export function inferHydratedChatStatus(
|
||||
}
|
||||
return mapHistoryStatusToChatStatus(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session record's status mapped verbatim — no transcript inference. For
|
||||
* callers observing a session whose record is actively maintained by the
|
||||
* executing host (the stale-stream poll), the record is the authority;
|
||||
* inferHydratedChatStatus's stale-record heuristic would misread a mid-run
|
||||
* snapshot that happens to end on assistant narration as a finished session.
|
||||
*/
|
||||
export function mapSessionRecordStatus(
|
||||
status: SessionHistoryStatus,
|
||||
): ChatSessionStatus {
|
||||
return mapHistoryStatusToChatStatus(status);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,197 @@ describe("useChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("heals a running attached session with a dead event stream by polling history", async () => {
|
||||
// Scheduled runs can execute on a host whose live events never reach
|
||||
// this client; the transcript must still settle without a remount.
|
||||
const hydratedSessionId = "session-dead-stream";
|
||||
let readCount = 0;
|
||||
let recordReads = 0;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
readCount += 1;
|
||||
const base = [
|
||||
{
|
||||
id: "history-user",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "user",
|
||||
content: "tell me the current time",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
return readCount === 1
|
||||
? base
|
||||
: [
|
||||
...base,
|
||||
{
|
||||
id: "history-answer",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "assistant",
|
||||
content: "It is 12:28 PM PT.",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
recordReads += 1;
|
||||
// Still running on the first poll — the snapshot already
|
||||
// ends on assistant narration, which must NOT read as
|
||||
// finished while the record says running.
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: recordReads === 1 ? "running" : "completed",
|
||||
};
|
||||
}
|
||||
if (command === "read_session_hooks") return [];
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "attach") {
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
// Fake timers must be active before hydration so the fallback's
|
||||
// interval registers on the fake clock.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await current.hydrateSession({
|
||||
sessionId: hydratedSessionId,
|
||||
status: "running",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
expect(current.status).toBe("running");
|
||||
expect(current.messages).toHaveLength(1);
|
||||
|
||||
// No chat_event chunks arrive. The first poll surfaces the
|
||||
// narration mid-run; the record still says running, and the
|
||||
// record — not transcript shape — decides the status.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_100);
|
||||
});
|
||||
expect(current.messages).toHaveLength(2);
|
||||
expect(current.messages[1]?.content).toBe("It is 12:28 PM PT.");
|
||||
expect(current.status).toBe("running");
|
||||
|
||||
// The record flips to completed; the next poll mirrors it.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_100);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(current.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps the stale-stream poll inert while a local turn is in flight", async () => {
|
||||
// Regression: the fallback poll replaced an optimistic user bubble
|
||||
// (raw prompt) with its canonical envelope-wrapped twin, desyncing
|
||||
// the rekey bookkeeping so the stream appended a duplicate bubble.
|
||||
const hydratedSessionId = "session-local-turn";
|
||||
let readCount = 0;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
readCount += 1;
|
||||
return [
|
||||
{
|
||||
id: "history-user",
|
||||
sessionId: hydratedSessionId,
|
||||
role: "user",
|
||||
content: "earlier prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
return { sessionId: hydratedSessionId, status: "running" };
|
||||
}
|
||||
if (command === "read_session_hooks") return [];
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "attach" || request?.action === "start") {
|
||||
return {
|
||||
sessionId: hydratedSessionId,
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
};
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
// Keep the send unresolved: the local turn stays in
|
||||
// flight for the whole test.
|
||||
return await new Promise(() => {});
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
await current.hydrateSession({
|
||||
sessionId: hydratedSessionId,
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
const readsAfterHydration = readCount;
|
||||
|
||||
await act(async () => {
|
||||
void current.sendPrompt("what time is it");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("starting");
|
||||
expect(
|
||||
current.messages.filter((m) => m.content === "what time is it"),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Model produces nothing for a long quiet window; the poll must
|
||||
// not fire while the local turn is unsettled.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(readCount).toBe(readsAfterHydration);
|
||||
expect(
|
||||
current.messages.filter((m) => m.content === "what time is it"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes command updates after attaching to an in-flight tool call", async () => {
|
||||
const hydratedSessionId = "session-in-flight-command";
|
||||
invokeMock.mockImplementation(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
extractAssistantTurnDataFromRpcMessages,
|
||||
inferHydratedChatStatus,
|
||||
makeId,
|
||||
mapSessionRecordStatus,
|
||||
normalizeRuntimeConfig,
|
||||
resolveCredentialError,
|
||||
} from "@/hooks/chat-session/helpers";
|
||||
@@ -91,6 +92,12 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
|
||||
"stopping",
|
||||
]);
|
||||
|
||||
// Stale-stream fallback cadence for attached sessions (see the polling
|
||||
// effect below): only poll after the live stream has been quiet this long,
|
||||
// and re-check at this interval while it stays quiet.
|
||||
const STALE_STREAM_QUIET_MS = 5_000;
|
||||
const STALE_STREAM_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
type PendingToolOutput = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
@@ -382,6 +389,9 @@ export function useChatSession() {
|
||||
>([]);
|
||||
const [promptsInQueue, setPromptsInQueue] = useState<PromptInQueue[]>([]);
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
// When the last chat_event chunk for the active session arrived. The
|
||||
// stale-stream fallback below only polls while this stays quiet.
|
||||
const lastLiveChunkAtRef = useRef(0);
|
||||
const promptsInQueueRef = useRef<PromptInQueue[]>([]);
|
||||
const liveToolMessageIdsRef = useRef<Record<string, string>>({});
|
||||
const pendingToolOutputRef = useRef(new Map<string, PendingToolOutput>());
|
||||
@@ -1224,6 +1234,7 @@ export function useChatSession() {
|
||||
if (!listeningSessionId || payload.sessionId !== listeningSessionId) {
|
||||
return;
|
||||
}
|
||||
lastLiveChunkAtRef.current = Date.now();
|
||||
if (abortedRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1800,6 +1811,119 @@ export function useChatSession() {
|
||||
};
|
||||
}, [clearLiveToolRefs, finalizeSettledTurn]);
|
||||
|
||||
// ---- Stale-stream fallback for attached sessions ----
|
||||
// Scheduled/automation runs execute on a session host whose events are
|
||||
// not projected through the hub's live pipeline (and with several hub
|
||||
// daemons sharing cron.db, a different daemon can claim the run
|
||||
// entirely), so an attached session can sit at "running" with a dead
|
||||
// event stream — stuck on the thinking shimmer until a remount re-reads
|
||||
// history. While an attached session is busy and the stream is quiet,
|
||||
// poll canonical history and the session record so the transcript and
|
||||
// status heal in place. A locally driven turn keeps chunks flowing, so
|
||||
// the quiet-window guard keeps this fallback out of the way there.
|
||||
useEffect(() => {
|
||||
if (!sessionId || hydratedHistorySessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (!BUSY_STATUSES.has(status)) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let polling = false;
|
||||
const poll = async () => {
|
||||
if (cancelled || polling) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS) {
|
||||
return;
|
||||
}
|
||||
// An assistant bubble mid-stream means the live pipeline works;
|
||||
// canonical history could lag behind it.
|
||||
if (activeAssistantMessageIdRef.current) {
|
||||
return;
|
||||
}
|
||||
// A locally driven turn is in flight (submit/queue bumps the epoch;
|
||||
// settling closes it). Its optimistic user bubble carries the raw
|
||||
// prompt while canonical history stores it wrapped in a
|
||||
// user_input envelope, so replacing state mid-turn desyncs the
|
||||
// rekey bookkeeping and the stream then appends a duplicate
|
||||
// bubble. The fallback exists for externally driven runs
|
||||
// (schedules, other clients) — stay inert until the local turn
|
||||
// settles.
|
||||
if (
|
||||
turnEpochRef.current !== turnSettledEpochRef.current ||
|
||||
outstandingOptimisticUserIdsRef.current.size > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
polling = true;
|
||||
try {
|
||||
const pollStartedAt = Date.now();
|
||||
const [historyMessages, record] = await Promise.all([
|
||||
desktopClient
|
||||
.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
})
|
||||
.catch(() => null),
|
||||
desktopClient
|
||||
.invoke<{ status?: string } | null>("get_discovered_session", {
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
if (
|
||||
cancelled ||
|
||||
activeSessionIdRef.current !== sessionId ||
|
||||
// The live stream resumed (or a local turn started) while
|
||||
// the poll was in flight; live state is fresher than the
|
||||
// snapshot we just read.
|
||||
Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS ||
|
||||
activeAssistantMessageIdRef.current ||
|
||||
turnEpochRef.current !== turnSettledEpochRef.current ||
|
||||
outstandingOptimisticUserIdsRef.current.size > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(historyMessages) && historyMessages.length > 0) {
|
||||
const mergedMessages = mergeHydratedMessagesWithLive({
|
||||
hydrated: historyMessages,
|
||||
current: messagesRef.current,
|
||||
sessionId,
|
||||
hydrationStartedAt: pollStartedAt,
|
||||
});
|
||||
// Same as hydration: canonical rows may have replaced live
|
||||
// tool rows, so rebuild the tool routing keys or later
|
||||
// tool events would append instead of updating in place.
|
||||
const liveToolState = deriveLiveToolState(mergedMessages);
|
||||
liveToolMessageIdsRef.current = liveToolState.messageIds;
|
||||
liveToolInputsRef.current = liveToolState.inputs;
|
||||
setMessages(mergedMessages);
|
||||
}
|
||||
// The record is the authority here: the sessions this poll
|
||||
// serves have a live host maintaining their record, and it
|
||||
// flips to a terminal status when the run ends. Transcript
|
||||
// inference (inferHydratedChatStatus) would misread a mid-run
|
||||
// snapshot ending on assistant narration as finished, hiding
|
||||
// the working indicator and disarming this poll.
|
||||
const nextStatus = record?.status?.trim();
|
||||
if (nextStatus) {
|
||||
setStatus(mapSessionRecordStatus(nextStatus as SessionHistoryStatus));
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
const interval = window.setInterval(
|
||||
() => void poll(),
|
||||
STALE_STREAM_POLL_INTERVAL_MS,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hydratedHistorySessionId, sessionId, status]);
|
||||
|
||||
// ---- Shared: start a new session via RPC ----
|
||||
|
||||
const startSession = useCallback(
|
||||
@@ -2742,6 +2866,10 @@ export function useChatSession() {
|
||||
activeSessionIdRef.current = session.sessionId;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
// A freshly hydrated session has no local turn in flight; without
|
||||
// this the mount defaults (epoch 0, settled -1) read as an open
|
||||
// turn and keep the stale-stream fallback inert forever.
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setHydratedHistorySessionId(session.sessionId);
|
||||
setPendingToolApprovals([]);
|
||||
setPendingAskQuestions([]);
|
||||
|
||||
@@ -118,6 +118,56 @@ describe("useSessionHistory session mapping", () => {
|
||||
current.threads.find((thread) => thread.id === "regular-session"),
|
||||
).toMatchObject({ source: "core", isScheduled: false });
|
||||
});
|
||||
|
||||
it("marks sessions scheduled when a schedule execution names them", async () => {
|
||||
// Scheduled runs executed by the local hub don't reliably stamp the
|
||||
// hub-schedule trigger into session metadata, so the executions list
|
||||
// is the fallback signal.
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: { limit?: number }) => {
|
||||
if (command === "list_discovered_sessions") {
|
||||
return await new Promise<unknown[]>((resolve, reject) => {
|
||||
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
|
||||
});
|
||||
}
|
||||
if (command === "list_routine_schedules") {
|
||||
return {
|
||||
activeExecutions: [{ sessionId: "cron-active" }],
|
||||
lastExecutions: [{ sessionId: "cron-session" }, {}],
|
||||
};
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([
|
||||
{
|
||||
...sessionRow("cron-session"),
|
||||
source: "core",
|
||||
metadata: { sessionHistoryOrigin: { mode: "user" } },
|
||||
},
|
||||
{
|
||||
...sessionRow("regular-session"),
|
||||
source: "core",
|
||||
metadata: { sessionHistoryOrigin: { mode: "user" } },
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "cron-session"),
|
||||
).toMatchObject({ isScheduled: true });
|
||||
expect(
|
||||
current.threads.find((thread) => thread.id === "regular-session"),
|
||||
).toMatchObject({ isScheduled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory initial load", () => {
|
||||
|
||||
@@ -504,6 +504,14 @@ export function useSessionHistory({
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Session ids that schedule executions report as their own. Scheduled runs
|
||||
// executed by the local hub do not reliably carry the "hub-schedule"
|
||||
// origin trigger in their session metadata (the runtime that claims the
|
||||
// run doesn't always stamp provenance), so the metadata check alone would
|
||||
// miss them; the executions list is the authoritative link.
|
||||
const [scheduledSessionIds, setScheduledSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
|
||||
// Limit of the most recent refresh that actually returned sessions. Failed
|
||||
// attempts roll back to this rather than to a caller-local snapshot, which
|
||||
@@ -553,6 +561,52 @@ export function useSessionHistory({
|
||||
});
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const collectScheduledSessionIds = async () => {
|
||||
const response = await desktopClient
|
||||
.invoke<{
|
||||
activeExecutions?: Array<{ sessionId?: unknown }>;
|
||||
lastExecutions?: Array<{ sessionId?: unknown }>;
|
||||
}>("list_routine_schedules")
|
||||
.catch(() => null);
|
||||
if (cancelled || !response) {
|
||||
return;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const execution of [
|
||||
...(response.activeExecutions ?? []),
|
||||
...(response.lastExecutions ?? []),
|
||||
]) {
|
||||
const sessionId =
|
||||
typeof execution?.sessionId === "string"
|
||||
? execution.sessionId.trim()
|
||||
: "";
|
||||
if (sessionId) {
|
||||
ids.add(sessionId);
|
||||
}
|
||||
}
|
||||
setScheduledSessionIds((current) => {
|
||||
// Merge instead of replace: the executions list is a rolling
|
||||
// window, so ids that fell out of it are still scheduled runs.
|
||||
const next = new Set(current);
|
||||
for (const id of ids) {
|
||||
next.add(id);
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
};
|
||||
void collectScheduledSessionIds();
|
||||
const interval = window.setInterval(
|
||||
() => void collectScheduledSessionIds(),
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
// Reuse an in-flight refresh only when it already asked for at least as
|
||||
// many sessions as we need now. "Load more" raises the limit and then
|
||||
@@ -1429,6 +1483,17 @@ export function useSessionHistory({
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const threadsWithScheduled = useMemo(() => {
|
||||
if (scheduledSessionIds.size === 0) {
|
||||
return threads;
|
||||
}
|
||||
return threads.map((thread) =>
|
||||
!thread.isScheduled && scheduledSessionIds.has(thread.id)
|
||||
? { ...thread, isScheduled: true }
|
||||
: thread,
|
||||
);
|
||||
}, [scheduledSessionIds, threads]);
|
||||
|
||||
return {
|
||||
getSessionByThreadId,
|
||||
hasLoadedHistory,
|
||||
@@ -1446,7 +1511,7 @@ export function useSessionHistory({
|
||||
forkThread,
|
||||
sessionById,
|
||||
sessions,
|
||||
threads,
|
||||
threads: threadsWithScheduled,
|
||||
unreadSessionIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
export const INITIAL_VISIBLE_THREAD_COUNT = 10;
|
||||
// One page of sidebar rows. Large enough to fill the sidebar on a tall
|
||||
// window (10 left a stub of rows over empty space); history fetches start at
|
||||
// 50, so the first page never needs an extra request.
|
||||
export const INITIAL_VISIBLE_THREAD_COUNT = 30;
|
||||
|
||||
export type SidebarProjectGroup = {
|
||||
id: string;
|
||||
|
||||
@@ -355,6 +355,8 @@ describe("buildSessionConfig", () => {
|
||||
|
||||
expect(config.providerId).toBe("cline")
|
||||
expect(config.apiKey).toBe("workos:test-access-token")
|
||||
expect(config.systemPrompt).toContain("# Workspace Configuration")
|
||||
expect(config.systemPrompt).toContain(JSON.stringify("/tmp/workspace"))
|
||||
})
|
||||
|
||||
it("resolves ClinePass from the shared Cline OAuth credentials", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// The factory does NOT handle UI concerns — that's the SdkController's job.
|
||||
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCoreStartInput,
|
||||
type CoreSessionConfig,
|
||||
getProviderAuthHandler,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@cline/llms"
|
||||
import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import { buildClineSystemPrompt, isClineProvider } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ClineClient } from "@shared/cline"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
@@ -901,10 +902,17 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
|
||||
: resolveProviderReasoningConfig(providerId)
|
||||
|
||||
// Build the system prompt using the shared prompt builder. Core still
|
||||
// expects callers to provide a concrete systemPrompt, but the prompt builder
|
||||
// can derive baseline workspace context from the root path and workspace
|
||||
// name, so we avoid duplicating core's richer workspace metadata pass here.
|
||||
// Include rich workspace metadata so Cline API observability can extract
|
||||
// git remotes and the latest commit hash from the system message.
|
||||
let workspaceMetadata: string | undefined
|
||||
if (isClineProvider(providerId)) {
|
||||
try {
|
||||
workspaceMetadata = await buildWorkspaceMetadata(workspaceRoot)
|
||||
} catch (error) {
|
||||
Logger.warn("[SessionFactory] Failed to build workspace metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
let systemPrompt = ""
|
||||
try {
|
||||
const workspaceName = resolveWorkspaceName(cwd)
|
||||
@@ -912,6 +920,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
ide: "VS Code",
|
||||
workspaceRoot,
|
||||
workspaceName,
|
||||
metadata: workspaceMetadata,
|
||||
mode: mode === "plan" ? "plan" : "act",
|
||||
providerId,
|
||||
platform: process.platform,
|
||||
|
||||
@@ -554,6 +554,30 @@ describe("ProviderCatalog Phase 3.5 listProviders", () => {
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(expect.anything(), { isClinePassEnabled: true })
|
||||
})
|
||||
|
||||
it("passes the SDK's subscription usage-cost display through to listings", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
id: "openai-codex",
|
||||
name: "OpenAI ChatGPT Subscription",
|
||||
authDescription: "OpenAI ChatGPT subscription access uses an OAuth device code flow.",
|
||||
protocol: "openai-responses",
|
||||
client: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
})
|
||||
const providerId = parseProviderId("openai-codex")
|
||||
const catalog = createProviderCatalog(makeReader({ providerId }))
|
||||
|
||||
const listings = await catalog.listProviders()
|
||||
|
||||
expect(listings).toHaveLength(1)
|
||||
expect(listings[0].usageCostDisplay).toBe("subscription")
|
||||
})
|
||||
|
||||
it("caches provider listings per catalog instance without reading provider config", async () => {
|
||||
const { createProviderCatalog } = await import("./catalog")
|
||||
mocks.listLocalProviders.mockResolvedValue({
|
||||
|
||||
@@ -48,13 +48,13 @@ const DEFAULT_MODEL_CATALOG_CONFIG: ModelCatalogConfig = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the SDK's usage-cost-display answer (string union) into the
|
||||
* extension's {@link UsageCostDisplay} type. The SDK function takes a
|
||||
* provider id (not metadata) and consults its own registry; we forward
|
||||
* the id and trust the answer rather than re-parsing the metadata bag.
|
||||
* Read the SDK's usage-cost-display answer for a provider. The SDK
|
||||
* function takes a provider id (not metadata) and consults its own
|
||||
* registry; we forward the id and trust the answer rather than
|
||||
* re-parsing the metadata bag.
|
||||
*/
|
||||
function readUsageCostDisplay(providerId: string): UsageCostDisplay {
|
||||
return resolveProviderUsageCostDisplay(providerId) === "hide" ? "hide" : "show"
|
||||
return resolveProviderUsageCostDisplay(providerId)
|
||||
}
|
||||
|
||||
function makeCacheKey(providerId: ProviderId, fingerprint: Fingerprint): CacheKey {
|
||||
|
||||
@@ -263,11 +263,13 @@ export interface Disposable {
|
||||
/**
|
||||
* SDK-driven hint for how to display per-token / total cost in the UI.
|
||||
* Mirrors `ProviderUsageCostDisplay` from `@cline/llms` and the CLI's
|
||||
* `shouldShowCliUsageCost` consumer. When `"hide"`, downstream UIs MUST
|
||||
* suppress per-token pricing rows (in model info cards) and total-cost
|
||||
* lines (in task summaries / status bars).
|
||||
* `shouldShowCliUsageCost` consumer. Anything other than `"show"` means
|
||||
* downstream UIs MUST suppress per-token pricing rows (in model info
|
||||
* cards) and total-cost lines (in task summaries / status bars);
|
||||
* `"subscription"` additionally signals that usage is covered by the
|
||||
* user's subscription (e.g. ClinePass, ChatGPT Plus/Pro).
|
||||
*/
|
||||
export type UsageCostDisplay = "show" | "hide"
|
||||
export type UsageCostDisplay = "show" | "hide" | "subscription"
|
||||
|
||||
export interface ProviderListing {
|
||||
readonly id: ProviderId
|
||||
|
||||
@@ -92,8 +92,10 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
// Local providers report no cost; the openai-compatible provider can
|
||||
// report cost only when the user has supplied both prices. For every
|
||||
// other provider, the SDK is the source of truth for whether to render
|
||||
// per-task cost: providers with `metadata.usageCostDisplay = "hide"`
|
||||
// (e.g. ChatGPT Plus/Pro subscription) are filtered out here. This
|
||||
// per-task cost: any `metadata.usageCostDisplay` other than "show" —
|
||||
// "hide", or "subscription" for flat-rate providers like ClinePass and
|
||||
// ChatGPT Plus/Pro where the computed figure would be an API-rate
|
||||
// estimate rather than a real charge — suppresses the cost here. This
|
||||
// mirrors the CLI's `shouldShowCliUsageCost` consumer and removes the
|
||||
// previous extension-side hard-coded "openai-codex" check.
|
||||
const usageCostDisplay = useProviderUsageCostDisplay(modeFields.apiProvider)
|
||||
@@ -105,7 +107,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
(modeFields.apiProvider !== "vscode-lm" &&
|
||||
modeFields.apiProvider !== "ollama" &&
|
||||
modeFields.apiProvider !== "lmstudio" &&
|
||||
usageCostDisplay !== "hide")
|
||||
usageCostDisplay === "show")
|
||||
|
||||
// Event handlers
|
||||
const toggleTaskExpanded = useCallback(() => setIsTaskExpanded(!isTaskExpanded), [setIsTaskExpanded, isTaskExpanded])
|
||||
|
||||
@@ -178,8 +178,8 @@ interface ModelInfoViewProps {
|
||||
/**
|
||||
* Suppress the per-token pricing display (compact input/output row, cache
|
||||
* pricing in Advanced, and tiered pricing). Set this for providers whose
|
||||
* billing is subscription-based or otherwise not per-token, mirroring the
|
||||
* SDK's `ProviderInfo.metadata.usageCostDisplay = "hide"` signal (see
|
||||
* billing is subscription-based or otherwise not per-token — any
|
||||
* `ProviderInfo.metadata.usageCostDisplay` other than `"show"` (see
|
||||
* `resolveProviderUsageCostDisplay` in `@cline/llms`).
|
||||
*/
|
||||
hideUsageCost?: boolean
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
import { useProviderModelSelection } from "@/hooks/useProviderModelSelection"
|
||||
import { useProviderModels } from "@/hooks/useProviderModels"
|
||||
import { useProviderUsageCostDisplay } from "@/hooks/useProviderUsageCostDisplay"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
@@ -27,6 +28,9 @@ export const ClaudeCodeProvider = ({ showModelOptions, isPopup, currentMode }: C
|
||||
const { handleFieldChange } = useApiConfigurationHandlers()
|
||||
const providerId = "claude-code"
|
||||
const { models, defaultModelId } = useProviderModels(providerId)
|
||||
// The models reuse Anthropic API pricing metadata, but usage is billed
|
||||
// through the Claude subscription — suppress the per-token price rows.
|
||||
const hideUsageCost = useProviderUsageCostDisplay(providerId) !== "show"
|
||||
const { config, write, commitSelection } = useProviderConfig(providerId)
|
||||
const { selectedModelId, selectedModelInfo, commitModelSelection } = useProviderModelSelection(providerId, currentMode, {
|
||||
models,
|
||||
@@ -99,7 +103,12 @@ export const ClaudeCodeProvider = ({ showModelOptions, isPopup, currentMode }: C
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelInfoView isPopup={isPopup} modelInfo={selectedModelInfo} selectedModelId={selectedModelId} />
|
||||
<ModelInfoView
|
||||
hideUsageCost={hideUsageCost}
|
||||
isPopup={isPopup}
|
||||
modelInfo={selectedModelInfo}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useDynamicProviderSelection(
|
||||
apiConfiguration: ApiConfiguration | undefined,
|
||||
mode: Mode,
|
||||
): DynamicProviderSelection {
|
||||
const hideUsageCost = useProviderUsageCostDisplay(providerId) === "hide"
|
||||
const hideUsageCost = useProviderUsageCostDisplay(providerId) !== "show"
|
||||
return useMemo(() => {
|
||||
const fields = readFields(apiConfiguration, mode)
|
||||
const fallbackInfo = FALLBACK_INFO_BY_PROVIDER[providerId] ?? openAiModelInfoSafeDefaults
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { useProviderListings } from "./useProviderListings"
|
||||
import { useProviderUsageCostDisplay } from "./useProviderUsageCostDisplay"
|
||||
|
||||
vi.mock("./useProviderListings", () => ({
|
||||
useProviderListings: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseProviderListings = vi.mocked(useProviderListings)
|
||||
|
||||
function withListings(providers: Array<{ id: string; usageCostDisplay: string }>) {
|
||||
mockUseProviderListings.mockReturnValue({
|
||||
providers,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useProviderListings>)
|
||||
}
|
||||
|
||||
describe("useProviderUsageCostDisplay", () => {
|
||||
it("returns the subscription mark for subscription-billed providers", () => {
|
||||
withListings([{ id: "cline-pass", usageCostDisplay: "subscription" }])
|
||||
const { result } = renderHook(() => useProviderUsageCostDisplay("cline-pass"))
|
||||
expect(result.current).toBe("subscription")
|
||||
})
|
||||
|
||||
it("returns hide for providers the SDK marks as hide", () => {
|
||||
withListings([{ id: "some-provider", usageCostDisplay: "hide" }])
|
||||
const { result } = renderHook(() => useProviderUsageCostDisplay("some-provider"))
|
||||
expect(result.current).toBe("hide")
|
||||
})
|
||||
|
||||
it("shows cost for providers marked show and for unknown providers", () => {
|
||||
withListings([{ id: "openrouter", usageCostDisplay: "show" }])
|
||||
expect(renderHook(() => useProviderUsageCostDisplay("openrouter")).result.current).toBe("show")
|
||||
expect(renderHook(() => useProviderUsageCostDisplay("anthropic")).result.current).toBe("show")
|
||||
expect(renderHook(() => useProviderUsageCostDisplay(undefined)).result.current).toBe("show")
|
||||
})
|
||||
|
||||
it("returns unknown while listings have not arrived", () => {
|
||||
withListings([])
|
||||
const { result } = renderHook(() => useProviderUsageCostDisplay("cline-pass"))
|
||||
expect(result.current).toBe("unknown")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMemo } from "react"
|
||||
import { useProviderListings } from "./useProviderListings"
|
||||
|
||||
export type UsageCostDisplay = "show" | "hide" | "subscription"
|
||||
|
||||
const USAGE_COST_DISPLAYS: readonly UsageCostDisplay[] = ["show", "hide", "subscription"]
|
||||
|
||||
function isUsageCostDisplay(value: string | undefined): value is UsageCostDisplay {
|
||||
return !!value && USAGE_COST_DISPLAYS.includes(value as UsageCostDisplay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces the SDK's `usageCostDisplay` decision for a single provider
|
||||
* id. The decision originates in the `@cline/llms` SDK (see
|
||||
@@ -8,26 +16,39 @@ import { useProviderListings } from "./useProviderListings"
|
||||
* `apps/vscode/src/sdk/model-catalog/catalog.ts`) and is propagated
|
||||
* through the `ProviderListing.usage_cost_display` gRPC field.
|
||||
*
|
||||
* Returns `"hide"` when the SDK reports a subscription-style provider
|
||||
* whose per-token / total cost displays should be suppressed (matches
|
||||
* the CLI's `shouldShowCliUsageCost` consumer in `sdk/apps/cli`). Falls
|
||||
* back to `"show"` while the listings are still loading or for any
|
||||
* provider the SDK does not explicitly mark — the same default policy
|
||||
* the SDK and CLI use.
|
||||
* Returns `"hide"` when cost is unknowable or meaningless for the
|
||||
* provider, and `"subscription"` when usage is billed through a
|
||||
* flat-rate subscription (e.g. ChatGPT Plus/Pro, ClinePass) — in that
|
||||
* case any computed dollar figure is a per-token API-rate estimate, not
|
||||
* an actual charge. Cost displays must render only for `"show"`, which
|
||||
* matches the CLI's `shouldShowCliUsageCost` consumer.
|
||||
*
|
||||
* Webview consumers must pass the returned value into
|
||||
* `ModelInfoView.hideUsageCost` (and equivalent cost-display sites)
|
||||
* rather than re-deriving it. If a new provider needs to suppress cost,
|
||||
* set `metadata.usageCostDisplay = "hide"` in the SDK provider builtin;
|
||||
* Returns `"unknown"` until the listings have arrived (and if the
|
||||
* listing request failed). Rendering cost in that window would flash a
|
||||
* dollar estimate at subscription users — the exact display this hook
|
||||
* exists to suppress — so consumers treat `"unknown"` like any other
|
||||
* non-`"show"` value and render nothing. Once listings are present, a
|
||||
* provider the SDK does not explicitly mark falls back to `"show"`,
|
||||
* the same default policy the SDK and CLI use.
|
||||
*
|
||||
* Webview consumers must derive cost visibility from the returned value
|
||||
* (`=== "show"`, or `ModelInfoView.hideUsageCost`) rather than
|
||||
* re-deriving it per provider. If a new provider needs to suppress
|
||||
* cost, set `metadata.usageCostDisplay` in the SDK provider builtin;
|
||||
* the webview picks it up without any change here.
|
||||
*/
|
||||
export function useProviderUsageCostDisplay(providerId: string | undefined): "show" | "hide" {
|
||||
export function useProviderUsageCostDisplay(providerId: string | undefined): UsageCostDisplay | "unknown" {
|
||||
const { providers } = useProviderListings()
|
||||
return useMemo(() => {
|
||||
if (!providerId) {
|
||||
return "show"
|
||||
}
|
||||
const listing = providers.find((p) => p.id === providerId)
|
||||
return listing?.usageCostDisplay === "hide" ? "hide" : "show"
|
||||
// Listings are never legitimately empty (builtin providers always
|
||||
// exist), so an empty array means "not loaded yet" or "load failed".
|
||||
if (providers.length === 0) {
|
||||
return "unknown"
|
||||
}
|
||||
const value = providers.find((p) => p.id === providerId)?.usageCostDisplay
|
||||
return isUsageCostDisplay(value) ? value : "show"
|
||||
}, [providers, providerId])
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useStaticProviderSelection(
|
||||
hideUsageCost: boolean
|
||||
} {
|
||||
const { models, defaultModelId } = useProviderModels(providerId)
|
||||
const hideUsageCost = useProviderUsageCostDisplay(providerId) === "hide"
|
||||
const hideUsageCost = useProviderUsageCostDisplay(providerId) !== "show"
|
||||
|
||||
const fallbackSavedModelId =
|
||||
currentMode === "plan" ? apiConfiguration?.planModeApiModelId : apiConfiguration?.actModeApiModelId
|
||||
|
||||
@@ -127,6 +127,25 @@ describe("MCP install service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats -- in marketplace args as the end of option parsing", () => {
|
||||
// Marketplace catalog entries mark the start of the stdio command with
|
||||
// "--" (matching the CLI form `cline mcp install name -- npx ...`).
|
||||
// The separator must not become the command itself.
|
||||
const parsed = parseMcpInstallArgs([
|
||||
"aikido",
|
||||
"--",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aikidosec/mcp@1.0.9",
|
||||
]);
|
||||
expect(parsed.targetArgs).toEqual(["npx", "-y", "@aikidosec/mcp@1.0.9"]);
|
||||
expect(buildMcpInstallTransport(parsed).transport).toEqual({
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@aikidosec/mcp@1.0.9"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps transport-like values as stdio command args for direct builder input", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
|
||||
@@ -90,6 +90,13 @@ function splitTargetArgsAndHeaders(input: {
|
||||
const args = input.targetArgs ?? [];
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
// Marketplace-style args use "--" to end option parsing (matching how
|
||||
// commander handles the CLI form); everything after it is the verbatim
|
||||
// stdio command. Without this the separator itself becomes the command.
|
||||
if (input.parseTransport && arg === "--") {
|
||||
targetArgs.push(...args.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (input.parseTransport && arg === "--transport") {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
|
||||
@@ -17,6 +17,11 @@ describe("provider usage cost display", () => {
|
||||
expect(shouldShowProviderUsageCost("openai-codex-cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides usage cost for the Claude Code subscription provider", () => {
|
||||
expect(resolveProviderUsageCostDisplay("claude-code")).toBe("subscription");
|
||||
expect(shouldShowProviderUsageCost("claude-code")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows usage cost by default for usage-billed providers", () => {
|
||||
expect(resolveProviderUsageCostDisplay("openai-native")).toBe("show");
|
||||
expect(resolveProviderUsageCostDisplay("anthropic")).toBe("show");
|
||||
|
||||
@@ -1160,6 +1160,12 @@ const BUILTIN_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
modelsFactory: buildClaudeCodeModels,
|
||||
defaults: { baseUrl: "" },
|
||||
configFields: [],
|
||||
// Claude Code is typically authenticated with a Pro/Max subscription,
|
||||
// where any dollar figure would be an API-rate estimate rather than a
|
||||
// real charge. The CLI does report a cost when it runs on API-key
|
||||
// billing, but the provider cannot tell the two apart from here, so
|
||||
// prefer not showing a number over showing a misleading one.
|
||||
metadata: { usageCostDisplay: "subscription" },
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MODE_TAG_INSTRUCTIONS,
|
||||
PLAN_MODE_INSTRUCTIONS,
|
||||
PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH,
|
||||
processWorkspaceInfo,
|
||||
} from "./cline";
|
||||
|
||||
const BASE_OPTIONS = {
|
||||
@@ -13,6 +14,29 @@ const BASE_OPTIONS = {
|
||||
platform: "linux",
|
||||
};
|
||||
|
||||
describe("processWorkspaceInfo", () => {
|
||||
it("redacts URL credentials while preserving SCP-style SSH remotes", () => {
|
||||
const metadata = JSON.parse(
|
||||
processWorkspaceInfo({
|
||||
rootPath: "/workspace/project",
|
||||
associatedRemoteUrls: [
|
||||
"origin: https://user:token@github.com/cline/cline.git",
|
||||
"backup: ssh://git:secret@example.com/cline/cline.git",
|
||||
"mirror: git@github.com:cline/cline.git",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
metadata.workspaces["/workspace/project"].associatedRemoteUrls,
|
||||
).toEqual([
|
||||
"origin: https://github.com/cline/cline.git",
|
||||
"backup: ssh://example.com/cline/cline.git",
|
||||
"mirror: git@github.com:cline/cline.git",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClineSystemPrompt mode instructions", () => {
|
||||
it("explains the user_input mode attribute in act mode", () => {
|
||||
const prompt = buildClineSystemPrompt({ ...BASE_OPTIONS, mode: "act" });
|
||||
@@ -78,6 +102,27 @@ describe("buildClineSystemPrompt mode instructions", () => {
|
||||
expect(rulesIndex).toBeLessThan(prompt.indexOf(MODE_TAG_INSTRUCTIONS));
|
||||
});
|
||||
|
||||
it("includes rich workspace metadata for the Cline backend parser", () => {
|
||||
const metadata = JSON.stringify({
|
||||
workspaces: {
|
||||
"/workspace/project": {
|
||||
hint: "project",
|
||||
associatedRemoteUrls: [
|
||||
"origin: https://github.com/cline/cline.git",
|
||||
],
|
||||
latestGitCommitHash: "abc123",
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = buildClineSystemPrompt({
|
||||
...BASE_OPTIONS,
|
||||
providerId: "cline",
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(prompt).toContain(`# Workspace Configuration\n${metadata}`);
|
||||
});
|
||||
|
||||
it("respects an explicit override prompt without injecting mode sections", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
...BASE_OPTIONS,
|
||||
|
||||
@@ -58,13 +58,39 @@ export const PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH = `${PLAN_MODE_INSTRUCTIONS_BA
|
||||
|
||||
Once you have presented your plan, end your turn and wait for the user's response. You do NOT have the ability to switch to act mode yourself -- the user must do it manually with the Plan/Act toggle once they are satisfied with the plan. If the task requires tools that are only available in act mode, ask the user to "toggle to Act mode" (use those words).`;
|
||||
|
||||
function redactRemoteUrlCredentials(remote: string): string {
|
||||
const schemeEnd = remote.indexOf("://");
|
||||
if (schemeEnd < 1) return remote;
|
||||
|
||||
const authorityStart = schemeEnd + 3;
|
||||
let authorityEnd = authorityStart;
|
||||
while (authorityEnd < remote.length) {
|
||||
const char = remote[authorityEnd];
|
||||
if (
|
||||
char === "/" ||
|
||||
char === "?" ||
|
||||
char === "#" ||
|
||||
char.charCodeAt(0) <= 32
|
||||
) {
|
||||
break;
|
||||
}
|
||||
authorityEnd++;
|
||||
}
|
||||
|
||||
const userInfoEnd = remote.lastIndexOf("@", authorityEnd - 1);
|
||||
if (userInfoEnd < authorityStart) return remote;
|
||||
return remote.slice(0, authorityStart) + remote.slice(userInfoEnd + 1);
|
||||
}
|
||||
|
||||
export function processWorkspaceInfo(info: WorkspaceInfo): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
workspaces: {
|
||||
[info.rootPath]: {
|
||||
hint: info.hint,
|
||||
associatedRemoteUrls: info.associatedRemoteUrls,
|
||||
associatedRemoteUrls: info.associatedRemoteUrls?.map(
|
||||
redactRemoteUrlCredentials,
|
||||
),
|
||||
latestGitCommitHash: info.latestGitCommitHash,
|
||||
latestGitBranchName: info.latestGitBranchName,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user