mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbb42a99c | ||
|
|
50d1578a7e | ||
|
|
5ef3b81369 | ||
|
|
ec3a57771d | ||
|
|
a695dab23a | ||
|
|
77af52661c | ||
|
|
0f4acccd08 | ||
|
|
f8c73cd8cc | ||
|
|
55a31a0d8a | ||
|
|
04438c0d54 | ||
|
|
f053ec48e4 | ||
|
|
0df406723c | ||
|
|
12703bf407 | ||
|
|
4a97b46f5f | ||
|
|
36fc3327ac | ||
|
|
2b48dc411f | ||
|
|
da6fe718d0 | ||
|
|
3515333e23 | ||
|
|
bd9ac5872b | ||
|
|
fb15324ad2 | ||
|
|
7a27c04ffa | ||
|
|
c37b252f65 | ||
|
|
2872138900 | ||
|
|
dc4620c529 | ||
|
|
2ac5c85e69 | ||
|
|
ab68fd7f34 | ||
|
|
b4ed8a226e | ||
|
|
cbf40961db | ||
|
|
2d05ba52da | ||
|
|
5d3778b5cf | ||
|
|
8d0eb54a1f | ||
|
|
a3989acc38 | ||
|
|
d199b1bff9 | ||
|
|
d41eed1198 | ||
|
|
6309971089 | ||
|
|
2d2c669421 | ||
|
|
c5f146a418 | ||
|
|
261ee4c313 | ||
|
|
d45b051c04 | ||
|
|
78c83cdf33 | ||
|
|
3266121fa1 | ||
|
|
6467de65a2 | ||
|
|
65fe885638 |
@@ -126,7 +126,7 @@ jobs:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -260,6 +260,41 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -280,3 +315,26 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -42,6 +42,8 @@ event names. It exports:
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
|
||||
**All events should be named using snake_case and so should their properties**
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
The canonical funnel that downstream analytics depends on:
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
- Fixed provider config not reloading when switching models
|
||||
- Fixed auto-update failing to detect Bun global installs after symlink resolution
|
||||
- Fixed unexpected logouts caused by transient network or server errors during token refresh
|
||||
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
|
||||
- Session id is now preserved when continuing within the same session
|
||||
- Hardened context compaction budget handling
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.39",
|
||||
"version": "3.0.40",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
@@ -118,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
|
||||
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
|
||||
+20
-3
@@ -928,6 +928,17 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "main",
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
@@ -971,9 +982,15 @@ export async function runCli(): Promise<void> {
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAccountId = selectedProviderSettings?.auth?.accountId;
|
||||
if (savedAccountId) {
|
||||
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
|
||||
const savedAuth = selectedProviderSettings?.auth;
|
||||
if (savedAuth?.accountId) {
|
||||
identifyTelemetryAccount({
|
||||
id: savedAuth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: savedAuth.organizationId,
|
||||
organizationName: savedAuth.organizationName,
|
||||
memberId: savedAuth.memberId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -130,7 +130,7 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(360_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
|
||||
@@ -61,11 +61,15 @@ export async function compactInteractiveMessages(input: {
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
modelInfo?.maxInputTokens ??
|
||||
modelInfo?.contextWindow ??
|
||||
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
|
||||
const compactionModelInfo = modelInfo
|
||||
? {
|
||||
...modelInfo,
|
||||
id: modelInfo.id ?? input.config.modelId,
|
||||
}
|
||||
: {
|
||||
id: input.config.modelId,
|
||||
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
|
||||
};
|
||||
const compact = createContextCompactionPrepareTurn(
|
||||
{
|
||||
providerConfig: resolveCompactionProviderConfig(
|
||||
@@ -106,11 +110,7 @@ export async function compactInteractiveMessages(input: {
|
||||
model: {
|
||||
id: input.config.modelId,
|
||||
provider: input.config.providerId,
|
||||
info: {
|
||||
...(modelInfo ?? {}),
|
||||
id: modelInfo?.id ?? input.config.modelId,
|
||||
maxInputTokens: maxInputTokens,
|
||||
},
|
||||
info: compactionModelInfo,
|
||||
},
|
||||
});
|
||||
if (!result?.messages) {
|
||||
|
||||
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
|
||||
@@ -157,6 +157,7 @@ function makeManager() {
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -814,6 +815,83 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
|
||||
@@ -49,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
|
||||
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
|
||||
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
|
||||
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
export type SessionConnectionUpdate = Parameters<
|
||||
CliCore["updateSessionConnection"]
|
||||
>[1];
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
@@ -210,12 +213,18 @@ export function createInteractiveSessionRuntime(input: {
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
// For continuing the same conversation, e.g. after a config change.
|
||||
sessionId?: string,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: buildSessionConfig(),
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
@@ -415,7 +424,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
@@ -431,6 +447,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
@@ -473,9 +490,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
{ preserveSessionId: true },
|
||||
);
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
// No live session to update; the next startup builds its config from
|
||||
// the already-mutated CLI config, so nothing else is needed.
|
||||
return;
|
||||
}
|
||||
await manager.updateSessionConnection(sessionId, update);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
await restartWithMessages([]);
|
||||
};
|
||||
@@ -609,7 +641,22 @@ export function createInteractiveSessionRuntime(input: {
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
// Report carried context from what the new session actually accepted:
|
||||
// the host can reject the inherited state (e.g. stale anchor), and the
|
||||
// UI must not claim a carry-over that did not happen.
|
||||
const acceptedState = projectedMessages
|
||||
? await readCompactionState(activeSessionId)
|
||||
: undefined;
|
||||
return {
|
||||
forkedFromSessionId,
|
||||
newSessionId: activeSessionId,
|
||||
carriedWorkingContext: acceptedState
|
||||
? {
|
||||
workingContextMessages: acceptedState.messages.length,
|
||||
canonicalMessages: messages.length,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
@@ -840,6 +887,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -9,23 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
|
||||
- Read files, search the codebase, and gather context to understand the problem
|
||||
- Ask clarifying questions when requirements are ambiguous
|
||||
- Present your plan as a structured outline with clear steps
|
||||
- Explain tradeoffs between different approaches when they exist
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
explicitSystemPrompt?: string;
|
||||
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
|
||||
mode?: AgentMode;
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
// Mode-tag and plan-mode instructions are appended by the shared prompt
|
||||
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
|
||||
// @cline/shared), so only the caller-specific rules are merged here.
|
||||
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: input.cwd,
|
||||
|
||||
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
"ClinePass limit reached",
|
||||
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
].join("\n");
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -769,6 +802,126 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_LIMIT_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function applyInteractiveModelChange(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>;
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
| "ensureReady"
|
||||
| "restartWithCurrentMessages"
|
||||
| "updateCurrentSessionConnection"
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { config, providerSettingsManager, sessionRuntime } = input;
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
|
||||
// Provider changes affect more than the model connection: startup resolves
|
||||
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
|
||||
// the runtime with the existing transcript so all of that state changes
|
||||
// together. restartWithCurrentMessages preserves the session ID.
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
// A same-ID restart reuses the existing manifest. Sync its connection label
|
||||
// after the fully configured runtime is live so session history reflects the
|
||||
// provider/model that will handle subsequent turns.
|
||||
await sessionRuntime.updateCurrentSessionConnection({
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -687,25 +732,12 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -151,6 +151,38 @@ export async function createClineAccountService(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
@@ -183,6 +215,7 @@ export async function loadClineAccountSnapshot(input: {
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
@@ -131,7 +134,7 @@ function formatToolParams(
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={f.path}>
|
||||
<span key={`${i}:${f.path}`}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -419,6 +422,86 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactionDividerRow(props: {
|
||||
entry: Extract<ChatEntry, { kind: "compaction" }>;
|
||||
}) {
|
||||
const { entry } = props;
|
||||
const { width: terminalWidth } = useTerminalDimensions();
|
||||
const inProgress = entry.status === "started";
|
||||
const labelColor = inProgress
|
||||
? "cyan"
|
||||
: entry.status === "failed"
|
||||
? "red"
|
||||
: entry.status === "cancelled" || entry.status === "skipped"
|
||||
? "gray"
|
||||
: "cyan";
|
||||
const label = `✻ ${formatCompactionDividerLabel(entry)} ✻`;
|
||||
// Fill the remaining line with a plain rule instead of a flexGrow bordered
|
||||
// box: a single fixed-content text row keeps the renderer's diffing stable.
|
||||
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
{inProgress ? (
|
||||
<box width={2}>
|
||||
<spinner name="dots" color={labelColor} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg="gray" content="── " />
|
||||
)}
|
||||
<text fg={labelColor} selectable content={label} />
|
||||
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -534,6 +617,15 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -557,6 +649,9 @@ export function ChatEntryView(props: {
|
||||
</box>
|
||||
);
|
||||
|
||||
case "compaction":
|
||||
return <CompactionDividerRow entry={entry} />;
|
||||
|
||||
case "done": {
|
||||
const parts: string[] = [];
|
||||
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
/**
|
||||
* Persist a manually entered API key for an OAuth-capable provider — the
|
||||
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
|
||||
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
|
||||
* stale token would otherwise keep winning over the manual key.
|
||||
*
|
||||
* The key is written both to the provider's auth storage entry (cline-pass
|
||||
* stores credentials under "cline") and to the provider's own entry: settings
|
||||
* resolution lets a direct entry shadow the storage entry, and provider
|
||||
* switching copies merged settings (including auth) into direct entries, so
|
||||
* both must be updated for the manual key to reliably take effect.
|
||||
*/
|
||||
export function saveManualProviderApiKey(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): void {
|
||||
// Empty strings delete these keys from the stored auth object.
|
||||
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId: storageProviderId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
if (
|
||||
providerId !== storageProviderId &&
|
||||
manager.read().providers[providerId]
|
||||
) {
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isProviderConfigured,
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
@@ -16,3 +27,99 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualProviderApiKey", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createManager(): ProviderSettingsManager {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
|
||||
tempDirs.push(dir);
|
||||
return new ProviderSettingsManager({
|
||||
filePath: join(dir, "providers.json"),
|
||||
});
|
||||
}
|
||||
|
||||
it("clears stored OAuth tokens so the manual key takes effect", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
accountId: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline", "manual-api-key");
|
||||
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
expect(settings?.apiKey).toBe("manual-api-key");
|
||||
expect(settings?.auth?.accessToken).toBeUndefined();
|
||||
expect(settings?.auth?.refreshToken).toBeUndefined();
|
||||
expect(settings?.auth?.accountId).toBe("acct_123");
|
||||
expect(getPersistedProviderApiKey("cline", settings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline", settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves cline-pass keys to the shared cline auth storage entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
// cline-pass inherits auth storage from the "cline" entry, so the key
|
||||
// must land there and the stale tokens must be gone for both providers.
|
||||
const clineSettings = manager.getProviderSettings("cline");
|
||||
expect(clineSettings?.apiKey).toBe("manual-api-key");
|
||||
expect(clineSettings?.auth?.accessToken).toBeUndefined();
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears stale credentials copied into a direct cline-pass entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
// Provider switching copies the merged settings (including auth) into
|
||||
// a direct cline-pass entry, which shadows the shared "cline" entry.
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline-pass",
|
||||
apiKey: "stale-copied-key",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -724,13 +727,27 @@ export function CodexCliStatusContent(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `true` on successful login, `"use_api_key"` when the user opts
|
||||
* into manual API key entry (only offered with `allowApiKeyFallback`).
|
||||
*/
|
||||
export type OAuthLoginResult = boolean | "use_api_key";
|
||||
|
||||
export function OAuthLoginContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
props: ChoiceContext<OAuthLoginResult> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
allowApiKeyFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerId, providerName } = props;
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -863,9 +880,19 @@ export function OAuthLoginContent(
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
@@ -892,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -915,8 +942,83 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual API key entry for OAuth-capable providers — the escape hatch for
|
||||
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
|
||||
* the manual key takes effect (see saveManualProviderApiKey).
|
||||
*/
|
||||
export function OAuthApiKeyInputContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const apiKey = value.trim();
|
||||
if (!apiKey) return;
|
||||
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
submit();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
Use an API key from your Cline dashboard instead of OAuth login. This
|
||||
replaces any saved login tokens.
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">API key</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onInput={setValue}
|
||||
placeholder="Paste your API key"
|
||||
flexGrow={1}
|
||||
focused
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Enter to save, Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
|
||||
|
||||
export interface LocalSlashCommandActionInput {
|
||||
name: string;
|
||||
isRunning: boolean;
|
||||
openAccount: () => void;
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
input.runCompact();
|
||||
// Autocomplete can invoke local commands while a turn is running. Keep
|
||||
// /compact handled, but do not let it take ownership of the active turn's
|
||||
// shared running state.
|
||||
if (!input.isRunning) {
|
||||
input.runCompact();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalized === "fork") {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type AccountDialogAction,
|
||||
AccountDialogContent,
|
||||
} from "../components/dialogs/account-dialog";
|
||||
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
|
||||
import {
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export function useAccountDialog(opts: {
|
||||
@@ -60,14 +63,14 @@ export function useAccountDialog(opts: {
|
||||
return;
|
||||
}
|
||||
if (action === "login") {
|
||||
const saved = await dialog.choice<boolean>({
|
||||
const saved = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
if (saved === true) {
|
||||
await onAccountChange?.();
|
||||
await openAccountDialog();
|
||||
return;
|
||||
|
||||
@@ -6,13 +6,14 @@ import type {
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import { resolveNonCompactionStatusLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
formatToolOutput,
|
||||
truncate,
|
||||
} from "../../utils/helpers";
|
||||
import type { ChatEntry, InlineStream, TuiProps } from "../types";
|
||||
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
|
||||
|
||||
interface AgentEventDeps {
|
||||
appendEntry: (entry: ChatEntry) => void;
|
||||
@@ -32,6 +33,7 @@ interface AgentEventDeps {
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const openCompactionEntryRef = useRef(false);
|
||||
const {
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
verbose,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
// streaming. Appending them immediately would split the message in two, so
|
||||
// they are held until the active content block closes (or the turn ends).
|
||||
const pendingCompactionEntriesRef = useRef<
|
||||
Extract<ChatEntry, { kind: "compaction" }>[]
|
||||
>([]);
|
||||
|
||||
const flushPendingCompactionEntries = useCallback(() => {
|
||||
const pending = pendingCompactionEntriesRef.current;
|
||||
if (pending.length === 0) return;
|
||||
pendingCompactionEntriesRef.current = [];
|
||||
for (const entry of pending) {
|
||||
if (entry.status !== "started" && openCompactionEntryRef.current) {
|
||||
updateEntry((current) =>
|
||||
current.kind === "compaction" && current.status === "started"
|
||||
? { ...current, ...entry }
|
||||
: current,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry(entry);
|
||||
if (entry.status === "started") {
|
||||
openCompactionEntryRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [appendEntry, updateEntry]);
|
||||
|
||||
const finalizeDanglingCompactionEntry = useCallback(
|
||||
(status: "failed" | "cancelled") => {
|
||||
if (!openCompactionEntryRef.current) return;
|
||||
openCompactionEntryRef.current = false;
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status }
|
||||
: entry,
|
||||
);
|
||||
},
|
||||
[updateEntry],
|
||||
);
|
||||
|
||||
const closeToolEntry = useCallback(
|
||||
(event: AgentEvent & { type: "content_end" }) => {
|
||||
const error = event.error ?? undefined;
|
||||
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(true);
|
||||
setIsStreaming(true);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "iteration_end":
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "content_start": {
|
||||
setIsStreaming(false);
|
||||
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("cancelled");
|
||||
break;
|
||||
case "error":
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("failed");
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
break;
|
||||
case "notice":
|
||||
if (event.displayRole === "status") {
|
||||
closeInlineStream();
|
||||
const label = resolveStatusNoticeLabel(event);
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (!compaction) {
|
||||
closeInlineStream();
|
||||
}
|
||||
if (compaction) {
|
||||
if (activeInlineStreamRef.current) {
|
||||
// An assistant message is still streaming; appending now
|
||||
// would split it around the divider. Hold the divider (final
|
||||
// state until the content block closes, then reconcile it
|
||||
// with the same open divider atomically.
|
||||
pendingCompactionEntriesRef.current.push({
|
||||
kind: "compaction",
|
||||
...compaction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (compaction.status === "started") {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
openCompactionEntryRef.current = true;
|
||||
} else if (openCompactionEntryRef.current) {
|
||||
// Finalize the in-progress divider in place, wherever it
|
||||
// sits in the transcript.
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, ...compaction }
|
||||
: entry,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const label = resolveNonCompactionStatusLabel(event);
|
||||
if (label) {
|
||||
appendEntry({ kind: "status", text: label });
|
||||
}
|
||||
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
[
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
updateEntry,
|
||||
closeInlineStream,
|
||||
activeInlineStreamRef,
|
||||
setIsRunning,
|
||||
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeActions(
|
||||
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
|
||||
): Omit<LocalSlashCommandActionInput, "name"> {
|
||||
return {
|
||||
isRunning: false,
|
||||
openAccount: vi.fn(),
|
||||
openConfig: vi.fn(),
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
|
||||
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
|
||||
});
|
||||
|
||||
it("does not start compaction while a turn is running", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ isRunning: true, runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts compaction while the session is idle", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for clear to reset the runtime session", async () => {
|
||||
let resolveClear: (() => void) | undefined;
|
||||
const clearConversation = vi.fn(
|
||||
|
||||
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import type { AppView, TuiProps } from "../types";
|
||||
import { formatCompactionStatus } from "../utils/compaction-status";
|
||||
import { hydrateSessionMessages } from "../utils/hydrate-messages";
|
||||
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
|
||||
import { HistoryDialogContent } from "../views/history-view";
|
||||
@@ -116,21 +115,42 @@ export function useLocalCommandActions(input: {
|
||||
}, [dialog, refocusTextarea, termHeight]);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
session.setIsRunning(true);
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: "Compacting context...",
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
});
|
||||
try {
|
||||
const result = await onCompact();
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "status",
|
||||
text: formatCompactionStatus(result),
|
||||
}));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? {
|
||||
...entry,
|
||||
status: result.compacted ? "completed" : "skipped",
|
||||
messagesBefore: result.messagesBefore,
|
||||
messagesAfter:
|
||||
result.workingContextMessagesAfter ?? result.messagesAfter,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
} catch (error) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
const cancelled =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || /abort/i.test(error.message));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status: cancelled ? "cancelled" : "failed" }
|
||||
: entry,
|
||||
);
|
||||
if (!cancelled) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session.setIsRunning(false);
|
||||
}
|
||||
}, [onCompact, session]);
|
||||
|
||||
@@ -159,6 +179,15 @@ export function useLocalCommandActions(input: {
|
||||
kind: "status",
|
||||
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
|
||||
}));
|
||||
if (result.carriedWorkingContext) {
|
||||
session.appendEntry({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: result.carriedWorkingContext.canonicalMessages,
|
||||
messagesAfter: result.carriedWorkingContext.workingContextMessages,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "error",
|
||||
@@ -181,6 +210,7 @@ export function useLocalCommandActions(input: {
|
||||
}
|
||||
return runLocalSlashCommandAction({
|
||||
name: resolved.name,
|
||||
isRunning: session.isRunning,
|
||||
invocation,
|
||||
openAccount,
|
||||
openConfig,
|
||||
@@ -209,6 +239,7 @@ export function useLocalCommandActions(input: {
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
session.isRunning,
|
||||
slashCommandRegistry,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
refreshProviderModelsFromSource,
|
||||
resolveProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
@@ -21,7 +22,9 @@ import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
@@ -79,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
|
||||
* using the provider's stored API key and headers, mirroring the extension's
|
||||
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
|
||||
* to manual model-id entry.
|
||||
*/
|
||||
async function fetchOpenAiCompatibleModelIds(
|
||||
providerId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const config = manager.getProviderConfig(providerId, {
|
||||
includeKnownModels: false,
|
||||
});
|
||||
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!baseUrl || !URL.canParse(baseUrl)) return [];
|
||||
|
||||
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
|
||||
const apiKey = config?.apiKey?.trim();
|
||||
if (
|
||||
apiKey &&
|
||||
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
|
||||
) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const ids = list
|
||||
.map((model) => {
|
||||
const id = (model as { id?: unknown } | null)?.id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -131,6 +179,23 @@ async function runProviderChange(
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
const supportsManualApiKey = isClineProvider(newProviderId);
|
||||
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
|
||||
await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthApiKeyInputContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
providerSettingsManager={manager}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
let option: ExistingProviderOption | undefined;
|
||||
@@ -165,17 +230,22 @@ async function runProviderChange(
|
||||
if (needsAuth) {
|
||||
let saved: boolean | undefined;
|
||||
if (isOAuthProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
const loginResult = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
allowApiKeyFallback={supportsManualApiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
saved =
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
@@ -275,12 +345,28 @@ export function useModelSelector(opts: {
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
let providerDisplayName = config.providerId;
|
||||
let endpointModelOptions: ModelOption[] = [];
|
||||
|
||||
const refreshProviderContext = async () => {
|
||||
modelOptions = buildModelOptions(
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
providerDisplayName = await getProviderDisplayName(config.providerId);
|
||||
// Free-text providers (openai-compatible) can still suggest model
|
||||
// ids when their endpoint answers /models; otherwise they keep the
|
||||
// manual input.
|
||||
endpointModelOptions = usesModelIdInput(config.providerId)
|
||||
? buildModelOptions(
|
||||
Object.fromEntries(
|
||||
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
|
||||
(id) => [id, { id, name: id }],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (endpointModelOptions.length > 0) {
|
||||
modelOptions = endpointModelOptions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!options?.startWithProviderChange) {
|
||||
@@ -316,7 +402,10 @@ export function useModelSelector(opts: {
|
||||
let pickingModel = true;
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
if (
|
||||
usesModelIdInput(config.providerId) &&
|
||||
endpointModelOptions.length === 0
|
||||
) {
|
||||
const modelId = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
})),
|
||||
isDestroyed: false,
|
||||
on: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
const rootMock = vi.hoisted(() => ({
|
||||
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
destroyHandlers.length = 0;
|
||||
rendererMock.isDestroyed = false;
|
||||
rendererMock.destroy.mockReset();
|
||||
rendererMock.setTerminalTitle.mockReset();
|
||||
rendererMock.on.mockReset();
|
||||
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "destroy") {
|
||||
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the terminal title before destroying the renderer", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
const titleCallOrder =
|
||||
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
|
||||
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
|
||||
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
|
||||
});
|
||||
|
||||
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
// Simulate OpenTUI's own signal handler destroying the renderer in the
|
||||
// same dispatch (e.g. an idle SIGTERM fires both our handler and
|
||||
// OpenTUI's exitHandler before microtasks drain).
|
||||
rendererMock.isDestroyed = true;
|
||||
for (const handler of destroyHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function renderOpenTui(
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
// Reset the title while the native renderer is still alive; the
|
||||
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
|
||||
// Re-check here: OpenTUI's own signal handlers can destroy the
|
||||
// renderer between destroy() queuing this microtask and it running
|
||||
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -472,15 +473,7 @@ function App(props: TuiProps) {
|
||||
};
|
||||
}, [renderer, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderer.setTerminalTitle("");
|
||||
};
|
||||
}, [renderer]);
|
||||
useTerminalTitle(renderer, terminalTitle);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -44,6 +44,15 @@ export type ChatEntry = (
|
||||
}
|
||||
| { kind: "error"; text: string }
|
||||
| { kind: "status"; text: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
compactionMode: "auto" | "manual" | "inherited";
|
||||
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
messagesBefore?: number;
|
||||
messagesAfter?: number;
|
||||
}
|
||||
| { kind: "team"; text: string }
|
||||
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
|
||||
| {
|
||||
@@ -186,7 +195,15 @@ export interface TuiProps {
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
{ forkedFromSessionId: string; newSessionId: string } | undefined
|
||||
| {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
formatTokenCount,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "./compaction-status";
|
||||
|
||||
describe("parseCompactionNoticeMetadata", () => {
|
||||
it("extracts a divider entry from a completed auto-compaction notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
reason: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a streaming divider entry from a started notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "started" });
|
||||
});
|
||||
|
||||
it("maps manual compaction notices to manual mode", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
})?.compactionMode,
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("maps a benign no-result terminal notice to skipped", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "skipped",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "skipped" });
|
||||
});
|
||||
|
||||
it("ignores non-compaction metadata", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
|
||||
).toBeUndefined();
|
||||
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-numeric counters instead of rendering garbage", () => {
|
||||
const parsed = parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: "25000",
|
||||
tokensAfter: Number.NaN,
|
||||
});
|
||||
expect(parsed?.tokensBefore).toBeUndefined();
|
||||
expect(parsed?.tokensAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats counts into compact units", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(6_300)).toBe("6.3k");
|
||||
expect(formatTokenCount(25_000)).toBe("25k");
|
||||
expect(formatTokenCount(1_200_000)).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactionDividerLabel", () => {
|
||||
it("includes token and message deltas when present", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
|
||||
});
|
||||
|
||||
it("labels in-progress compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Auto compacting messages");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Compacting messages");
|
||||
});
|
||||
|
||||
it("labels failed and cancelled compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "failed",
|
||||
}),
|
||||
).toBe("Compaction failed");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "cancelled",
|
||||
}),
|
||||
).toBe("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("labels skipped compaction without calling it cancelled", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "skipped",
|
||||
}),
|
||||
).toBe("Compaction skipped");
|
||||
});
|
||||
|
||||
it("labels inherited working context from forks and restarts", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: 60,
|
||||
messagesAfter: 15,
|
||||
}),
|
||||
).toBe("Compacted working context carried over · 60 → 15 messages");
|
||||
});
|
||||
|
||||
it("labels manual compaction and omits missing counters", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "completed",
|
||||
}),
|
||||
).toBe("Context compacted (manual)");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,106 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
import type { ChatEntry, InteractiveCompactionResult } from "../types";
|
||||
|
||||
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a compaction divider entry from a status notice's metadata.
|
||||
* "started" notices produce a streaming (in-progress) divider; "completed"
|
||||
* notices produce the final divider with counters. Returns undefined for
|
||||
* non-compaction notices.
|
||||
*/
|
||||
export function parseCompactionNoticeMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Omit<CompactionDividerEntry, "kind"> | undefined {
|
||||
if (
|
||||
!metadata ||
|
||||
(metadata.phase !== "started" &&
|
||||
metadata.phase !== "completed" &&
|
||||
metadata.phase !== "skipped")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = metadata.kind ?? metadata.reason;
|
||||
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
|
||||
return undefined;
|
||||
}
|
||||
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
|
||||
if (metadata.phase === "started") {
|
||||
return { compactionMode, status: "started" };
|
||||
}
|
||||
if (metadata.phase === "skipped") {
|
||||
return { compactionMode, status: "skipped" };
|
||||
}
|
||||
return {
|
||||
compactionMode,
|
||||
status: "completed",
|
||||
tokensBefore: asFiniteNumber(metadata.tokensBefore),
|
||||
tokensAfter: asFiniteNumber(metadata.tokensAfter),
|
||||
messagesBefore: asFiniteNumber(metadata.messagesBefore),
|
||||
messagesAfter: asFiniteNumber(metadata.messagesAfter),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1_000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
export function formatCompactionDividerLabel(
|
||||
entry: CompactionDividerEntry,
|
||||
): string {
|
||||
if (entry.status === "started") {
|
||||
return entry.compactionMode === "manual"
|
||||
? "Compacting messages"
|
||||
: "Auto compacting messages";
|
||||
}
|
||||
if (entry.status === "failed") {
|
||||
return "Compaction failed";
|
||||
}
|
||||
if (entry.status === "cancelled") {
|
||||
return "Compaction cancelled";
|
||||
}
|
||||
if (entry.status === "skipped") {
|
||||
return "Compaction skipped";
|
||||
}
|
||||
const parts: string[] = [
|
||||
entry.compactionMode === "manual"
|
||||
? "Context compacted (manual)"
|
||||
: entry.compactionMode === "inherited"
|
||||
? "Compacted working context carried over"
|
||||
: "Context compacted",
|
||||
];
|
||||
if (
|
||||
typeof entry.tokensBefore === "number" &&
|
||||
typeof entry.tokensAfter === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${formatTokenCount(entry.tokensBefore)} → ${formatTokenCount(entry.tokensAfter)} tokens`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof entry.messagesBefore === "number" &&
|
||||
typeof entry.messagesAfter === "number"
|
||||
) {
|
||||
parts.push(`${entry.messagesBefore} → ${entry.messagesAfter} messages`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ChatCommandState = {
|
||||
export type ForkSessionResult = {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
/**
|
||||
* Present when the source session had valid compaction state that was
|
||||
* re-anchored onto the forked session, so the UI can surface why the
|
||||
* next request is smaller than the canonical history.
|
||||
*/
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MuteCommandInput = {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
const config = createConfig({ enabled: true, maxInputTokens: 123 });
|
||||
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
|
||||
|
||||
applyCliCompactionMode(config, "basic");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("basic");
|
||||
|
||||
applyCliCompactionMode(config, "off");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: false,
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("off");
|
||||
});
|
||||
|
||||
@@ -42,14 +42,23 @@ describe("resolveStatusNoticeLabel", () => {
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errorOutput = "";
|
||||
setCurrentOutputMode("text");
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
errorOutput += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errorOutput += `${args.map(String).join(" ")}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a ⎿ before text that follows a tool block", () => {
|
||||
@@ -196,6 +205,23 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("formats ClinePass limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("ClinePass limit reached");
|
||||
expect(errorOutput).toContain("Switch to Cline usage-based billing");
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "../tui/utils/compaction-status";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -23,6 +28,24 @@ const TEAM_RUN_ACTIVE_SUFFIX = `${c.dim} ...${c.reset}`;
|
||||
|
||||
export function resolveStatusNoticeLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (compaction) {
|
||||
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
|
||||
}
|
||||
return resolveNonCompactionStatusLabel(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a status notice already known not to be a compaction notice.
|
||||
* Callers that have parsed the compaction metadata themselves use this to
|
||||
* avoid re-parsing.
|
||||
*/
|
||||
export function resolveNonCompactionStatusLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
@@ -181,7 +204,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(event.error.message);
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -42,11 +42,12 @@ export function getPersistedProviderApiKey(
|
||||
* or endpoint config for the provider. Used by the picker to decide whether
|
||||
* to offer "Use existing configuration?" before opening the configure dialog.
|
||||
*
|
||||
* Treats OAuth providers as configured when an access token is present; for
|
||||
* everything else, any persisted API key, base URL, or model id counts. We
|
||||
* don't enforce required fields here — the runtime no longer pre-flights
|
||||
* credentials, so a missing key only matters when the API call actually
|
||||
* runs and the provider's own auth error is surfaced.
|
||||
* Treats OAuth providers as configured when an access token or a manually
|
||||
* saved API key is present (the /settings escape hatch for when OAuth isn't
|
||||
* working); for everything else, any persisted API key, base URL, or model id
|
||||
* counts. We don't enforce required fields here — the runtime no longer
|
||||
* pre-flights credentials, so a missing key only matters when the API call
|
||||
* actually runs and the provider's own auth error is surfaced.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
providerId: string,
|
||||
@@ -54,7 +55,8 @@ export function isProviderConfigured(
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
|
||||
return Boolean(getPersistedProviderApiKey(providerId, settings));
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
if (settings.baseUrl?.trim()) return true;
|
||||
|
||||
@@ -20,10 +20,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
// react-markdown/streamdown pass the hast `Element` here, whose
|
||||
// `properties` is a broad `Record`. Keep this assignable from that type
|
||||
// (rather than a narrow `{ metastring?: string }`) so the component stays
|
||||
// compatible with `Components` regardless of how strict the resolved
|
||||
// hast/streamdown types are; the metastring value is validated at read time.
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,7 +70,8 @@ const MarkdownCode = ({
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const metaValue = node?.properties?.metastring;
|
||||
const meta = typeof metaValue === "string" ? metaValue : undefined;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
@@ -16,6 +16,15 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
|
||||
package. Other Cline web surfaces can take only its tokens or opt into the
|
||||
Tailwind adapter and shared base styles without depending on the desktop
|
||||
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
|
||||
desktop integration notes.
|
||||
|
||||
## Shareable Desktop Packages
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
@@ -53,6 +55,9 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -63,7 +68,6 @@
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"marked": "^17.0.3",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -71,11 +75,11 @@
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
@@ -85,6 +89,7 @@
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionConnectionUpdate } from "./chat-session";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
@@ -49,3 +57,188 @@ describe("buildSessionConnectionUpdate", () => {
|
||||
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUpdateSessionConnection", () => {
|
||||
it("skips the redundant connection update on the first send", () => {
|
||||
const config = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the connection when the selected reasoning level changes", () => {
|
||||
const current = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldUpdateSessionConnection(current, {
|
||||
...current,
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
|
||||
function createContext(options?: {
|
||||
attachedViaHub?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
text: "done",
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
}));
|
||||
const sessionId = "session-connection-test";
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: options?.config ?? baseConfig,
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
attachedViaHub: options?.attachedViaHub ?? false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionManager: { send, updateSessionConnection },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, sessionId, updateSessionConnection };
|
||||
}
|
||||
|
||||
it("skips an identical update for a locally-created session", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
send.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes hub-attached sessions even when the cached config matches", async () => {
|
||||
const { ctx, sessionId, updateSessionConnection } = createContext({
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "hello",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace metadata prewarming", () => {
|
||||
it("reuses one in-flight scan and consumes it only once", async () => {
|
||||
let resolveFirst: ((value: string) => void) | undefined;
|
||||
const firstResult = new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockImplementationOnce(async () => await firstResult)
|
||||
.mockResolvedValueOnce("fresh metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-reuse";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
const consumed = consumeWorkspaceMetadata(cwd, load);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
resolveFirst?.("prewarmed metadata");
|
||||
|
||||
await expect(consumed).resolves.toBe("prewarmed metadata");
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"fresh metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts failed scans so the next session can retry", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("git unavailable"))
|
||||
.mockResolvedValueOnce("recovered metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-retry";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
|
||||
"git unavailable",
|
||||
);
|
||||
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
|
||||
"recovered metadata",
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps different workspaces in separate single-flight entries", () => {
|
||||
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
|
||||
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
|
||||
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes a prewarm that is older than the startup window", async () => {
|
||||
const load = vi
|
||||
.fn<(cwd: string) => Promise<string>>()
|
||||
.mockResolvedValueOnce("startup metadata")
|
||||
.mockResolvedValueOnce("current metadata");
|
||||
const cwd = "/tmp/cline-desktop-prewarm-expired";
|
||||
|
||||
prewarmWorkspaceMetadata(cwd, load, () => 0);
|
||||
await expect(
|
||||
consumeWorkspaceMetadata(
|
||||
cwd,
|
||||
load,
|
||||
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
|
||||
),
|
||||
).resolves.toBe("current metadata");
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
@@ -24,6 +26,69 @@ type SessionConnectionUpdate = Parameters<
|
||||
ClineCore["updateSessionConnection"]
|
||||
>[1];
|
||||
|
||||
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
|
||||
type WorkspaceMetadataCacheEntry = {
|
||||
createdAt: number;
|
||||
promise: Promise<string>;
|
||||
};
|
||||
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
|
||||
const workspaceMetadataPromises = new Map<
|
||||
string,
|
||||
WorkspaceMetadataCacheEntry
|
||||
>();
|
||||
|
||||
function getWorkspaceMetadataPromise(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader,
|
||||
now: () => number,
|
||||
): { key: string; promise: Promise<string> } {
|
||||
const key = resolve(cwd);
|
||||
const existing = workspaceMetadataPromises.get(key);
|
||||
const createdAt = now();
|
||||
if (
|
||||
existing &&
|
||||
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
|
||||
) {
|
||||
return { key, promise: existing.promise };
|
||||
}
|
||||
const promise = load(key);
|
||||
workspaceMetadataPromises.set(key, { createdAt, promise });
|
||||
void promise.catch(() => {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
});
|
||||
return { key, promise };
|
||||
}
|
||||
|
||||
export function prewarmWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): void {
|
||||
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
|
||||
}
|
||||
|
||||
export async function consumeWorkspaceMetadata(
|
||||
cwd: string,
|
||||
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
|
||||
now: () => number = Date.now,
|
||||
): Promise<string> {
|
||||
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (workspaceMetadataPromises.get(key)?.promise === promise) {
|
||||
workspaceMetadataPromises.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshWorkspaceMetadata(cwd: string): void {
|
||||
workspaceMetadataPromises.delete(resolve(cwd));
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -179,58 +244,53 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
const updates: SessionConnectionUpdate = {};
|
||||
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
|
||||
// into typed fields; the thinking/reasoning transition rules live in the
|
||||
// shared @cline/core builder.
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
if (providerId) {
|
||||
updates.providerId = providerId;
|
||||
}
|
||||
const modelId = String(config.model ?? config.modelId ?? "").trim();
|
||||
if (modelId) {
|
||||
updates.modelId = modelId;
|
||||
}
|
||||
const apiKey =
|
||||
const rawApiKey =
|
||||
typeof config.apiKey === "string"
|
||||
? config.apiKey.trim()
|
||||
: typeof config.api_key === "string"
|
||||
? config.api_key.trim()
|
||||
: undefined;
|
||||
if (apiKey) {
|
||||
updates.apiKey = apiKey;
|
||||
}
|
||||
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
|
||||
updates.baseUrl = config.baseUrl.trim();
|
||||
}
|
||||
if (config.headers && typeof config.headers === "object") {
|
||||
updates.headers = config.headers as Record<string, string>;
|
||||
}
|
||||
if (config.providerConfig && typeof config.providerConfig === "object") {
|
||||
updates.providerConfig =
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"];
|
||||
}
|
||||
if (thinking === false) {
|
||||
updates.thinking = false;
|
||||
updates.reasoningEffort = null;
|
||||
updates.thinkingBudgetTokens = null;
|
||||
return updates;
|
||||
}
|
||||
if (thinking === true) {
|
||||
updates.thinking = true;
|
||||
}
|
||||
if (reasoningEffort) {
|
||||
updates.thinking = true;
|
||||
updates.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
if (thinkingBudgetTokens !== undefined) {
|
||||
updates.thinking = true;
|
||||
updates.thinkingBudgetTokens = thinkingBudgetTokens;
|
||||
}
|
||||
return updates;
|
||||
const baseUrl =
|
||||
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
|
||||
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
|
||||
const thinkingBudgetTokens = readPositiveInteger(
|
||||
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
|
||||
);
|
||||
return buildConnectionUpdate({
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(rawApiKey ? { apiKey: rawApiKey } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(config.headers && typeof config.headers === "object"
|
||||
? { headers: config.headers as Record<string, string> }
|
||||
: {}),
|
||||
...(config.providerConfig && typeof config.providerConfig === "object"
|
||||
? {
|
||||
providerConfig:
|
||||
config.providerConfig as SessionConnectionUpdate["providerConfig"],
|
||||
}
|
||||
: {}),
|
||||
...(typeof config.thinking === "boolean"
|
||||
? { thinking: config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUpdateSessionConnection(
|
||||
currentConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): boolean {
|
||||
return !isDeepStrictEqual(
|
||||
buildSessionConnectionUpdate(currentConfig),
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
@@ -246,7 +306,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
: config.mode === "plan"
|
||||
? "plan"
|
||||
: "act";
|
||||
const metadata = await buildWorkspaceMetadata(cwd);
|
||||
const metadata = await consumeWorkspaceMetadata(cwd);
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
? config.rules
|
||||
@@ -461,8 +521,14 @@ async function handleSend(
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, request.config)
|
||||
) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
}
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -1163,10 +1169,13 @@ export async function handleCommand(
|
||||
|
||||
// ── Git operations ─────────────────────────────────────────────────
|
||||
if (command === "get_git_branch") {
|
||||
const branches = listGitBranches(
|
||||
ctx,
|
||||
typeof args?.cwd === "string" ? args.cwd : undefined,
|
||||
);
|
||||
const cwd =
|
||||
typeof args?.cwd === "string" && args.cwd.trim()
|
||||
? args.cwd.trim()
|
||||
: ctx.workspaceRoot;
|
||||
const branches = listGitBranches(ctx, cwd);
|
||||
const { prewarmWorkspaceMetadata } = await import("./chat-session");
|
||||
prewarmWorkspaceMetadata(cwd);
|
||||
return { branch: branches.current };
|
||||
}
|
||||
if (command === "list_git_branches") {
|
||||
@@ -1179,11 +1188,14 @@ export async function handleCommand(
|
||||
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
|
||||
const branch = String(args?.branch ?? "").trim();
|
||||
if (!branch) throw new Error("branch is required");
|
||||
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
|
||||
execFileSync("git", ["checkout", branch], {
|
||||
cwd: cwd?.trim() || ctx.workspaceRoot,
|
||||
cwd: targetCwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const { refreshWorkspaceMetadata } = await import("./chat-session");
|
||||
refreshWorkspaceMetadata(targetCwd);
|
||||
return { branch };
|
||||
}
|
||||
|
||||
@@ -1252,6 +1264,15 @@ export async function handleCommand(
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
const workspacePath = String(args?.path ?? "").trim();
|
||||
if (!workspacePath) return { valid: false };
|
||||
try {
|
||||
return { valid: statSync(workspacePath).isDirectory() };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
if (command === "pick_workspace_directory") {
|
||||
return pickWorkspaceDirectory();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
} from "./context";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -34,6 +35,7 @@ async function main() {
|
||||
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
|
||||
const ctx = createSidecarContext(workspaceRoot);
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -59,8 +61,10 @@ async function main() {
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
SIDECAR_MODE,
|
||||
SIDECAR_PORT,
|
||||
type SidecarContext,
|
||||
@@ -15,12 +16,20 @@ type SidecarServer = {
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
|
||||
// the sidecar runs inside a container). Origin validation itself stays on.
|
||||
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const TRUSTED_BROWSER_ORIGINS = new Set([
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"http://localhost:3125",
|
||||
"http://127.0.0.1:3125",
|
||||
...EXTRA_TRUSTED_ORIGINS,
|
||||
]);
|
||||
|
||||
const JSON_HEADERS = {
|
||||
@@ -115,7 +124,7 @@ export function startServer(
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
server = BunRuntime.serve({
|
||||
hostname: "127.0.0.1",
|
||||
hostname: SIDECAR_HOST,
|
||||
port: candidate,
|
||||
fetch: createFetchHandler(ctx, onShutdown),
|
||||
websocket: createWebSocketHandler(ctx),
|
||||
|
||||
@@ -115,4 +115,8 @@ export type BunRuntimeApi = {
|
||||
export const BunRuntime = (globalThis as { Bun?: BunRuntimeApi }).Bun;
|
||||
|
||||
export const SIDECAR_PORT = Number(process.env.CLINE_SIDECAR_PORT) || 3126;
|
||||
// Loopback-only by default. Set CLINE_SIDECAR_HOST=0.0.0.0 to accept
|
||||
// connections from outside the local host (e.g. Docker port publishing).
|
||||
export const SIDECAR_HOST =
|
||||
process.env.CLINE_SIDECAR_HOST?.trim() || "127.0.0.1";
|
||||
export const SIDECAR_MODE = "sidecar";
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./webview", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
|
||||
@@ -1,123 +1,10 @@
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-geist-sans: "Geist Variable";
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), "Geist Fallback";
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
@@ -126,83 +13,149 @@
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
/* Aurora background (components/ui/aurora-bg.tsx) */
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: translate3d(-8%, 5%, 0) rotate(-5deg) scale(0.94);
|
||||
}
|
||||
body {
|
||||
@apply m-0 bg-background text-foreground;
|
||||
50% {
|
||||
opacity: 0.92;
|
||||
transform: translate3d(13%, -10%, 0) rotate(6deg) scale(1.11);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.62;
|
||||
transform: translate3d(-5%, -2%, 0) rotate(-3deg) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply leading-relaxed;
|
||||
@keyframes aurora-drift-reverse {
|
||||
0% {
|
||||
opacity: 0.62;
|
||||
transform: translate3d(10%, -6%, 0) rotate(5deg) scale(1.08);
|
||||
}
|
||||
.markdown * {
|
||||
@apply text-sm leading-relaxed;
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translate3d(-12%, -12%, 0) rotate(-6deg) scale(0.96);
|
||||
}
|
||||
.markdown + .markdown {
|
||||
@apply mt-2;
|
||||
}
|
||||
.markdown p {
|
||||
@apply my-2 first:mt-0 last:mb-0;
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
}
|
||||
.markdown blockquote {
|
||||
@apply border-l-2 border-border pl-3;
|
||||
}
|
||||
.markdown code {
|
||||
@apply space-y-1 rounded bg-muted px-1 py-0.5;
|
||||
}
|
||||
.markdown h1 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.markdown ul {
|
||||
@apply list-disc space-y-1;
|
||||
}
|
||||
.markdown ol {
|
||||
@apply list-decimal space-y-1;
|
||||
}
|
||||
.markdown li {
|
||||
@apply ml-5;
|
||||
}
|
||||
.markdown pre {
|
||||
@apply space-y-1 overflow-x-auto rounded bg-muted p-3;
|
||||
}
|
||||
.markdown div {
|
||||
@apply space-y-1;
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate3d(6%, 2%, 0) rotate(3deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@keyframes aurora-horizon-breathe {
|
||||
0% {
|
||||
opacity: 0.48;
|
||||
transform: translate3d(-3%, 9%, 0) scale(0.96, 0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.88;
|
||||
transform: translate3d(3%, -5%, 0) scale(1.08, 1.16);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.58;
|
||||
transform: translate3d(-1%, 2%, 0) scale(1.02, 0.96);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@keyframes aurora-current-sweep {
|
||||
0% {
|
||||
opacity: 0.28;
|
||||
transform: translate3d(-16%, 8%, 0) rotate(-8deg) scaleX(0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: translate3d(17%, -8%, 0) rotate(4deg) scaleX(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: translate3d(28%, 4%, 0) rotate(-2deg) scaleX(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.3 0.005 260);
|
||||
border-radius: 3px;
|
||||
@keyframes aurora-twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
transform: translate3d(0, 4px, 0) rotate(0deg) scale(0.78);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.78;
|
||||
transform: translate3d(var(--aurora-star-x, 5px), -8px, 0) rotate(35deg)
|
||||
scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.005 260);
|
||||
.aurora-horizon {
|
||||
animation: aurora-horizon-breathe 8s ease-in-out -3s infinite alternate;
|
||||
transform-origin: center bottom;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: oklch(0.75 0.12 165 / 0.25);
|
||||
.aurora-current {
|
||||
animation-name: aurora-current-sweep;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.aurora-current-reverse {
|
||||
animation-direction: alternate-reverse;
|
||||
}
|
||||
|
||||
.aurora-motion {
|
||||
animation-name: aurora-drift;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
.aurora-motion-reverse {
|
||||
animation-name: aurora-drift-reverse;
|
||||
}
|
||||
|
||||
.aurora-star {
|
||||
--aurora-star-x: 5px;
|
||||
animation-name: aurora-twinkle;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.aurora-star:nth-of-type(2n) {
|
||||
--aurora-star-x: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aurora-current,
|
||||
.aurora-horizon,
|
||||
.aurora-motion,
|
||||
.aurora-star {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation timing */
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.aurora-horizon {
|
||||
opacity: 0.68;
|
||||
transform: translate3d(0, -1%, 0) scale(1.05);
|
||||
}
|
||||
|
||||
.aurora-current,
|
||||
.aurora-motion {
|
||||
opacity: 0.58;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.aurora-current,
|
||||
.aurora-horizon,
|
||||
.aurora-motion {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agent Desktop",
|
||||
description: "AI coding agent interface",
|
||||
generator: "v0.app",
|
||||
title: "Cline",
|
||||
description: "Build software with Cline.",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
|
||||
@@ -18,12 +18,17 @@ import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import { SettingsView } from "@/components/views/settings/settings-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
@@ -37,6 +42,13 @@ import {
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
workspacePathsFromSessions,
|
||||
writeWorkspaceSelectionToWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
@@ -45,24 +57,9 @@ function makeThreadId(): string {
|
||||
type Thread = {
|
||||
id: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
hasStarted?: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceSessionItem = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
const preferredTitle = options.title?.trim();
|
||||
if (preferredTitle) {
|
||||
@@ -75,6 +72,8 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
|
||||
export default function Home() {
|
||||
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>("General");
|
||||
const [threads, setThreads] = useState<Thread[]>(() => [
|
||||
{ id: makeThreadId() },
|
||||
]);
|
||||
@@ -102,11 +101,15 @@ export default function Home() {
|
||||
const next = [...prev];
|
||||
next[existingIdx] = {
|
||||
...next[existingIdx],
|
||||
hasStarted: true,
|
||||
historySession: session,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return [...prev, { id: threadId, historySession: session }];
|
||||
return [
|
||||
...prev,
|
||||
{ id: threadId, hasStarted: true, historySession: session },
|
||||
];
|
||||
});
|
||||
setActiveThreadId(threadId);
|
||||
setView("chat");
|
||||
@@ -188,70 +191,107 @@ export default function Home() {
|
||||
?.sessionId ?? null;
|
||||
const activeThread =
|
||||
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
|
||||
const handleHome = useCallback(() => {
|
||||
if (activeThread?.historySession || activeThread?.hasStarted) {
|
||||
handleNewThread();
|
||||
return;
|
||||
}
|
||||
setView("chat");
|
||||
}, [activeThread, handleNewThread]);
|
||||
const handleThreadStarted = useCallback((threadId: string) => {
|
||||
setThreads((current) =>
|
||||
current.map((thread) =>
|
||||
thread.id === threadId && !thread.hasStarted
|
||||
? { ...thread, hasStarted: true }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const sessionHistory = useSessionHistory({
|
||||
activeSessionId: activeHistorySessionId,
|
||||
onDeleteSession: handleDeleteSession,
|
||||
onOpenSession: handleOpenSession,
|
||||
onUpdateSessionMetadata: handleUpdateSessionMetadata,
|
||||
});
|
||||
const historyWorkspacePaths = useMemo(
|
||||
() => workspacePathsFromSessions(sessionHistory.sessions),
|
||||
[sessionHistory.sessions],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
isHomeActive={
|
||||
view === "chat" &&
|
||||
!activeThread?.historySession &&
|
||||
!activeThread?.hasStarted
|
||||
}
|
||||
onHome={handleHome}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onNewThread={handleNewThread}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={setView}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
{view === "settings" ? (
|
||||
<div className="fixed inset-0 z-50 bg-background text-foreground">
|
||||
<SettingsView onClose={() => setView("chat")} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={setSettingsSection}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatThreadPane({
|
||||
threadId,
|
||||
historySession,
|
||||
knownWorkspacePaths,
|
||||
onUpdateSessionMetadata,
|
||||
onDeleteSession,
|
||||
onNewThread,
|
||||
onOpenSession,
|
||||
onThreadStarted,
|
||||
}: {
|
||||
threadId: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
knownWorkspacePaths: string[];
|
||||
onUpdateSessionMetadata?: (
|
||||
sessionId: string,
|
||||
metadata: SessionMetadata,
|
||||
@@ -259,6 +299,7 @@ function ChatThreadPane({
|
||||
onDeleteSession?: (sessionId: string, threadId?: string) => void;
|
||||
onNewThread?: () => void;
|
||||
onOpenSession?: (session: SessionHistoryItem) => void;
|
||||
onThreadStarted?: (threadId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -304,7 +345,12 @@ function ChatThreadPane({
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
const [workspaces, setWorkspaces] = useState<string[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<string[]>(() =>
|
||||
mergeWorkspacePaths(
|
||||
readWorkspaceSelectionFromWindow().workspaces,
|
||||
knownWorkspacePaths,
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
const hydratedSessionRef = useRef<string | null>(null);
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
@@ -318,6 +364,24 @@ function ChatThreadPane({
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
: merged;
|
||||
});
|
||||
}, [knownWorkspacePaths]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastWorkspace = (config.workspaceRoot || config.cwd || "").trim();
|
||||
writeWorkspaceSelectionToWindow({
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
});
|
||||
}, [config.cwd, config.workspaceRoot, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -438,52 +502,28 @@ function ChatThreadPane({
|
||||
|
||||
const listWorkspaces = useCallback(
|
||||
async (preferredWorkspace?: string): Promise<string[]> => {
|
||||
const roots = new Set<string>();
|
||||
const preferred = (preferredWorkspace || "").trim();
|
||||
if (preferred) {
|
||||
roots.add(preferred);
|
||||
}
|
||||
const current = (
|
||||
workspaceRef.current.workspaceRoot ||
|
||||
workspaceRef.current.cwd ||
|
||||
""
|
||||
).trim();
|
||||
if (current) {
|
||||
roots.add(current);
|
||||
}
|
||||
|
||||
try {
|
||||
const discovered = await desktopClient
|
||||
.invoke<WorkspaceSessionItem[]>("list_discovered_sessions", {
|
||||
limit: 20,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
for (const session of discovered) {
|
||||
const candidate = (session.workspaceRoot || session.cwd || "").trim();
|
||||
if (candidate) {
|
||||
roots.add(candidate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback to current workspace when history is unavailable.
|
||||
}
|
||||
|
||||
return [...roots].sort((a, b) => a.localeCompare(b));
|
||||
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
|
||||
},
|
||||
[],
|
||||
[knownWorkspacePaths],
|
||||
);
|
||||
|
||||
const refreshWorkspaces = useCallback(
|
||||
async (preferredWorkspace?: string) => {
|
||||
try {
|
||||
const results = await listWorkspaces(preferredWorkspace);
|
||||
setWorkspaces((current) =>
|
||||
current.length === results.length &&
|
||||
current.every((workspace, index) => workspace === results[index])
|
||||
setWorkspaces((current) => {
|
||||
const merged = mergeWorkspacePaths(current, results);
|
||||
return current.length === merged.length &&
|
||||
current.every((workspace, index) => workspace === merged[index])
|
||||
? current
|
||||
: results,
|
||||
);
|
||||
: merged;
|
||||
});
|
||||
} finally {
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
@@ -508,17 +548,21 @@ function ChatThreadPane({
|
||||
if (normalizedNext === normalizedCurrent) {
|
||||
return true;
|
||||
}
|
||||
const validation = await desktopClient
|
||||
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
|
||||
path: nextWorkspace,
|
||||
})
|
||||
.catch(() => ({ valid: false }));
|
||||
if (validation.valid !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
workspaceRoot: nextWorkspace,
|
||||
cwd: nextWorkspace,
|
||||
}));
|
||||
setWorkspaces((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nextWorkspace);
|
||||
return [...next].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
@@ -533,7 +577,7 @@ function ChatThreadPane({
|
||||
setGitBranch("no-git");
|
||||
});
|
||||
|
||||
// Re-fetch workspace list so the new root appears
|
||||
// Refresh the merged history, stored, and current workspace catalog.
|
||||
void refreshWorkspaces(nextWorkspace);
|
||||
|
||||
return true;
|
||||
@@ -618,11 +662,12 @@ function ChatThreadPane({
|
||||
if (!trimmed && pendingAttachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
onThreadStarted?.(threadId);
|
||||
setPromptInput("");
|
||||
const toSend = [...pendingAttachments];
|
||||
setPendingAttachments([]);
|
||||
await sendPrompt(trimmed, toSend);
|
||||
}, [pendingAttachments, promptInput, sendPrompt]);
|
||||
}, [onThreadStarted, pendingAttachments, promptInput, sendPrompt, threadId]);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
|
||||
@@ -826,6 +871,8 @@ function ChatThreadPane({
|
||||
const displayedIsSwitching = hideDeletedSessionUi
|
||||
? false
|
||||
: isHydratingSession;
|
||||
const isWelcomeState =
|
||||
displayedMessages.length === 0 && !displayedIsSwitching && !displayedError;
|
||||
|
||||
const handleRenameTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -913,152 +960,153 @@ function ChatThreadPane({
|
||||
);
|
||||
}
|
||||
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAttachFiles={(files) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`,
|
||||
),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
onEditPromptInQueue={(promptId, prompt) => {
|
||||
void updatePromptInQueue(promptId, prompt);
|
||||
}}
|
||||
onUndoPromptInQueue={(item) => {
|
||||
void handleUndoQueuedPrompt(item);
|
||||
}}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onSend={() => void handleSend()}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
mode={config.mode}
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
variant={isWelcomeState ? "welcome" : "conversation"}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={workspaceContextValue}>
|
||||
<div className="grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
<div className="z-20">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) {
|
||||
setShowDiffView(true);
|
||||
}
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full min-h-0 overflow-hidden">
|
||||
{showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
onStartChat={(prompt) => {
|
||||
setPromptInput(prompt);
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
>
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
model={config.model}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
provider={config.provider}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="z-20 shrink-0">
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAttachFiles={(files) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`,
|
||||
),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
body={
|
||||
showDiffView ? (
|
||||
<DiffView
|
||||
fileDiffs={fileDiffs}
|
||||
onClose={() => setShowDiffView(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
onAnswerAskQuestion={handleAnswerAskQuestion}
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
error={displayedError}
|
||||
messages={displayedMessages}
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onRefreshGitBranch={() => void refreshGitBranch()}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
onEditPromptInQueue={(promptId, prompt) => {
|
||||
void updatePromptInQueue(promptId, prompt);
|
||||
}}
|
||||
onUndoPromptInQueue={(item) => {
|
||||
void handleUndoQueuedPrompt(item);
|
||||
}}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (
|
||||
prev.provider === nextProvider &&
|
||||
prev.apiKey === nextApiKey
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onReset={() => {
|
||||
setPendingAttachments([]);
|
||||
void reset();
|
||||
}}
|
||||
onSend={() => void handleSend()}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
mode={config.mode}
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
reasoningEffort={config.reasoningEffort}
|
||||
status={status}
|
||||
summary={summary}
|
||||
thinking={config.thinking}
|
||||
/>
|
||||
</div>
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
pendingAskQuestions={pendingAskQuestions}
|
||||
sessionId={displayedSessionId}
|
||||
streamingMessageId={activeAssistantMessageId}
|
||||
isSessionSwitching={displayedIsSwitching}
|
||||
status={displayedStatus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
composer={composer}
|
||||
onStartChat={setPromptInput}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
|
||||
@@ -24,6 +24,7 @@ type AgentHeaderProps = {
|
||||
canDeleteSession?: boolean;
|
||||
deletingSession?: boolean;
|
||||
onOpenDiff?: () => void;
|
||||
showSessionActions?: boolean;
|
||||
status?: ChatSessionStatus;
|
||||
diff?: {
|
||||
additions: number;
|
||||
@@ -41,6 +42,7 @@ export function AgentHeader({
|
||||
canDeleteSession,
|
||||
deletingSession,
|
||||
onOpenDiff,
|
||||
showSessionActions = true,
|
||||
status,
|
||||
diff,
|
||||
}: AgentHeaderProps) {
|
||||
@@ -80,12 +82,13 @@ export function AgentHeader({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between px-4">
|
||||
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:pl-12">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<output
|
||||
aria-label={`Session status: ${status}`}
|
||||
className={cn(
|
||||
"rounded w-2 h-2 font-mono",
|
||||
"size-2 shrink-0 rounded font-mono",
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "failed"
|
||||
@@ -95,7 +98,7 @@ export function AgentHeader({
|
||||
/>
|
||||
{isEditingTitle ? (
|
||||
<form
|
||||
className="m-0"
|
||||
className="m-0 min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitTitle();
|
||||
@@ -103,7 +106,7 @@ export function AgentHeader({
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 w-64 text-sm"
|
||||
className="h-7 w-64 max-w-full text-sm"
|
||||
disabled={renamingTitle}
|
||||
onBlur={() => {
|
||||
void submitTitle();
|
||||
@@ -122,7 +125,7 @@ export function AgentHeader({
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground",
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
canEditTitle &&
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
|
||||
)}
|
||||
@@ -135,6 +138,7 @@ export function AgentHeader({
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
type="button"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</button>
|
||||
@@ -142,7 +146,8 @@ export function AgentHeader({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Session actions"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
id="show-more-btn"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -164,34 +169,37 @@ export function AgentHeader({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* DIFF */}
|
||||
<Button
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges ? "hover:bg-secondary/80" : "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
{showSessionActions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
aria-label={`Open diff: ${additions} additions, ${deletions} deletions`}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
? "hover:bg-secondary/80"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function makeThread(project: string, index: number): SessionThread {
|
||||
return {
|
||||
id: `${project}-${index}`,
|
||||
title: `${project} session ${index}`,
|
||||
codebase: project,
|
||||
workspacePath: `/projects/${project}`,
|
||||
time: `${index}m`,
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionHistory(
|
||||
threads: SessionThread[],
|
||||
loadMoreSessions: ReturnType<typeof vi.fn>,
|
||||
options: {
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
} = {},
|
||||
): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
isLoadingMore: false,
|
||||
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions: options.mayHaveMoreSessions ?? false,
|
||||
openThread: vi.fn(),
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
threads,
|
||||
unreadSessionIds: new Set<string>(),
|
||||
} as unknown as UseSessionHistoryResult;
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function sessionIsVisible(title: string): boolean {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].some(
|
||||
(button) => button.querySelector("span")?.textContent === title,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AgentSidebar session organization", () => {
|
||||
it("defaults to time and keeps project expansion scoped to one project", async () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
makeThread("alpha", index + 1),
|
||||
),
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
makeThread("beta", index + 1),
|
||||
),
|
||||
];
|
||||
const loadMoreSessions = vi.fn(async () => undefined);
|
||||
const loadOlderSessions = vi.fn(async () => undefined);
|
||||
const sessionHistory = makeSessionHistory(threads, loadMoreSessions, {
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
isHomeActive
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={vi.fn()}
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]'),
|
||||
).not.toBeNull();
|
||||
expect(sessionIsVisible("alpha session 10")).toBe(true);
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 1")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Show more"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(loadMoreSessions).toHaveBeenCalledWith(20);
|
||||
|
||||
await click(
|
||||
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
|
||||
);
|
||||
const projectOption = await vi.waitFor(() => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
||||
].find((candidate) => candidate.textContent?.includes("Sort by project"));
|
||||
expect(option).toBeDefined();
|
||||
return option as HTMLElement;
|
||||
});
|
||||
await click(projectOption);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[aria-label="Sort sessions: Project"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(container.textContent).toContain("alpha");
|
||||
expect(container.textContent).toContain("beta");
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(false);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Show more in alpha"));
|
||||
expect(sessionIsVisible("alpha session 11")).toBe(true);
|
||||
expect(sessionIsVisible("beta session 11")).toBe(false);
|
||||
|
||||
await click(buttonWithText("Load older projects"));
|
||||
expect(loadOlderSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Blocks,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Clock3,
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Home,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Pin,
|
||||
Plus,
|
||||
Radio,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Store,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -22,6 +33,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ClineLogo } from "@/components/cline-logo";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -55,37 +67,117 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import type {
|
||||
SessionThread,
|
||||
UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
workspaceDisplayName,
|
||||
} from "@/lib/sidebar-session-organization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Thread = SessionThread;
|
||||
type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
const INITIAL_VISIBLE_THREAD_COUNT = 10;
|
||||
type SidebarSortMode = "time" | "project";
|
||||
const SETTINGS_SECTION_ICONS = {
|
||||
General: SlidersHorizontal,
|
||||
Models: Bot,
|
||||
"MCP Servers": Server,
|
||||
"MCP Marketplace": Store,
|
||||
Customizations: Blocks,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Account: CircleUserRound,
|
||||
} satisfies Record<SettingsSection, typeof Settings>;
|
||||
|
||||
function SettingsSectionNavigation({
|
||||
activeSection,
|
||||
collapsed,
|
||||
onSelect,
|
||||
}: {
|
||||
activeSection: SettingsSection;
|
||||
collapsed: boolean;
|
||||
onSelect: (section: SettingsSection) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
|
||||
collapsed ? "w-full items-center" : "w-full",
|
||||
)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<p className="px-2 pb-2 text-sm font-medium text-muted-foreground">
|
||||
Settings
|
||||
</p>
|
||||
) : null}
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<Button
|
||||
aria-current={activeSection === section ? "page" : undefined}
|
||||
aria-label={section}
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
collapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => onSelect(section)}
|
||||
title={section}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{section}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSidebar({
|
||||
isHomeActive,
|
||||
onHome,
|
||||
onNewThread,
|
||||
onSettingsSectionChange,
|
||||
setView,
|
||||
settingsSection,
|
||||
view,
|
||||
activeSessionId,
|
||||
sessionHistory,
|
||||
}: {
|
||||
isHomeActive: boolean;
|
||||
onHome: () => void;
|
||||
onNewThread?: () => void;
|
||||
setView: (view: "chat" | "sessions" | "settings") => void;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
setView: (view: AppView) => void;
|
||||
settingsSection: SettingsSection;
|
||||
view: AppView;
|
||||
activeSessionId?: string | null;
|
||||
sessionHistory: UseSessionHistoryResult;
|
||||
}) {
|
||||
const { isMobile, setOpen, state } = useSidebar();
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
const {
|
||||
deleteThread: deleteHistoryThread,
|
||||
forkThread: forkHistoryThread,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread: openHistoryThread,
|
||||
@@ -96,6 +188,7 @@ export function AgentSidebar({
|
||||
} = sessionHistory;
|
||||
const activeThread = activeSessionId ?? "";
|
||||
const [filter, setFilter] = useState<FilterOption>("All");
|
||||
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showMoreCount, setShowMoreCount] = useState(
|
||||
@@ -106,6 +199,12 @@ export function AgentSidebar({
|
||||
const [deleteConfirmThread, setDeleteConfirmThread] = useState<Thread | null>(
|
||||
null,
|
||||
);
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -120,7 +219,8 @@ export function AgentSidebar({
|
||||
filtered = filtered.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.codebase.toLowerCase().includes(q),
|
||||
t.codebase.toLowerCase().includes(q) ||
|
||||
t.workspacePath.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
switch (filter) {
|
||||
@@ -134,19 +234,44 @@ export function AgentSidebar({
|
||||
return filtered;
|
||||
}
|
||||
}, [filter, searchQuery, threads]);
|
||||
const closeMobileSidebar = useCallback(() => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}, [isMobile, setOpenMobile]);
|
||||
|
||||
const openThread = useCallback(
|
||||
(threadId: string) => {
|
||||
setView("chat");
|
||||
openHistoryThread(threadId);
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[openHistoryThread, setView],
|
||||
[closeMobileSidebar, openHistoryThread, setView],
|
||||
);
|
||||
|
||||
const openNewThread = useCallback(() => {
|
||||
setView("chat");
|
||||
onNewThread?.();
|
||||
}, [onNewThread, setView]);
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, onNewThread, setView]);
|
||||
const openHome = useCallback(() => {
|
||||
onHome();
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, onHome]);
|
||||
const openSessions = useCallback(() => {
|
||||
setView("sessions");
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, setView]);
|
||||
const openSettings = useCallback(() => {
|
||||
setView("settings");
|
||||
closeMobileSidebar();
|
||||
}, [closeMobileSidebar, setView]);
|
||||
const openSettingsSection = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
setView("settings");
|
||||
closeMobileSidebar();
|
||||
},
|
||||
[closeMobileSidebar, onSettingsSectionChange, setView],
|
||||
);
|
||||
|
||||
const startRenameThread = useCallback((thread: Thread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
@@ -202,8 +327,30 @@ export function AgentSidebar({
|
||||
: [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount),
|
||||
[filter, pinnedThreads, sessionThreads, showMoreCount],
|
||||
);
|
||||
const showShowMore =
|
||||
sessionThreads.length > showMoreCount || mayHaveMoreSessions;
|
||||
const showTimeShowMore =
|
||||
sessionThreads.length > showMoreCount ||
|
||||
(filter === "All" && !searchQuery && mayHaveMoreSessions);
|
||||
const projectGroups = useMemo(
|
||||
() => groupThreadsByProject([...pinnedThreads, ...sessionThreads]),
|
||||
[pinnedThreads, sessionThreads],
|
||||
);
|
||||
|
||||
const toggleProject = useCallback((project: string) => {
|
||||
setCollapsedProjects((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(project)) next.delete(project);
|
||||
else next.add(project);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const showMoreForProject = useCallback((project: string) => {
|
||||
setProjectVisibleCounts((current) => ({
|
||||
...current,
|
||||
[project]:
|
||||
(current[project] ?? INITIAL_VISIBLE_THREAD_COUNT) +
|
||||
INITIAL_VISIBLE_THREAD_COUNT,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const filterMenu = (
|
||||
<DropdownMenu>
|
||||
@@ -222,6 +369,7 @@ export function AgentSidebar({
|
||||
onValueChange={(value) => {
|
||||
setFilter(value as FilterOption);
|
||||
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
|
||||
setProjectVisibleCounts({});
|
||||
}}
|
||||
value={filter}
|
||||
>
|
||||
@@ -234,29 +382,119 @@ export function AgentSidebar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const sortMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Sort sessions: ${sortMode === "time" ? "Time" : "Project"}`}
|
||||
className="m-0! inline-flex size-8 items-center justify-center rounded-md p-0! text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
size="icon"
|
||||
title={sortMode === "time" ? "Sort by time" : "Sort by project"}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownUp className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
if (value === "time" || value === "project") {
|
||||
setSortMode(value);
|
||||
}
|
||||
}}
|
||||
value={sortMode}
|
||||
>
|
||||
<DropdownMenuRadioItem value="time">
|
||||
<Clock3 className="size-4" />
|
||||
Sort by time
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="project">
|
||||
<FolderTree className="size-4" />
|
||||
Sort by project
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const threadItem = (thread: Thread) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
editing={editingSessionId === thread.id}
|
||||
isActive={activeThread === thread.id}
|
||||
key={thread.id}
|
||||
onCancelRename={cancelRenameThread}
|
||||
onClick={() => openThread(thread.id)}
|
||||
onCommitRename={() => void commitRenameThread(thread)}
|
||||
onDelete={() => requestDeleteThread(thread)}
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id ? pendingAction.action : null
|
||||
}
|
||||
thread={thread}
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
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">
|
||||
<div className="mt-2 flex w-full min-w-0 flex-col gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-16 shrink-0 items-center px-4",
|
||||
isCollapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
onClick={openHome}
|
||||
type="button"
|
||||
>
|
||||
<ClineLogo className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start min-w-0",
|
||||
"min-w-0 justify-start",
|
||||
view === "chat" &&
|
||||
isHomeActive &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
aria-label="New Session"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
variant="sidebar"
|
||||
aria-label="Home"
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<MessageSquare className="size-4" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
{!isCollapsed ? "New Session" : null}
|
||||
<Home className="size-4" />
|
||||
{!isCollapsed ? "Home" : null}
|
||||
</Button>
|
||||
{isCollapsed ? (
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 px-1.5">
|
||||
{view === "settings" ? (
|
||||
<SettingsSectionNavigation
|
||||
activeSection={settingsSection}
|
||||
collapsed
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Expand sidebar"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
@@ -267,139 +505,211 @@ export function AgentSidebar({
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isCollapsed ? (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||
{searchOpen ? (
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md bg-sidebar-accent px-2 py-1.5">
|
||||
<Search className="size-4 shrink-0" />
|
||||
<Input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-sidebar-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => {
|
||||
if (!searchQuery) setSearchOpen(false);
|
||||
}}
|
||||
autoFocus={true}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="py-1.5 min-w-0"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
title="Search sessions"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Search className="size-4 shrink-0" />
|
||||
<span>Search</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 min-h-0 w-full flex-1">
|
||||
<ScrollArea className="h-full min-h-0 w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{displayedThreads.length > 0 && (
|
||||
<ThreadSection
|
||||
action={filterMenu}
|
||||
label={filter === "All" ? "Sessions" : filter}
|
||||
onClick={() => setView("sessions")}
|
||||
>
|
||||
{displayedThreads.map((thread) => (
|
||||
<ThreadItem
|
||||
editTitle={editingTitle}
|
||||
editing={editingSessionId === thread.id}
|
||||
isActive={activeThread === thread.id}
|
||||
key={thread.id}
|
||||
onCancelRename={cancelRenameThread}
|
||||
onClick={() => openThread(thread.id)}
|
||||
onCommitRename={() =>
|
||||
void commitRenameThread(thread)
|
||||
}
|
||||
onDelete={() => requestDeleteThread(thread)}
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id
|
||||
? pendingAction.action
|
||||
: null
|
||||
}
|
||||
thread={thread}
|
||||
unread={unreadSessionIds.has(thread.id)}
|
||||
/>
|
||||
))}
|
||||
</ThreadSection>
|
||||
)}
|
||||
|
||||
{displayedThreads.length === 0 && (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No sessions match your search."
|
||||
: "No sessions found in history."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
|
||||
setShowMoreCount(nextCount);
|
||||
void loadMoreSessions(nextCount);
|
||||
}}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : view === "settings" ? (
|
||||
<div className="mt-5 min-h-0 flex-1 px-3">
|
||||
<SettingsSectionNavigation
|
||||
activeSection={settingsSection}
|
||||
collapsed={false}
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 w-full flex-1" />
|
||||
<>
|
||||
<div className="mt-5 shrink-0 px-3">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-muted-foreground transition-colors hover:text-sidebar-foreground",
|
||||
view === "sessions" && "text-sidebar-foreground",
|
||||
)}
|
||||
onClick={openSessions}
|
||||
type="button"
|
||||
>
|
||||
{sortMode === "time" ? "Sessions" : "Projects"}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
aria-label="Search sessions"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={() => setSearchOpen((current) => !current)}
|
||||
size="icon"
|
||||
title="Search sessions"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
{sortMenu}
|
||||
{filterMenu}
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
|
||||
onClick={openNewThread}
|
||||
size="icon"
|
||||
title="New session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-sidebar-border bg-background/70 px-2 py-1">
|
||||
<Search className="size-4 shrink-0" />
|
||||
<Input
|
||||
className="h-7 min-w-0 flex-1 border-0 bg-transparent px-0 text-sm text-sidebar-foreground shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
autoFocus={true}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 min-h-0 w-full flex-1">
|
||||
<ScrollArea className="h-full min-h-0 w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pb-3 px-3">
|
||||
{isLoadingHistory && threads.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
Loading session history...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sortMode === "time"
|
||||
? displayedThreads.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="pl-2"
|
||||
onClick={() =>
|
||||
showMoreForProject(project.id)
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
Show more in {project.label}
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ProjectSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{(sortMode === "time"
|
||||
? displayedThreads.length === 0
|
||||
: projectGroups.length === 0) && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No sessions match your search."
|
||||
: "No sessions found in history."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sortMode === "time" && showTimeShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
|
||||
setShowMoreCount(nextCount);
|
||||
void loadMoreSessions(nextCount);
|
||||
}}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show more
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{sortMode === "project" &&
|
||||
filter === "All" &&
|
||||
!searchQuery &&
|
||||
mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
variant="sidebarText"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading older projects...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Load older projects
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 px-2 py-3">
|
||||
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"justify-start min-w-0",
|
||||
"min-w-0 justify-start",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
)}
|
||||
onClick={() => setView("settings")}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
{!isCollapsed ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
C
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
Cline Desktop
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
@@ -451,33 +761,35 @@ export function AgentSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadSection({
|
||||
function ProjectSection({
|
||||
label,
|
||||
action,
|
||||
onClick,
|
||||
collapsed,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
action?: ReactNode;
|
||||
onClick?: () => void;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-1 min-w-0")}>
|
||||
<div className="flex h-9 w-full min-w-0 flex-nowrap items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||
<button
|
||||
aria-label={`Open ${label} sessions view`}
|
||||
className="flex min-w-0 flex-1 items-center self-stretch rounded-md pl-0 pr-2 text-left transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 shrink truncate">{label}</span>
|
||||
</button>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
<div className="mb-1 min-w-0">
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-md px-1 text-left text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
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>
|
||||
</button>
|
||||
{!collapsed ? <div className="pl-3">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -515,6 +827,7 @@ function ThreadItem({
|
||||
const costLabel = formatCostUsd(thread.totalCostUsd);
|
||||
const title = normalizeTitle(thread.title);
|
||||
const pending = pendingAction !== null;
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const statusDotClass = pending
|
||||
? "bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
@@ -522,16 +835,20 @@ function ThreadItem({
|
||||
: unread
|
||||
? "bg-blue-500"
|
||||
: "";
|
||||
const infoItems: Array<[string, string | null | undefined]> = [
|
||||
const infoItems: Array<[string, string | null | undefined, string?]> = [
|
||||
["ID", thread.id],
|
||||
["Workspace", thread.codebase],
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
workspacePath || undefined,
|
||||
],
|
||||
["Status", thread.status],
|
||||
["Updated", thread.time],
|
||||
["Provider", thread.provider],
|
||||
["Model", thread.model],
|
||||
["Tokens", tokenLabel],
|
||||
["Cost", costLabel],
|
||||
].filter((item): item is [string, string] => Boolean(item[1]));
|
||||
].filter((item): item is [string, string, string?] => Boolean(item[1]));
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -573,20 +890,20 @@ function ThreadItem({
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold leading-tight">
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
</span>
|
||||
{thread.pinned ? (
|
||||
<Pin
|
||||
aria-label="Pinned"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-2 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3" />
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", statusDotClass)}
|
||||
/>
|
||||
) : null}
|
||||
<span>{thread.time}</span>
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
</ContextMenuTrigger>
|
||||
@@ -600,10 +917,15 @@ function ThreadItem({
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs">
|
||||
{infoItems.map(([label, value]) => (
|
||||
{infoItems.map(([label, value, fullValue]) => (
|
||||
<div className="contents" key={label}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate font-mono">{value}</span>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono"
|
||||
title={fullValue}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ClineLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("inline-block shrink-0 bg-current", className)}
|
||||
style={{
|
||||
maskImage: "url('/cline-logo-filled.svg')",
|
||||
maskPosition: "center",
|
||||
maskRepeat: "no-repeat",
|
||||
maskSize: "contain",
|
||||
WebkitMaskImage: "url('/cline-logo-filled.svg')",
|
||||
WebkitMaskPosition: "center",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskSize: "contain",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Star {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
|
||||
const BLOBS = [
|
||||
{
|
||||
id: "periwinkle-left",
|
||||
position: "left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
|
||||
gradient:
|
||||
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), transparent 70%)",
|
||||
duration: "11s",
|
||||
delay: "0s",
|
||||
reverse: false,
|
||||
},
|
||||
{
|
||||
id: "violet-right",
|
||||
position: "right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
|
||||
gradient:
|
||||
"radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), transparent 70%)",
|
||||
duration: "12.5s",
|
||||
delay: "-12s",
|
||||
reverse: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function seededUnit(index: number, salt: number): number {
|
||||
let value =
|
||||
Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
|
||||
value ^= value >>> 16;
|
||||
value = Math.imul(value, 0x7feb352d);
|
||||
value ^= value >>> 15;
|
||||
value = Math.imul(value, 0x846ca68b);
|
||||
value ^= value >>> 16;
|
||||
return (value >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorative aurora background built entirely from CSS: blurred gradient
|
||||
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
|
||||
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
|
||||
* positioned parent; pointer events pass through.
|
||||
*
|
||||
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
|
||||
*/
|
||||
export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
|
||||
// The field is deterministic so server and browser markup always agree.
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: starCount }, (_, index) => {
|
||||
// Squared skew biases stars toward the bottom, where the glow lives.
|
||||
const r = seededUnit(index, 1);
|
||||
const sizeRoll = seededUnit(index, 3);
|
||||
return {
|
||||
left: `${seededUnit(index, 2) * 100}%`,
|
||||
top: `${100 - (1 - r * r) * 45}%`,
|
||||
size: sizeRoll < 0.14 ? 4 : sizeRoll < 0.52 ? 3 : 2,
|
||||
delay: `${seededUnit(index, 4) * -5}s`,
|
||||
duration: `${3.5 + seededUnit(index, 5) * 3.5}s`,
|
||||
opacity: 0.35 + seededUnit(index, 6) * 0.6,
|
||||
color:
|
||||
seededUnit(index, 7) > 0.78
|
||||
? "var(--brand-cyan)"
|
||||
: "color-mix(in oklab, white 92%, var(--brand-lilac))",
|
||||
};
|
||||
}),
|
||||
[starCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-horizon absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60 blur-[64px]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in oklab, var(--brand-lilac) 58%, transparent), color-mix(in oklab, var(--brand-magenta) 62%, transparent) 42%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 78%, color-mix(in oklab, var(--brand-cyan) 58%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-current absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50 blur-[46px]"
|
||||
style={{
|
||||
animationDelay: "-2s",
|
||||
animationDuration: "9s",
|
||||
background:
|
||||
"linear-gradient(105deg, transparent 12%, color-mix(in oklab, var(--brand-magenta) 66%, transparent) 38%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 58%, transparent 82%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-current aurora-current-reverse absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45 blur-[52px]"
|
||||
style={{
|
||||
animationDelay: "-6s",
|
||||
animationDuration: "12s",
|
||||
background:
|
||||
"linear-gradient(75deg, transparent 10%, color-mix(in oklab, var(--brand-cyan) 62%, transparent) 42%, color-mix(in oklab, var(--brand-violet) 70%, transparent) 64%, transparent 88%)",
|
||||
}}
|
||||
/>
|
||||
{BLOBS.map((blob) => (
|
||||
<div
|
||||
key={blob.id}
|
||||
className={`aurora-motion absolute blur-[64px] ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
|
||||
style={{
|
||||
background: blob.gradient,
|
||||
animationDuration: blob.duration,
|
||||
animationDelay: blob.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{stars.map((s) => (
|
||||
<span
|
||||
key={`${s.left}-${s.top}`}
|
||||
className="aurora-star absolute rounded-[1px]"
|
||||
style={{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
background: s.color,
|
||||
opacity: s.opacity,
|
||||
animationDelay: s.delay,
|
||||
animationDuration: s.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
markdownCodeHighlighter,
|
||||
SUPPORTED_MARKDOWN_LANGUAGES,
|
||||
} from "./markdown-highlighter";
|
||||
|
||||
function highlight(code: string, language: "typescript") {
|
||||
return new Promise<
|
||||
NonNullable<ReturnType<typeof markdownCodeHighlighter.highlight>>
|
||||
>((resolve) => {
|
||||
const immediate = markdownCodeHighlighter.highlight(
|
||||
{ code, language, themes: ["github-light", "github-dark"] },
|
||||
resolve,
|
||||
);
|
||||
if (immediate) resolve(immediate);
|
||||
});
|
||||
}
|
||||
|
||||
describe("markdownCodeHighlighter", () => {
|
||||
test("keeps the syntax bundle to the shared supported language set", () => {
|
||||
expect(SUPPORTED_MARKDOWN_LANGUAGES).toEqual([
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
]);
|
||||
expect(markdownCodeHighlighter.supportsLanguage("ts")).toBe(true);
|
||||
expect(markdownCodeHighlighter.supportsLanguage("rust")).toBe(false);
|
||||
});
|
||||
|
||||
test("loads a supported grammar and returns themed tokens", async () => {
|
||||
const result = await highlight("const answer: number = 42;", "typescript");
|
||||
|
||||
expect(
|
||||
result.tokens
|
||||
.flat()
|
||||
.map((token) => token.content)
|
||||
.join(""),
|
||||
).toBe("const answer: number = 42;");
|
||||
expect(result.tokens.flat().some((token) => token.htmlStyle?.color)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type {
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import type { CodeHighlighterPlugin } from "streamdown";
|
||||
|
||||
type HighlightResult = NonNullable<
|
||||
ReturnType<CodeHighlighterPlugin["highlight"]>
|
||||
>;
|
||||
|
||||
export const SUPPORTED_MARKDOWN_LANGUAGES = [
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
] as const;
|
||||
|
||||
type SupportedMarkdownLanguage = (typeof SUPPORTED_MARKDOWN_LANGUAGES)[number];
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_MARKDOWN_LANGUAGES);
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedMarkdownLanguage> = {
|
||||
cjs: "javascript",
|
||||
console: "shellscript",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
json5: "jsonc",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
ts: "typescript",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const LANGUAGE_LOADERS: Record<
|
||||
SupportedMarkdownLanguage,
|
||||
() => Promise<LanguageRegistration[]>
|
||||
> = {
|
||||
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
|
||||
css: () => import("@shikijs/langs/css").then((module) => module.default),
|
||||
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
|
||||
html: () => import("@shikijs/langs/html").then((module) => module.default),
|
||||
javascript: () =>
|
||||
import("@shikijs/langs/javascript").then((module) => module.default),
|
||||
json: () => import("@shikijs/langs/json").then((module) => module.default),
|
||||
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
|
||||
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
|
||||
markdown: () =>
|
||||
import("@shikijs/langs/markdown").then((module) => module.default),
|
||||
python: () =>
|
||||
import("@shikijs/langs/python").then((module) => module.default),
|
||||
shellscript: () =>
|
||||
import("@shikijs/langs/shellscript").then((module) => module.default),
|
||||
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
|
||||
typescript: () =>
|
||||
import("@shikijs/langs/typescript").then((module) => module.default),
|
||||
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
|
||||
};
|
||||
|
||||
const LIGHT_THEME = "github-light";
|
||||
const DARK_THEME = "github-dark";
|
||||
const MAX_CACHED_RESULTS = 256;
|
||||
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedMarkdownLanguage, Promise<void>>();
|
||||
const resultCache = new Map<string, HighlightResult>();
|
||||
const pendingHighlights = new Map<string, Promise<HighlightResult>>();
|
||||
const loggedFailures = new Set<string>();
|
||||
|
||||
function normalizeLanguage(language: string): SupportedMarkdownLanguage | null {
|
||||
const normalized = language.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
return SUPPORTED_LANGUAGE_SET.has(aliased)
|
||||
? (aliased as SupportedMarkdownLanguage)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = Promise.all([
|
||||
import("shiki/core"),
|
||||
import("shiki/engine/javascript"),
|
||||
]).then(([core, engine]) =>
|
||||
core.createHighlighterCore({
|
||||
engine: engine.createJavaScriptRegexEngine({ forgiving: true }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
function ensureThemes(highlighter: HighlighterCore): Promise<void> {
|
||||
if (!themesPromise) {
|
||||
themesPromise = Promise.all([
|
||||
import("@shikijs/themes/github-light").then((module) => module.default),
|
||||
import("@shikijs/themes/github-dark").then((module) => module.default),
|
||||
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
|
||||
}
|
||||
return themesPromise;
|
||||
}
|
||||
|
||||
function ensureLanguage(
|
||||
highlighter: HighlighterCore,
|
||||
language: SupportedMarkdownLanguage,
|
||||
): Promise<void> {
|
||||
const existing = languagePromises.get(language);
|
||||
if (existing) return existing;
|
||||
|
||||
const loading = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
function rawHighlight(code: string): HighlightResult {
|
||||
return {
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line
|
||||
? [
|
||||
{
|
||||
bgColor: "transparent",
|
||||
color: "inherit",
|
||||
content: line,
|
||||
htmlStyle: {},
|
||||
offset: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheResult(key: string, result: HighlightResult): void {
|
||||
resultCache.delete(key);
|
||||
resultCache.set(key, result);
|
||||
if (resultCache.size <= MAX_CACHED_RESULTS) return;
|
||||
|
||||
const oldestKey = resultCache.keys().next().value;
|
||||
if (oldestKey !== undefined) resultCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
function reportHighlightFailure(
|
||||
language: SupportedMarkdownLanguage,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (loggedFailures.has(language)) return;
|
||||
loggedFailures.add(language);
|
||||
console.warn(
|
||||
`Syntax highlighting unavailable for ${language}; rendering plain code.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
function loadHighlight(
|
||||
code: string,
|
||||
language: SupportedMarkdownLanguage,
|
||||
): Promise<HighlightResult> {
|
||||
const cacheKey = `${language}\0${code}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = pendingHighlights.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const loading = getHighlighter()
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, language);
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: DARK_THEME,
|
||||
light: LIGHT_THEME,
|
||||
},
|
||||
});
|
||||
return {
|
||||
bg: result.bg,
|
||||
fg: result.fg,
|
||||
rootStyle: result.rootStyle,
|
||||
tokens: result.tokens,
|
||||
} satisfies HighlightResult;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
reportHighlightFailure(language, error);
|
||||
return rawHighlight(code);
|
||||
})
|
||||
.then((result) => {
|
||||
cacheResult(cacheKey, result);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingHighlights.delete(cacheKey);
|
||||
});
|
||||
|
||||
pendingHighlights.set(cacheKey, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
export const markdownCodeHighlighter = {
|
||||
getSupportedLanguages: () => [...SUPPORTED_MARKDOWN_LANGUAGES],
|
||||
getThemes: () => [LIGHT_THEME, DARK_THEME],
|
||||
highlight: ({ code, language }, callback) => {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
if (!normalizedLanguage) return rawHighlight(code);
|
||||
|
||||
const cacheKey = `${normalizedLanguage}\0${code}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const loading = loadHighlight(code, normalizedLanguage);
|
||||
if (callback) {
|
||||
void loading.then((result) => callback(result));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
name: "shiki",
|
||||
supportsLanguage: (language) => normalizeLanguage(language) !== null,
|
||||
type: "code-highlighter",
|
||||
} satisfies CodeHighlighterPlugin;
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { MarkdownLinkSafetyModal, MemoizedMarkdown } from "./markdown";
|
||||
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"clipboard",
|
||||
);
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
let openWindow: ReturnType<typeof vi.fn<typeof window.open>>;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
openWindow = vi.fn<typeof window.open>(() => null);
|
||||
vi.spyOn(window, "open").mockImplementation(openWindow);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, "clipboard", originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "clipboard");
|
||||
}
|
||||
});
|
||||
|
||||
async function renderMarkdown(
|
||||
props: Parameters<typeof MemoizedMarkdown>[0],
|
||||
): Promise<void> {
|
||||
await act(async () => root.render(<MemoizedMarkdown {...props} />));
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchMouseEvent(
|
||||
element: Element,
|
||||
type: "auxclick" | "contextmenu",
|
||||
button: number,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent(type, { bubbles: true, button, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLButtonElement {
|
||||
const button = [
|
||||
...document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("MemoizedMarkdown interactions", () => {
|
||||
test("confirms and closes an external link dialog exactly once", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownLinkSafetyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
url="https://example.com/review"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(getButton("Open link"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
test("requires confirmation before opening an external link", async () => {
|
||||
const url = "https://example.com/review?source=cline";
|
||||
await renderMarkdown({ content: `[Review docs](${url})` });
|
||||
const link = await vi.waitFor(() => {
|
||||
const renderedLink = container.querySelector<HTMLElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
expect(renderedLink).not.toBeNull();
|
||||
return renderedLink as HTMLElement;
|
||||
});
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("#confirm-external-link");
|
||||
|
||||
await dispatchMouseEvent(link, "contextmenu", 2);
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
|
||||
await dispatchMouseEvent(link, "auxclick", 1);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
});
|
||||
await click(getButton("Cancel"));
|
||||
|
||||
await click(link);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain(url);
|
||||
});
|
||||
|
||||
await click(getButton("Cancel"));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
|
||||
await click(link);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
||||
});
|
||||
await click(getButton("Open link"));
|
||||
|
||||
expect(openWindow).toHaveBeenCalledTimes(1);
|
||||
expect(openWindow).toHaveBeenCalledWith(url, "_blank", "noreferrer");
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps same-document links navigable without a confirmation", async () => {
|
||||
await renderMarkdown({ content: "[Details](#details)" });
|
||||
const link = container.querySelector<HTMLAnchorElement>(
|
||||
'[data-streamdown="link"]',
|
||||
);
|
||||
|
||||
expect(link?.getAttribute("href")).toBe("#details");
|
||||
await click(link as HTMLAnchorElement);
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("copies fenced code through the Clipboard API", async () => {
|
||||
const source = "const answer = 42;";
|
||||
await renderMarkdown({
|
||||
content: `\`\`\`text\n${source}\n\`\`\``,
|
||||
});
|
||||
const copyButton = await vi.waitFor(() => {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-streamdown="code-block-copy-button"]',
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
|
||||
await click(copyButton);
|
||||
await vi.waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith(`${source}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
test("rerenders incomplete streaming Markdown as completed static Markdown", async () => {
|
||||
await renderMarkdown({
|
||||
content: "```text\nconst answer =",
|
||||
streaming: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const codeBlock = container.querySelector(
|
||||
'[data-streamdown="code-block"]',
|
||||
);
|
||||
expect(codeBlock?.getAttribute("data-incomplete")).toBe("true");
|
||||
});
|
||||
|
||||
await renderMarkdown({
|
||||
content: "```text\nconst answer = 42;\n```\n\nCompleted.",
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const codeBlock = container.querySelector(
|
||||
'[data-streamdown="code-block"]',
|
||||
);
|
||||
expect(codeBlock).not.toBeNull();
|
||||
expect(codeBlock?.getAttribute("data-incomplete")).toBeNull();
|
||||
expect(container.textContent).toContain("const answer = 42;");
|
||||
expect(container.textContent).toContain("Completed.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MemoizedMarkdown } from "./markdown";
|
||||
|
||||
describe("MemoizedMarkdown", () => {
|
||||
test("renders structured GFM content and blocks remote images", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={`# Review
|
||||
|
||||
| Surface | Status |
|
||||
| --- | --- |
|
||||
| Code | Ready |
|
||||
|
||||
\`\`\`typescript
|
||||
const ready = true;
|
||||
\`\`\`
|
||||
|
||||
`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="heading-1"');
|
||||
expect(html).toContain('data-streamdown="table-wrapper"');
|
||||
expect(html).toContain('data-streamdown="code-block"');
|
||||
expect(html).toContain('data-streamdown="blocked-image"');
|
||||
expect(html).toContain("External image blocked for privacy");
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
test("renders app-local images", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={`
|
||||
|
||||
`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html.match(/<img/g)).toHaveLength(2);
|
||||
expect(html).toContain('src="/images/local.png"');
|
||||
expect(html).toContain('src="/images/second-local.png"');
|
||||
expect(html).not.toContain('data-streamdown="blocked-image"');
|
||||
});
|
||||
|
||||
test("repairs an unfinished code fence while streaming", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={"```typescript\nconst stillStreaming = true;"}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="code-block"');
|
||||
expect(html).toContain("stillStreaming");
|
||||
});
|
||||
|
||||
test("routes external links through confirmation controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Review](https://example.com/review)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-streamdown="link"');
|
||||
expect(html).toContain("Review");
|
||||
expect(html).toContain('href="#confirm-external-link"');
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).not.toContain('href="https://example.com/review"');
|
||||
});
|
||||
|
||||
test("leaves app-local and fragment links navigable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Details](#details) [Home](/)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="#details"');
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).not.toContain('aria-haspopup="dialog"');
|
||||
});
|
||||
|
||||
test("blocks scheme-less hostnames before they reach link rendering", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown content="[Review](example.com/path)" />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Review");
|
||||
expect(html).toContain("blocked");
|
||||
expect(html).not.toContain("<a");
|
||||
expect(html).not.toContain('data-streamdown="link"');
|
||||
});
|
||||
|
||||
test("does not expose unsafe script URLs or raw scripts", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
content={
|
||||
'[unsafe](javascript:alert("no"))\n\n\n\n\n\n<script>window.pwned = true</script>'
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("data:image");
|
||||
expect(html).not.toContain("file:///etc/passwd");
|
||||
expect(html).not.toContain("<script");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,216 @@
|
||||
import { marked } from "marked";
|
||||
import { memo, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { ComponentProps, MouseEvent } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type ControlsConfig,
|
||||
type ExtraProps,
|
||||
type LinkSafetyModalProps,
|
||||
Streamdown,
|
||||
} from "streamdown";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./alert-dialog";
|
||||
import { markdownCodeHighlighter } from "./markdown-highlighter";
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
({ content }: { content: string }) => {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.content !== nextProps.content) return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const streamdownPlugins = { cjk, code: markdownCodeHighlighter };
|
||||
const streamdownControls = {
|
||||
code: { copy: true, download: false },
|
||||
mermaid: false,
|
||||
table: false,
|
||||
} satisfies ControlsConfig;
|
||||
|
||||
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
|
||||
|
||||
export function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown);
|
||||
return tokens.map((token) => token.raw);
|
||||
export function MarkdownLinkSafetyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
url,
|
||||
}: LinkSafetyModalProps) {
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
open={isOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Open external link?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You are about to leave Cline and visit this address.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="max-h-32 overflow-y-auto wrap-break-word rounded-md bg-muted p-3 font-mono text-sm">
|
||||
{url}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Open link</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedMarkdown = memo(
|
||||
({ content, id }: { content: string; id: string }) => {
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
|
||||
const occurrences = new Map<string, number>();
|
||||
type MarkdownLinkProps = ComponentProps<"a"> & ExtraProps;
|
||||
|
||||
return blocks.map((block) => {
|
||||
const occurrence = (occurrences.get(block) ?? 0) + 1;
|
||||
occurrences.set(block, occurrence);
|
||||
return (
|
||||
<MemoizedMarkdownBlock
|
||||
content={block}
|
||||
key={`${id}-block_${occurrence}-${block}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
function SafeMarkdownLink({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
node: _node,
|
||||
rel: _rel,
|
||||
target: _target,
|
||||
title,
|
||||
...props
|
||||
}: MarkdownLinkProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isIncomplete = href === "streamdown:incomplete-link";
|
||||
const url = isIncomplete ? undefined : href;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
data-incomplete={isIncomplete}
|
||||
data-streamdown="link"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isAppLink =
|
||||
url.startsWith("#") ||
|
||||
(url.startsWith("/") && !url.startsWith("//")) ||
|
||||
url.startsWith("./") ||
|
||||
url.startsWith("../") ||
|
||||
(!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith("//"));
|
||||
|
||||
if (isAppLink) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
|
||||
data-streamdown="link"
|
||||
href={url}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const openConfirmation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const confirmMiddleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 1) openConfirmation(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: External Markdown retains native link semantics while confirmation withholds the live destination. */}
|
||||
<a
|
||||
{...props}
|
||||
aria-haspopup="dialog"
|
||||
className={`wrap-anywhere font-medium text-primary underline ${className ?? ""}`}
|
||||
data-streamdown="link"
|
||||
href="#confirm-external-link"
|
||||
onAuxClick={confirmMiddleClick}
|
||||
onClick={openConfirmation}
|
||||
title={title ?? url}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<MarkdownLinkSafetyModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onConfirm={() => window.open(url, "_blank", "noreferrer")}
|
||||
url={url}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownImageProps =
|
||||
| (ComponentProps<"img"> & ExtraProps)
|
||||
| (Record<string, unknown> & ExtraProps);
|
||||
|
||||
const remoteImagePattern = /^(?:https?:)?[\\/]{2}/i;
|
||||
|
||||
function isSafeMarkdownImageSource(source: string): boolean {
|
||||
const normalized = source.trim();
|
||||
if (!normalized || remoteImagePattern.test(normalized)) return false;
|
||||
|
||||
// Streamdown's hardened URL policy accepts app-root paths. Keeping the rule
|
||||
// this narrow prevents model-authored Markdown from making hidden requests.
|
||||
return normalized.startsWith("/");
|
||||
}
|
||||
|
||||
function MarkdownImage({ alt, height, src, title, width }: MarkdownImageProps) {
|
||||
const label = typeof alt === "string" ? alt.trim() : "";
|
||||
const source = typeof src === "string" ? src.trim() : "";
|
||||
|
||||
if (source && isSafeMarkdownImageSource(source)) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: Markdown can reference runtime app assets that Next Image cannot statically optimize.
|
||||
<img
|
||||
alt={label}
|
||||
className="my-4 max-w-full rounded-lg"
|
||||
data-streamdown="image"
|
||||
height={typeof height === "number" ? height : undefined}
|
||||
loading="lazy"
|
||||
src={source}
|
||||
title={typeof title === "string" ? title : undefined}
|
||||
width={typeof width === "number" ? width : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-streamdown="blocked-image" role="note">
|
||||
External image blocked for privacy{label ? `: ${label}` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
a: SafeMarkdownLink,
|
||||
img: MarkdownImage,
|
||||
} satisfies Components;
|
||||
|
||||
export const MemoizedMarkdown = memo(
|
||||
({
|
||||
content,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
}) => (
|
||||
<Streamdown
|
||||
className="cline-markdown"
|
||||
components={markdownComponents}
|
||||
controls={streamdownControls}
|
||||
dir="auto"
|
||||
isAnimating={streaming}
|
||||
lineNumbers
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
normalizeHtmlIndentation
|
||||
parseIncompleteMarkdown={streaming}
|
||||
plugins={streamdownPlugins}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
),
|
||||
);
|
||||
|
||||
MemoizedMarkdown.displayName = "MemoizedMarkdown";
|
||||
|
||||
@@ -26,7 +26,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = 256;
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
@@ -26,6 +26,7 @@ function Slider({
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-interactive=""
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { ChatInputBar } from "./chat-input-bar";
|
||||
|
||||
const { loadProviderModelCatalogMock, loadProviderModelsMock } = vi.hoisted(
|
||||
() => ({
|
||||
loadProviderModelCatalogMock: vi.fn(),
|
||||
loadProviderModelsMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/provider-model-catalog", () => ({
|
||||
loadProviderModelCatalog: loadProviderModelCatalogMock,
|
||||
loadProviderModels: loadProviderModelsMock,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
loadProviderModelCatalogMock.mockReset().mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: [] },
|
||||
});
|
||||
loadProviderModelsMock.mockReset().mockResolvedValue([]);
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatInputBar", () => {
|
||||
it("preserves an explicit High selection across capability and status updates", async () => {
|
||||
const onReasoningChange = vi.fn();
|
||||
const render = async (status: ChatSessionStatus) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onUndoPromptInQueue={vi.fn()}
|
||||
promptInput=""
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="high"
|
||||
status={status}
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
await render("idle");
|
||||
await vi.waitFor(() => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
expect(trigger?.textContent).toContain("High");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
});
|
||||
await render("starting");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
|
||||
await render("running");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
|
||||
|
||||
expect(onReasoningChange).not.toHaveBeenCalled();
|
||||
const workspaceTrigger = container.querySelector("#git-branch-btn");
|
||||
expect(workspaceTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"overflow-visible",
|
||||
);
|
||||
expect(
|
||||
workspaceTrigger?.parentElement?.parentElement?.className,
|
||||
).not.toContain("truncate");
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: ["test-model"] },
|
||||
});
|
||||
const onReasoningChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/workspace/cline",
|
||||
workspaces: ["/workspace/cline"],
|
||||
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onUndoPromptInQueue={vi.fn()}
|
||||
promptInput=""
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
});
|
||||
const trigger = await vi.waitFor(() => {
|
||||
const element = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
expect(element?.disabled).toBe(false);
|
||||
return element as HTMLButtonElement;
|
||||
});
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const highOption = await vi.waitFor(() => {
|
||||
const element = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((option) => option.textContent?.includes("High"));
|
||||
expect(element).toBeDefined();
|
||||
return element as HTMLElement;
|
||||
});
|
||||
await act(async () => {
|
||||
highOption.dispatchEvent(
|
||||
new MouseEvent("pointerup", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
highOption.click();
|
||||
});
|
||||
|
||||
expect(onReasoningChange).toHaveBeenCalledWith({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
ChevronDown,
|
||||
CircleStop,
|
||||
Coins,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -23,6 +21,13 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
@@ -188,6 +193,7 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
|
||||
}
|
||||
|
||||
type ChatInputBarProps = {
|
||||
variant?: "conversation" | "welcome";
|
||||
status: ChatSessionStatus;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -203,12 +209,10 @@ type ChatInputBarProps = {
|
||||
onReasoningChange: (
|
||||
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
|
||||
) => void;
|
||||
onRefreshGitBranch: () => void;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
onReset: () => void;
|
||||
promptsInQueue: PromptInQueue[];
|
||||
attachments: Array<{ id: string; name: string; isImage: boolean }>;
|
||||
onAttachFiles: (files: File[]) => void;
|
||||
@@ -227,6 +231,7 @@ type ChatInputBarProps = {
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
variant = "conversation",
|
||||
status,
|
||||
provider,
|
||||
model,
|
||||
@@ -240,12 +245,10 @@ export function ChatInputBar({
|
||||
onModelChange,
|
||||
onModeToggle,
|
||||
onReasoningChange,
|
||||
onRefreshGitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
onSend,
|
||||
onAbort,
|
||||
onReset,
|
||||
promptsInQueue,
|
||||
attachments,
|
||||
onAttachFiles,
|
||||
@@ -264,10 +267,33 @@ export function ChatInputBar({
|
||||
} = useWorkspace();
|
||||
const isBusy =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const canAbort = status === "running" || status === "stopping";
|
||||
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
|
||||
|
||||
const [modelSupportsReasoning, setModelSupportsReasoning] = useState(() =>
|
||||
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
|
||||
const [reasoningCapability, setReasoningCapability] = useState<{
|
||||
provider: string;
|
||||
model: string;
|
||||
supported: boolean | null;
|
||||
} | null>(null);
|
||||
const modelSupportsReasoning =
|
||||
reasoningCapability?.provider === provider &&
|
||||
reasoningCapability.model === model
|
||||
? reasoningCapability.supported
|
||||
: null;
|
||||
const handleModelSupportsReasoningChange = useCallback(
|
||||
(supported: boolean | null) => {
|
||||
setReasoningCapability((current) => {
|
||||
if (
|
||||
current?.provider === provider &&
|
||||
current.model === model &&
|
||||
current.supported === supported
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { provider, model, supported };
|
||||
});
|
||||
},
|
||||
[model, provider],
|
||||
);
|
||||
const canSend = hasDraft;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -312,32 +338,50 @@ export function ChatInputBar({
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const effortLabel = modelSupportsReasoning
|
||||
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
|
||||
: "None";
|
||||
const handleEffortCycle = useCallback(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
return;
|
||||
}
|
||||
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
|
||||
if (!nextOption) {
|
||||
return;
|
||||
}
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
|
||||
const hasExplicitReasoningSelection =
|
||||
thinking !== undefined || reasoningEffort !== undefined;
|
||||
const effortLabel =
|
||||
!hasExplicitReasoningSelection && modelSupportsReasoning === null
|
||||
? "Reasoning"
|
||||
: !hasExplicitReasoningSelection && modelSupportsReasoning === false
|
||||
? "None"
|
||||
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
|
||||
const handleEffortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (modelSupportsReasoning !== true) {
|
||||
return;
|
||||
}
|
||||
const nextOption = EFFORT_LEVELS.find((option) => option.value === value);
|
||||
if (nextOption) {
|
||||
onReasoningChange(buildReasoningConfig(nextOption));
|
||||
}
|
||||
},
|
||||
[modelSupportsReasoning, onReasoningChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelSupportsReasoning) {
|
||||
if (thinking !== false || reasoningEffort !== undefined) {
|
||||
onReasoningChange({ thinking: false, reasoningEffort: undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (thinking === undefined && reasoningEffort === undefined) {
|
||||
if (
|
||||
modelSupportsReasoning === true &&
|
||||
thinking === undefined &&
|
||||
reasoningEffort === undefined
|
||||
) {
|
||||
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) return;
|
||||
if (
|
||||
variant === "conversation" ||
|
||||
(variant === "welcome" &&
|
||||
promptInput.trim().length > 0 &&
|
||||
document.activeElement !== input)
|
||||
) {
|
||||
input.focus();
|
||||
}
|
||||
}, [promptInput, variant]);
|
||||
|
||||
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
|
||||
setEditingQueuedPromptId(item.id);
|
||||
setEditingQueuedPromptValue(item.prompt);
|
||||
@@ -589,9 +633,16 @@ export function ChatInputBar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card",
|
||||
variant === "welcome"
|
||||
? "overflow-visible rounded-xl border border-border/90 bg-card/90 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
|
||||
: "border-t border-border bg-card/95 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{/* Input area */}
|
||||
<div className="px-4 py-3">
|
||||
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
|
||||
{promptsInQueue.length > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-border bg-background/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -734,7 +785,11 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div className="relative">
|
||||
{slashOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
|
||||
id="slash-command-suggestions"
|
||||
role="listbox"
|
||||
>
|
||||
{filteredSlashCommands.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{slashLoading
|
||||
@@ -745,6 +800,7 @@ export function ChatInputBar({
|
||||
<>
|
||||
{filteredSlashCommands.map((cmd, index) => (
|
||||
<button
|
||||
aria-selected={index === slashSelectedIndex}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
index === slashSelectedIndex
|
||||
@@ -752,7 +808,9 @@ export function ChatInputBar({
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
key={cmd.name}
|
||||
id={`slash-command-option-${index}`}
|
||||
onClick={() => insertSlashCommandItem(cmd.name)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium">/{cmd.name}</span>
|
||||
@@ -773,7 +831,11 @@ export function ChatInputBar({
|
||||
</div>
|
||||
)}
|
||||
{mentionOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl"
|
||||
id="mention-file-suggestions"
|
||||
role="listbox"
|
||||
>
|
||||
{mentionFiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{mentionLoading ? "Searching files..." : "No matching files"}
|
||||
@@ -782,6 +844,7 @@ export function ChatInputBar({
|
||||
<>
|
||||
{mentionFiles.map((filePath, index) => (
|
||||
<button
|
||||
aria-selected={index === mentionSelectedIndex}
|
||||
className={cn(
|
||||
"block w-full rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
index === mentionSelectedIndex
|
||||
@@ -789,7 +852,9 @@ export function ChatInputBar({
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
key={filePath}
|
||||
id={`mention-file-option-${index}`}
|
||||
onClick={() => insertMentionFile(filePath)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{filePath}
|
||||
@@ -804,8 +869,31 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome" &&
|
||||
"min-h-16 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
aria-activedescendant={
|
||||
slashOpen && filteredSlashCommands.length > 0
|
||||
? `slash-command-option-${slashSelectedIndex}`
|
||||
: mentionOpen && mentionFiles.length > 0
|
||||
? `mention-file-option-${mentionSelectedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={
|
||||
slashOpen
|
||||
? "slash-command-suggestions"
|
||||
: mentionOpen
|
||||
? "mention-file-suggestions"
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={slashOpen || mentionOpen}
|
||||
aria-haspopup="listbox"
|
||||
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
@@ -893,15 +981,20 @@ export function ChatInputBar({
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
variant === "welcome"
|
||||
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
|
||||
: isBusy
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
role="combobox"
|
||||
rows={
|
||||
promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
variant === "welcome"
|
||||
? 2
|
||||
: promptInputFocused
|
||||
? PROMPT_INPUT_FOCUSED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS
|
||||
}
|
||||
value={promptInput}
|
||||
/>
|
||||
@@ -916,6 +1009,7 @@ export function ChatInputBar({
|
||||
>
|
||||
{attachment.isImage ? "image:" : "file:"} {attachment.name}
|
||||
<button
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => onRemoveAttachment(attachment.id)}
|
||||
type="button"
|
||||
@@ -928,11 +1022,12 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Composer settings and submit */}
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground max-[560px]:grid max-[560px]:grid-cols-[auto_auto_minmax(0,1fr)_auto] max-[560px]:items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 max-[560px]:contents">
|
||||
<button
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Attach files"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-[560px]:col-start-1 max-[560px]:row-start-1"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
@@ -944,97 +1039,138 @@ export function ChatInputBar({
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
onAttachFiles(files);
|
||||
}
|
||||
if (files.length > 0) onAttachFiles(files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={setModelSupportsReasoning}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="hidden rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</button>
|
||||
{isBusy && (
|
||||
<div className="flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<button
|
||||
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors"
|
||||
onClick={onAbort}
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
mode === "plan"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "plan") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="h-4 w-4" />
|
||||
Plan
|
||||
</button>
|
||||
)}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
aria-pressed={mode === "act"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 transition-colors",
|
||||
mode === "act"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "act") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Act
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0 max-[560px]:col-start-3 max-[560px]:col-end-5 max-[560px]:row-start-1">
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={modelSupportsReasoning !== true}
|
||||
onValueChange={handleEffortChange}
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
? "The selected model does not report reasoning support"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Brain className="size-3" />
|
||||
<SelectValue>{effortLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tokensSummary ? (
|
||||
<span className="max-[900px]:hidden">
|
||||
<StatusItem
|
||||
icon={Coins}
|
||||
label={tokensSummary}
|
||||
hasOption={false}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusItem
|
||||
label={mode === "act" ? "Act" : "Plan"}
|
||||
onClick={onModeToggle}
|
||||
/>
|
||||
<StatusItem
|
||||
disabled={!modelSupportsReasoning}
|
||||
icon={Brain}
|
||||
label={effortLabel}
|
||||
onClick={handleEffortCycle}
|
||||
/>
|
||||
{tokensSummary && (
|
||||
<StatusItem icon={Coins} label={tokensSummary} hasOption={false} />
|
||||
)}
|
||||
</div>
|
||||
{/* GIT BRANCH */}
|
||||
<div className="flex items-center gap-3">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
<button
|
||||
className="hidden items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={onRefreshGitBranch}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="hidden items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
|
||||
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[560px]:col-start-4 max-[560px]:row-start-2">
|
||||
{canAbort && (
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
className={cn(
|
||||
"bg-foreground p-1.5 text-background transition-colors hover:bg-foreground/80",
|
||||
variant === "welcome" ? "rounded-md" : "rounded-full",
|
||||
)}
|
||||
onClick={onAbort}
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{(!isBusy || canSend) && (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "welcome"
|
||||
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
|
||||
: "rounded-full bg-foreground text-background hover:bg-foreground/80",
|
||||
)}
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1045,6 +1181,7 @@ function ModelSelector({
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
variant,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
@@ -1052,9 +1189,10 @@ function ModelSelector({
|
||||
provider: string;
|
||||
model: string;
|
||||
isBusy: boolean;
|
||||
variant: "conversation" | "welcome";
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
}) {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
@@ -1063,6 +1201,9 @@ function ModelSelector({
|
||||
const [providerReasoningModels, setProviderReasoningModels] = useState<
|
||||
Record<string, string[]>
|
||||
>(FALLBACK_PROVIDER_REASONING_MODELS);
|
||||
const [reasoningCapabilitySource, setReasoningCapabilitySource] = useState<
|
||||
"loading" | "catalog" | "fallback"
|
||||
>("loading");
|
||||
const [enabledProviderIds, setEnabledProviderIds] = useState<string[]>([]);
|
||||
const [lastSelection, setLastSelection] = useState(() =>
|
||||
readModelSelectionStorageFromWindow(),
|
||||
@@ -1120,6 +1261,7 @@ function ModelSelector({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReasoningCapabilitySource("loading");
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
@@ -1129,6 +1271,7 @@ function ModelSelector({
|
||||
}
|
||||
setProviderModels(payload.providerModels);
|
||||
setProviderReasoningModels(payload.providerReasoningModels);
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) => {
|
||||
const nextProviderIds = new Set(payload.enabledProviderIds);
|
||||
if (normalizedProvider) {
|
||||
@@ -1142,7 +1285,7 @@ function ModelSelector({
|
||||
return Array.from(nextProviderIds);
|
||||
});
|
||||
} catch {
|
||||
// Keep local fallback values when provider catalog is unavailable.
|
||||
if (!cancelled) setReasoningCapabilitySource("fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,6 +1323,7 @@ function ModelSelector({
|
||||
...current,
|
||||
[normalizedProvider]: reasoningModelIds,
|
||||
}));
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setEnabledProviderIds((current) =>
|
||||
current.includes(normalizedProvider)
|
||||
? current
|
||||
@@ -1246,6 +1390,16 @@ function ModelSelector({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reasoningCapabilitySource === "loading") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
reasoningCapabilitySource === "fallback" &&
|
||||
!(FALLBACK_PROVIDER_MODELS[normalizedProvider] ?? []).includes(model)
|
||||
) {
|
||||
onModelSupportsReasoningChange(null);
|
||||
return;
|
||||
}
|
||||
onModelSupportsReasoningChange(
|
||||
hasReasoningCapability(
|
||||
providerReasoningModels,
|
||||
@@ -1258,10 +1412,11 @@ function ModelSelector({
|
||||
onModelSupportsReasoningChange,
|
||||
normalizedProvider,
|
||||
providerReasoningModels,
|
||||
reasoningCapabilitySource,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xxs">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
|
||||
<Combobox
|
||||
items={providers}
|
||||
onValueChange={(value) => {
|
||||
@@ -1287,7 +1442,11 @@ function ModelSelector({
|
||||
value={resolvedProvider}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="h-7 text-xxs"
|
||||
aria-label="Provider"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-20",
|
||||
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || providers.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
@@ -1297,7 +1456,7 @@ function ModelSelector({
|
||||
<ComboboxEmpty>No providers found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-xxs" key={item} value={item}>
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
@@ -1316,7 +1475,11 @@ function ModelSelector({
|
||||
value={resolvedModel}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="h-7"
|
||||
aria-label="Model"
|
||||
className={cn(
|
||||
"h-7 text-[11px] max-[560px]:w-32",
|
||||
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
|
||||
)}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
readOnly
|
||||
showClear={false}
|
||||
@@ -1326,7 +1489,7 @@ function ModelSelector({
|
||||
<ComboboxEmpty>No models found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem className="text-xxs" key={item} value={item}>
|
||||
<ComboboxItem className="text-[11px]" key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
@@ -1350,6 +1513,16 @@ function StatusItem({
|
||||
disabled?: boolean;
|
||||
hasOption?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
{Icon ? <Icon className="h-3 w-3" /> : null}
|
||||
<span className="max-[560px]:sr-only">{label}</span>
|
||||
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
|
||||
</>
|
||||
);
|
||||
if (!onClick) {
|
||||
return <span className="flex items-center gap-1">{content}</span>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -1360,9 +1533,7 @@ function StatusItem({
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{Icon ? <Icon className="h-3 w-3" /> : null}
|
||||
<span>{label}</span>
|
||||
{hasOption ? <ChevronDown className="h-2.5 w-2.5" /> : null}
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import { ChatMessages } from "./chat-messages";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderMessages(messages: ChatMessage[]) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ChatMessages
|
||||
chatTransportState="connected"
|
||||
error={null}
|
||||
messages={messages}
|
||||
onAnswerAskQuestion={vi.fn()}
|
||||
onApproveToolApproval={vi.fn()}
|
||||
onRejectToolApproval={vi.fn()}
|
||||
pendingAskQuestions={[]}
|
||||
pendingToolApprovals={[]}
|
||||
sessionId="session-1"
|
||||
status="completed"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ChatMessages tool disclosures", () => {
|
||||
it("renders a detail-less tool summary as static text", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-static",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
createdAt: 1,
|
||||
meta: { toolName: "search" },
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = [...container.querySelectorAll("span")].find((element) =>
|
||||
element.textContent?.includes("Explored"),
|
||||
);
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "tool-expandable",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "search",
|
||||
input: { queries: ["workspace selector"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Explored 1 search"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(panelId).toBeTruthy();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"workspace selector",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -34,8 +35,7 @@ import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { parseApplyPatchInput } from "@/lib/session-diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../ui/markdown";
|
||||
import { normalizeTitle } from "../../utils";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
import { formatChatMessageContent } from "./message-content";
|
||||
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
@@ -46,8 +46,6 @@ type ChatMessagesProps = {
|
||||
| "connected"
|
||||
| "unavailable";
|
||||
isSessionSwitching?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
error: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
@@ -61,7 +59,6 @@ type ChatMessagesProps = {
|
||||
) => void | Promise<void>;
|
||||
onRestoreCheckpoint?: (runCount: number) => void | Promise<void>;
|
||||
onForkSession?: () => void | Promise<void>;
|
||||
onStartChat?: (prompt: string) => void;
|
||||
};
|
||||
|
||||
type ToolApprovalRequestItem = {
|
||||
@@ -97,8 +94,6 @@ function ChatMessagesImpl({
|
||||
status,
|
||||
chatTransportState = "connecting",
|
||||
isSessionSwitching = false,
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
error,
|
||||
streamingMessageId = null,
|
||||
@@ -109,9 +104,9 @@ function ChatMessagesImpl({
|
||||
onAnswerAskQuestion,
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
onStartChat,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
@@ -208,6 +203,17 @@ function ChatMessagesImpl({
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = scrollContentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) scrollToBottom("auto");
|
||||
});
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
pendingToolApprovals.map((item) => item.requestId),
|
||||
@@ -376,16 +382,15 @@ function ChatMessagesImpl({
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
|
||||
{showIdleDetails ? (
|
||||
<WelcomeScreen
|
||||
provider={provider}
|
||||
model={model}
|
||||
onStartChat={onStartChat ?? (() => {})}
|
||||
quickActions={[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
ref={scrollContentRef}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -748,7 +753,10 @@ function MessageBubble({
|
||||
return <ToolMessageBlock message={message} />;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeTitle(message.content);
|
||||
const displayContent = formatChatMessageContent(
|
||||
message.role,
|
||||
message.content,
|
||||
);
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
@@ -761,7 +769,7 @@ function MessageBubble({
|
||||
<div
|
||||
className={cn(
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[50%] flex-col items-end gap-1",
|
||||
isUser && "flex max-w-[85%] flex-col items-end gap-1 md:max-w-[50%]",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
@@ -774,35 +782,20 @@ function MessageBubble({
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{isStreaming && message.role === "assistant" ? (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{normalizedContent || " "}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
/>
|
||||
) : null}
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={normalizedContent || " "}
|
||||
id={message.id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
@@ -926,11 +919,14 @@ function MessageBubble({
|
||||
function ReasoningBlock({
|
||||
content,
|
||||
redacted,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
@@ -939,17 +935,35 @@ function ReasoningBlock({
|
||||
return (
|
||||
<div className="my-2">
|
||||
<Button
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
Thinking
|
||||
<BrainIcon aria-hidden="true" className="size-4" />
|
||||
<span>{streaming ? "Thinking" : "Thought process"}</span>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
{streaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{displayContent}
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1334,6 +1348,7 @@ function buildToolSummaryFromMeta(
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1365,33 +1380,38 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
const summaryContent = (
|
||||
<>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">-{summary.diff.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
<Button
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">
|
||||
-{summary.diff.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{hasExpandedSections ? (
|
||||
{hasExpandedSections ? (
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{summaryContent}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
@@ -1399,10 +1419,17 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex max-w-full items-center justify-start gap-2 py-1 text-left text-sm font-medium text-primary">
|
||||
{summaryContent}
|
||||
</div>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
@@ -1417,7 +1444,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatChatMessageContent } from "./message-content";
|
||||
|
||||
describe("formatChatMessageContent", () => {
|
||||
test("unwraps transport envelopes only for user messages", () => {
|
||||
expect(
|
||||
formatChatMessageContent(
|
||||
"user",
|
||||
" <user_input>\nPlease fix the tests\n</user_input> ",
|
||||
),
|
||||
).toBe("Please fix the tests");
|
||||
});
|
||||
|
||||
test("preserves assistant examples that contain transport tags", () => {
|
||||
const content =
|
||||
"<user_input>\nThis tag is part of the explanation.\n</user_input>";
|
||||
expect(formatChatMessageContent("assistant", content)).toBe(content);
|
||||
});
|
||||
|
||||
test("preserves assistant mode notices instead of stripping them", () => {
|
||||
const content = "<mode_notice>\nPlan mode details\n</mode_notice>";
|
||||
expect(formatChatMessageContent("assistant", content)).toBe(content);
|
||||
});
|
||||
|
||||
test("trims outer whitespace for non-user roles", () => {
|
||||
expect(formatChatMessageContent("error", " Request failed \n")).toBe(
|
||||
"Request failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared/browser";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
|
||||
export function formatChatMessageContent(
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
): string {
|
||||
const trimmed = content.trim();
|
||||
return role === "user" ? formatDisplayUserInput(trimmed) : trimmed;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("renders every known project instead of capping the project strip", () => {
|
||||
const workspaces = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `/projects/project-${index + 1}`,
|
||||
);
|
||||
const html = renderToStaticMarkup(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: workspaces[0] ?? "",
|
||||
workspaces,
|
||||
listWorkspaces: vi.fn(async () => workspaces),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
onStartChat={vi.fn()}
|
||||
quickActions={[]}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
|
||||
for (let index = 1; index <= workspaces.length; index += 1) {
|
||||
expect(html).toContain(`project-${index}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Check, FolderOpen } from "lucide-react";
|
||||
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AuroraBackground } from "@/components/ui/aurora-bg";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
@@ -20,68 +15,95 @@ interface QuickAction {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string): string {
|
||||
const normalized = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
id: "review-changes",
|
||||
label: "Review changes",
|
||||
description: "Review the current changes and call out anything risky.",
|
||||
prompt: "Review the current changes and call out anything risky.",
|
||||
},
|
||||
{
|
||||
id: "check-build",
|
||||
label: "Check for build errors",
|
||||
description: "Run the relevant checks and help me fix any failures.",
|
||||
prompt: "Check this project for build errors and help me fix any failures.",
|
||||
},
|
||||
];
|
||||
|
||||
function toWorkspaceName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "workspace";
|
||||
if (!trimmed) return "Workspace";
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || "workspace";
|
||||
return parts[parts.length - 1] || "Workspace";
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(workspacePath: string): string {
|
||||
const trimmed = workspacePath.trim();
|
||||
if (!trimmed) return workspacePath;
|
||||
const unixHome = trimmed.match(/^\/Users\/[^/]+\/(.*)$/);
|
||||
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
|
||||
const linuxHome = trimmed.match(/^\/home\/[^/]+\/(.*)$/);
|
||||
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
|
||||
const windowsHome = trimmed.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
|
||||
if (windowsHome) {
|
||||
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
|
||||
return tail ? `~/${tail}` : "~";
|
||||
}
|
||||
return workspacePath;
|
||||
function workspaceLabels(paths: string[]): Map<string, string> {
|
||||
const segments = paths.map((path) =>
|
||||
path
|
||||
.trim()
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return new Map(
|
||||
paths.map((path, index) => {
|
||||
const parts = segments[index] ?? [];
|
||||
for (let depth = 1; depth <= parts.length; depth += 1) {
|
||||
const candidate = parts.slice(-depth).join("/");
|
||||
const matches = segments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return [path, candidate];
|
||||
}
|
||||
return [path, toWorkspaceName(path)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen({
|
||||
active,
|
||||
body,
|
||||
composer,
|
||||
onStartChat,
|
||||
quickActions,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
composer: ReactNode;
|
||||
onStartChat: (prompt: string) => void;
|
||||
quickActions: QuickAction[];
|
||||
}) {
|
||||
const { workspaceRoot, workspaces, refreshWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState(false);
|
||||
const {
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
refreshWorkspaces,
|
||||
switchWorkspace,
|
||||
pickWorkspaceDirectory,
|
||||
} = useWorkspace();
|
||||
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
||||
const availableWorkspaces = useMemo(() => {
|
||||
const next = new Map<string, string>();
|
||||
const register = (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
|
||||
};
|
||||
register(workspaceRoot);
|
||||
for (const workspacePath of workspaces) {
|
||||
register(workspacePath);
|
||||
}
|
||||
for (const workspacePath of workspaces) register(workspacePath);
|
||||
return [...next.values()];
|
||||
}, [workspaceRoot, workspaces]);
|
||||
const actions =
|
||||
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
|
||||
const labelsByWorkspace = useMemo(
|
||||
() => workspaceLabels(availableWorkspaces),
|
||||
[availableWorkspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshWorkspaces();
|
||||
}, [refreshWorkspaces]);
|
||||
if (active) void refreshWorkspaces();
|
||||
}, [active, refreshWorkspaces]);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
async (path: string) => {
|
||||
@@ -89,108 +111,145 @@ export function WelcomeScreen({
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot) ||
|
||||
switchingWorkspace
|
||||
)
|
||||
) {
|
||||
return;
|
||||
setSwitchingWorkspace(true);
|
||||
await switchWorkspace(path);
|
||||
setSwitchingWorkspace(false);
|
||||
}
|
||||
setSwitchingWorkspace(path);
|
||||
try {
|
||||
await switchWorkspace(path);
|
||||
} finally {
|
||||
setSwitchingWorkspace(null);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, switchWorkspace, switchingWorkspace],
|
||||
[switchWorkspace, switchingWorkspace, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
// TODO: wire up quick action prompt to chat input
|
||||
void action;
|
||||
};
|
||||
const handleAddWorkspace = useCallback(async () => {
|
||||
if (addingWorkspace) return;
|
||||
setAddingWorkspace(true);
|
||||
try {
|
||||
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
|
||||
if (selected) await switchWorkspace(selected);
|
||||
} finally {
|
||||
setAddingWorkspace(false);
|
||||
}
|
||||
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
|
||||
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="mt-2 text-balance text-center text-muted-foreground">
|
||||
Let's explore, edit, and ship code together!
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "relative h-full min-h-0 overflow-hidden bg-background"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? <AuroraBackground /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "relative z-10 h-full w-full overflow-x-hidden overflow-y-auto"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
|
||||
What would you like to build?
|
||||
</h1>
|
||||
|
||||
{/* Workspace selector */}
|
||||
<div className="mb-8 w-full max-w-md">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Workspace
|
||||
</Label>
|
||||
<Command className="rounded-xl border border-border bg-card">
|
||||
<CommandInput placeholder="Search workspaces..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No workspaces found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableWorkspaces.map((wsPath) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(wsPath) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
return (
|
||||
<CommandItem
|
||||
key={wsPath}
|
||||
value={wsPath}
|
||||
onSelect={() => {
|
||||
void handleSelectWorkspace(wsPath);
|
||||
}}
|
||||
disabled={switchingWorkspace}
|
||||
className="gap-3 py-2.5"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{toWorkspaceName(wsPath)}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{formatWorkspaceLabel(wsPath)}
|
||||
</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check className="ml-auto h-4 w-4 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="mb-8 w-full">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{quickActions?.map((action) => {
|
||||
return (
|
||||
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
|
||||
<legend className="sr-only">Workspaces</legend>
|
||||
{availableWorkspaces.map((path) => {
|
||||
const isActive =
|
||||
normalizeWorkspacePath(path) ===
|
||||
normalizeWorkspacePath(workspaceRoot);
|
||||
const isSwitching = switchingWorkspace === path;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
disabled={Boolean(switchingWorkspace)}
|
||||
key={path}
|
||||
onClick={() => void handleSelectWorkspace(path)}
|
||||
title={path}
|
||||
type="button"
|
||||
>
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: (labelsByWorkspace.get(path) ??
|
||||
toWorkspaceName(path))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
|
||||
disabled={addingWorkspace}
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
type="button"
|
||||
key={action.id}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="group flex flex-col items-start gap-2 rounded-xl border border-border bg-card/50 p-4 text-left transition-all hover:border-primary/30 hover:bg-card"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{action.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
{addingWorkspace ? (
|
||||
<FolderPlus className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
New project
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
|
||||
key="conversation-body"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
|
||||
key="persistent-composer"
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="mt-11 w-full divide-y divide-border/80 overflow-hidden rounded-xl border border-border/60 bg-background/95 px-2 shadow-sm">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-5 px-3 py-3 text-left transition-colors hover:bg-background/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
key={action.id}
|
||||
onClick={() => onStartChat(action.prompt)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-medium text-foreground">
|
||||
{action.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<ArrowRight className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceSelector } from "./workspace-selector";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement {
|
||||
const button = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(button).toBeDefined();
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("WorkspaceSelector", () => {
|
||||
it("switches both workspace and branch choices from the opened menu", async () => {
|
||||
const onSwitchWorkspace = vi.fn(async () => true);
|
||||
const onSwitchGitBranch = vi.fn(async () => true);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main", "feature/review"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/workspace/one"
|
||||
workspaces={["/workspace/one", "/workspace/two"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("/workspace/two");
|
||||
expect(container.textContent).toContain("feature/review");
|
||||
});
|
||||
await click(buttonWithText("/workspace/two"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onSwitchWorkspace).toHaveBeenCalledWith("/workspace/two");
|
||||
});
|
||||
|
||||
await click(container.querySelector("#git-branch-btn") as Element);
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("feature/review");
|
||||
});
|
||||
await click(buttonWithText("feature/review"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,7 @@ export function WorkspaceSelector({
|
||||
variant="ghost"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-40 cursor-default h-auto rounded-none opacity-0"
|
||||
data-cursor="default"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setShowWorkspacePathInput(false);
|
||||
|
||||
@@ -59,7 +59,7 @@ type InstalledStatusState = "loading" | "ready";
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
const CODE_FONT_STYLE: CSSProperties = {
|
||||
fontFamily:
|
||||
'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
'"Azeret Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
};
|
||||
|
||||
const primitivePageDetails = {
|
||||
|
||||
@@ -195,10 +195,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
|
||||
<header className="flex shrink-0 items-end justify-between gap-6 px-18 pb-7 pt-10 max-[1200px]:px-8 max-md:pl-12 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:pr-4 max-[720px]:pt-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<h1 className="text-[32px] font-semibold leading-[1.15] tracking-normal">
|
||||
Sessions
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Recent sessions across clients and workspaces.
|
||||
</p>
|
||||
</div>
|
||||
@@ -228,10 +230,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
|
||||
{sortDirection === "newest" ? "Newest first" : "Newest first"}
|
||||
Newest first
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
|
||||
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
|
||||
Oldest first
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -284,7 +286,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<section className="min-h-0 flex-1 overflow-auto px-18 pb-10 max-[1200px]:px-8 max-[720px]:px-4">
|
||||
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Session</span>
|
||||
@@ -293,7 +295,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span />
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
<div>
|
||||
{history.isLoadingHistory && history.threads.length === 0 ? (
|
||||
@@ -412,12 +414,15 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span className="sr-only">Open session: </span>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
statusTone(thread.status),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{thread.status}: </span>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
@@ -440,42 +445,44 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Session actions for ${thread.title}`}
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
disabled={Boolean(pendingKind)}
|
||||
type="button"
|
||||
>
|
||||
{pendingKind ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
readSystemHubTheme,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
@@ -34,18 +30,22 @@ import { toSettingsPatch } from "./settings-patch";
|
||||
// Settings nav categories
|
||||
// -----------------------------------------------------------
|
||||
|
||||
const navCategories = [
|
||||
export const SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Providers",
|
||||
"MCP",
|
||||
"Marketplace",
|
||||
"Extensions",
|
||||
"Models",
|
||||
"MCP Servers",
|
||||
"MCP Marketplace",
|
||||
"Customizations",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -59,17 +59,13 @@ let providerCatalogCache: {
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({
|
||||
chrome = "full",
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
section,
|
||||
onNavigateSection,
|
||||
}: {
|
||||
chrome?: "full" | "content";
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
section: SettingsSection;
|
||||
onNavigateSection: (section: SettingsSection) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const activeNav = section;
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
@@ -93,6 +89,13 @@ export function SettingsView({
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Models") {
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
}
|
||||
}, [section]);
|
||||
|
||||
const setProvidersWithCache = useCallback(
|
||||
(next: Provider[] | ((prev: Provider[]) => Provider[])) => {
|
||||
setProviders((prev) => {
|
||||
@@ -139,7 +142,7 @@ export function SettingsView({
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav !== "Providers") {
|
||||
if (activeNav !== "Models") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -288,8 +291,7 @@ export function SettingsView({
|
||||
};
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
@@ -310,7 +312,7 @@ export function SettingsView({
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
@@ -337,18 +339,11 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
onNavigateSection("Models");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const selectSection = (section: SettingsSection) => {
|
||||
setActiveNav(section);
|
||||
onNavigateSection?.(section);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
@@ -403,13 +398,13 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Providers" ? (
|
||||
activeNav === "Models" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP" ? (
|
||||
) : activeNav === "MCP Servers" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Marketplace" ? (
|
||||
) : activeNav === "MCP Marketplace" ? (
|
||||
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
|
||||
) : activeNav === "Extensions" ? (
|
||||
) : activeNav === "Customizations" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
@@ -427,57 +422,10 @@ export function SettingsView({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (chrome === "content") {
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-background">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold text-foreground">Settings</h1>
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="justify-start"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Settings sidebar nav */}
|
||||
<nav className="w-56 shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{navCategories.map((cat) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
selectSection(cat);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
<div className="grid h-full grid-rows-[3rem_minmax(0,1fr)] overflow-hidden bg-background md:block">
|
||||
<div aria-hidden="true" className="md:hidden" />
|
||||
<div className="min-h-0 overflow-hidden md:h-full">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -487,6 +435,82 @@ function GeneralSettingsContent() {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
const [telemetryError, setTelemetryError] = useState<string | null>(null);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadGlobalSettings();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadGlobalSettings]);
|
||||
|
||||
const updateTelemetryOptOut = async (nextValue: boolean) => {
|
||||
const previousValue = telemetryOptOut;
|
||||
setTelemetryOptOut(nextValue);
|
||||
setTelemetrySaving(true);
|
||||
setTelemetryError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_telemetry_opt_out",
|
||||
{ telemetry_opt_out: nextValue },
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryOptOut(previousValue);
|
||||
setTelemetryError(message);
|
||||
} finally {
|
||||
setTelemetrySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = autoUpdateEnabled;
|
||||
setAutoUpdateEnabled(nextValue);
|
||||
setAutoUpdateSaving(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_auto_update_enabled",
|
||||
{ auto_update_enabled: nextValue },
|
||||
);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAutoUpdateEnabled(previousValue);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setAutoUpdateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
@@ -515,6 +539,48 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Auto update
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Automatically install Cline CLI updates on startup.
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Telemetry
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Enable error and usage reports to help improve Cline.
|
||||
</p>
|
||||
{telemetryError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update telemetry setting: {telemetryError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Telemetry"
|
||||
checked={!telemetryOptOut}
|
||||
disabled={telemetryLoading || telemetrySaving}
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths";
|
||||
|
||||
export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE =
|
||||
"Chat connection is unavailable. Reopen the app window to restore realtime chat.";
|
||||
@@ -40,6 +41,7 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
|
||||
export function getInitialChatConfig(): ChatSessionConfig {
|
||||
const selection = readModelSelectionStorageFromWindow();
|
||||
const workspaceSelection = readWorkspaceSelectionFromWindow();
|
||||
const rememberedProvider = normalizeProviderId(selection.lastProvider);
|
||||
const rememberedModelForProvider = rememberedProvider
|
||||
? (selection.lastModelByProvider[rememberedProvider] ??
|
||||
@@ -59,5 +61,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
|
||||
...DEFAULT_CHAT_CONFIG,
|
||||
provider,
|
||||
model,
|
||||
workspaceRoot: workspaceSelection.lastWorkspace,
|
||||
cwd: workspaceSelection.lastWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSessionId } from "@cline/shared/browser";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatSessionConfig,
|
||||
@@ -12,7 +13,7 @@ type RpcMessageLike = {
|
||||
};
|
||||
|
||||
export function makeId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
return createSessionId(`${prefix}_`);
|
||||
}
|
||||
|
||||
function stringifyRpcMessageContent(content: unknown): string {
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
getTransportError: vi.fn(() => null),
|
||||
getTransportState: vi.fn(() => "connected"),
|
||||
invoke: invokeMock,
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
type ChatSessionHook = ReturnType<typeof useChatSession>;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let current: ChatSessionHook;
|
||||
|
||||
function HookHarness() {
|
||||
current = useChatSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useChatSession", () => {
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
let plannedSessionId = "";
|
||||
let startConfig:
|
||||
| { thinking?: boolean; reasoningEffort?: string; sessionId?: string }
|
||||
| undefined;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
config?: {
|
||||
sessionId?: string;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
startConfig = request.config;
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Ready", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
current.setConfig((previous) => ({
|
||||
...previous,
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
}));
|
||||
});
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendPromise = current.sendPrompt("Start the task");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(current.status).toBe("starting");
|
||||
expect(current.messages).toHaveLength(1);
|
||||
expect(current.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Start the task",
|
||||
});
|
||||
expect(current.messages[0]?.sessionId).toMatch(/^session_/);
|
||||
expect(plannedSessionId).toBe(current.messages[0]?.sessionId);
|
||||
expect(startConfig).toMatchObject({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
sessionId: plannedSessionId,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await sendPromise;
|
||||
});
|
||||
expect(
|
||||
current.messages.some((message) => message.content === "Ready"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("overlaps attachment serialization with cold session startup", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
let resolveFile: ((value: string) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const fileContent = new Promise<string>((resolve) => {
|
||||
resolveFile = resolve;
|
||||
});
|
||||
const text = vi.fn(async () => await fileContent);
|
||||
const attachment = {
|
||||
name: "notes.txt",
|
||||
type: "text/plain",
|
||||
size: 5,
|
||||
lastModified: 1,
|
||||
text,
|
||||
} as unknown as File;
|
||||
let plannedSessionId = "";
|
||||
let sentAttachments: unknown;
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
config?: { sessionId?: string };
|
||||
attachments?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
sentAttachments = request.attachments;
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendPromise = current.sendPrompt("Read this", [attachment]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(text).toHaveBeenCalledTimes(1);
|
||||
expect(plannedSessionId).toMatch(/^session_/);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(sentAttachments).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
resolveFile?.("hello");
|
||||
await sendPromise;
|
||||
});
|
||||
expect(sentAttachments).toEqual({
|
||||
userImages: [],
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("shares one cold start and queues a second prompt behind it", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
const actions: Array<{
|
||||
action?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
}> = [];
|
||||
let plannedSessionId = "";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
config?: { sessionId?: string };
|
||||
}
|
||||
| undefined;
|
||||
actions.push(request ?? {});
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return await startResponse;
|
||||
}
|
||||
if (request?.action === "send" && request.delivery === "queue") {
|
||||
return { ok: true, queued: true, promptsInQueue: [] };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "First done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let firstSend: Promise<void> | undefined;
|
||||
let secondSend: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
firstSend = current.sendPrompt("First prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
secondSend = current.sendPrompt("Second prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
actions.filter((request) => request.action === "start"),
|
||||
).toHaveLength(1);
|
||||
expect(current.promptsInQueue.map((item) => item.prompt)).toContain(
|
||||
"Second prompt",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.({ sessionId: plannedSessionId });
|
||||
await Promise.all([firstSend, secondSend]);
|
||||
});
|
||||
const sends = actions.filter((request) => request.action === "send");
|
||||
expect(sends).toHaveLength(2);
|
||||
expect(sends.map((request) => request.sessionId)).toEqual([
|
||||
plannedSessionId,
|
||||
plannedSessionId,
|
||||
]);
|
||||
expect(sends.map((request) => request.delivery)).toEqual([
|
||||
undefined,
|
||||
"queue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves prompt order when the first prompt has a slow attachment", async () => {
|
||||
let resolveFile: ((value: string) => void) | undefined;
|
||||
const fileContent = new Promise<string>((resolve) => {
|
||||
resolveFile = resolve;
|
||||
});
|
||||
const attachment = {
|
||||
name: "slow.txt",
|
||||
type: "text/plain",
|
||||
size: 5,
|
||||
lastModified: 1,
|
||||
text: vi.fn(async () => await fileContent),
|
||||
} as unknown as File;
|
||||
const sends: Array<{
|
||||
prompt?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
}> = [];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| {
|
||||
action?: string;
|
||||
prompt?: string;
|
||||
delivery?: string;
|
||||
sessionId?: string;
|
||||
config?: { sessionId?: string };
|
||||
}
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
sends.push(request);
|
||||
return request.delivery === "queue"
|
||||
? { ok: true, queued: true, promptsInQueue: [] }
|
||||
: {
|
||||
ok: true,
|
||||
result: { text: "Done", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let firstSend: Promise<void> | undefined;
|
||||
let secondSend: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
firstSend = current.sendPrompt("First prompt", [attachment]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
secondSend = current.sendPrompt("Second prompt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(sends).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
resolveFile?.("hello");
|
||||
await Promise.all([firstSend, secondSend]);
|
||||
});
|
||||
expect(sends.map(({ prompt, delivery }) => ({ prompt, delivery }))).toEqual(
|
||||
[
|
||||
{ prompt: "First prompt", delivery: undefined },
|
||||
{ prompt: "Second prompt", delivery: "queue" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a fresh session when a cold start fails and the user retries", async () => {
|
||||
let startAttempts = 0;
|
||||
const actions: string[] = [];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
actions.push(request?.action ?? "unknown");
|
||||
if (request?.action === "start") {
|
||||
startAttempts += 1;
|
||||
if (startAttempts === 1) throw new Error("start failed");
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: { text: "Recovered", finishReason: "completed" },
|
||||
};
|
||||
}
|
||||
return { promptsInQueue: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.sendPrompt("First attempt"));
|
||||
expect(current.status).toBe("error");
|
||||
await act(async () => current.sendPrompt("Retry"));
|
||||
|
||||
expect(actions.filter((action) => action !== "pending_prompts")).toEqual([
|
||||
"start",
|
||||
"start",
|
||||
"send",
|
||||
]);
|
||||
expect(
|
||||
current.messages.some((message) => message.content === "Recovered"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to process context when the remembered workspace is stale", async () => {
|
||||
await act(async () => root.unmount());
|
||||
window.localStorage.setItem(
|
||||
"cline.code.workspace-selection.v1",
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/workspace/deleted",
|
||||
workspaces: ["/workspace/deleted"],
|
||||
}),
|
||||
);
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "validate_workspace_directory") {
|
||||
return { valid: false };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/cline");
|
||||
expect(current.config.cwd).toBe("/workspace/cline");
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
|
||||
path: "/workspace/deleted",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a remembered workspace that becomes available while process context is loading", async () => {
|
||||
await act(async () => root.unmount());
|
||||
let resolveContext:
|
||||
| ((value: { cwd: string; workspaceRoot: string }) => void)
|
||||
| undefined;
|
||||
const contextResponse = new Promise<{
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}>((resolve) => {
|
||||
resolveContext = resolve;
|
||||
});
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return await contextResponse;
|
||||
}
|
||||
if (command === "validate_workspace_directory") {
|
||||
return { valid: true };
|
||||
}
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
"cline.code.workspace-selection.v1",
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/workspace/remembered",
|
||||
workspaces: ["/workspace/remembered"],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveContext?.({
|
||||
cwd: "/workspace/default",
|
||||
workspaceRoot: "/workspace/default",
|
||||
});
|
||||
await contextResponse;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/remembered");
|
||||
expect(current.config.cwd).toBe("/workspace/remembered");
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
|
||||
path: "/workspace/remembered",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a workspace selected while process context is loading", async () => {
|
||||
await act(async () => root.unmount());
|
||||
let resolveContext:
|
||||
| ((value: { cwd: string; workspaceRoot: string }) => void)
|
||||
| undefined;
|
||||
const contextResponse = new Promise<{
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}>((resolve) => {
|
||||
resolveContext = resolve;
|
||||
});
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") return await contextResponse;
|
||||
return [];
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<HookHarness />));
|
||||
await act(async () => {
|
||||
current.setConfig((previous) => ({
|
||||
...previous,
|
||||
workspaceRoot: "/workspace/selected",
|
||||
cwd: "/workspace/selected",
|
||||
}));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveContext?.({
|
||||
cwd: "/workspace/default",
|
||||
workspaceRoot: "/workspace/default",
|
||||
});
|
||||
await contextResponse;
|
||||
});
|
||||
expect(current.config.workspaceRoot).toBe("/workspace/selected");
|
||||
expect(current.config.cwd).toBe("/workspace/selected");
|
||||
});
|
||||
});
|
||||
@@ -34,15 +34,19 @@ import {
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildSessionDiffState,
|
||||
type SessionHookEvent,
|
||||
EMPTY_DIFF_SUMMARY,
|
||||
type SessionDiffSummary,
|
||||
type SessionFileDiff,
|
||||
type SessionHookEvent,
|
||||
} from "@/lib/session-diff";
|
||||
import type {
|
||||
SessionHistoryItem,
|
||||
SessionHistoryStatus,
|
||||
} from "@/lib/session-history";
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
|
||||
|
||||
@@ -240,6 +244,9 @@ export function useChatSession() {
|
||||
null,
|
||||
);
|
||||
const hydrationRequestIdRef = useRef(0);
|
||||
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const activePromptSubmissionsRef = useRef(0);
|
||||
const [chatTransportState, setChatTransportState] =
|
||||
useState<ChatTransportState>(desktopClient.getTransportState());
|
||||
const [chatTransportError, setChatTransportError] = useState<string | null>(
|
||||
@@ -468,11 +475,33 @@ export function useChatSession() {
|
||||
const ctx = await desktopClient.invoke<ProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
workspaceRoot: ctx.workspaceRoot || ctx.cwd,
|
||||
cwd: ctx.workspaceRoot || ctx.cwd,
|
||||
}));
|
||||
const rememberedWorkspace =
|
||||
readWorkspaceSelectionFromWindow().lastWorkspace;
|
||||
const validation = rememberedWorkspace
|
||||
? await desktopClient
|
||||
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
|
||||
path: rememberedWorkspace,
|
||||
})
|
||||
.catch(() => ({ valid: false }))
|
||||
: { valid: false };
|
||||
setConfig((prev) => {
|
||||
const currentWorkspace = (prev.workspaceRoot || prev.cwd || "").trim();
|
||||
const selectionChangedWhileLoading = Boolean(
|
||||
currentWorkspace &&
|
||||
normalizeWorkspacePath(currentWorkspace) !==
|
||||
normalizeWorkspacePath(rememberedWorkspace),
|
||||
);
|
||||
const workspace = selectionChangedWhileLoading
|
||||
? currentWorkspace
|
||||
: validation.valid === true
|
||||
? rememberedWorkspace
|
||||
: ctx.workspaceRoot || ctx.cwd;
|
||||
return {
|
||||
...prev,
|
||||
workspaceRoot: workspace,
|
||||
cwd: workspace,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Ignore in non-Tauri mode.
|
||||
}
|
||||
@@ -914,7 +943,10 @@ export function useChatSession() {
|
||||
// ---- Shared: start a new session via RPC ----
|
||||
|
||||
const startSession = useCallback(
|
||||
async (validatedConfig: ChatSessionConfig): Promise<string> => {
|
||||
async (
|
||||
validatedConfig: ChatSessionConfig,
|
||||
options: { preserveStatus?: boolean } = {},
|
||||
): Promise<string> => {
|
||||
const payload = await postSession({
|
||||
action: "start",
|
||||
config: validatedConfig,
|
||||
@@ -925,7 +957,9 @@ export function useChatSession() {
|
||||
// Mark idle — not running — so the first sendPrompt is not queued.
|
||||
// The status transitions to "starting"/"running" once a prompt is
|
||||
// actually dispatched.
|
||||
setStatus("idle");
|
||||
if (!options.preserveStatus) {
|
||||
setStatus("idle");
|
||||
}
|
||||
setConfig(validatedConfig);
|
||||
setHydratedHistorySessionId(null);
|
||||
return id;
|
||||
@@ -987,7 +1021,8 @@ export function useChatSession() {
|
||||
setIsHydratingSession(false);
|
||||
abortedRef.current = false;
|
||||
clearAbortFallbackTimeout();
|
||||
let activeSessionId = sessionId;
|
||||
const pendingSessionStart = sessionStartPromiseRef.current;
|
||||
let activeSessionId = sessionId ?? activeSessionIdRef.current;
|
||||
|
||||
const validation = validateConfig(config);
|
||||
if (!validation.parsed) {
|
||||
@@ -995,51 +1030,67 @@ export function useChatSession() {
|
||||
return;
|
||||
}
|
||||
const parsed = validation.parsed;
|
||||
|
||||
if (activeSessionId && hydratedHistorySessionId === activeSessionId) {
|
||||
try {
|
||||
activeSessionId = await startSession({
|
||||
...parsed,
|
||||
sessionId: activeSessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
return;
|
||||
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
|
||||
activePromptSubmissionsRef.current += 1;
|
||||
let promptSubmissionFinished = false;
|
||||
const finishPromptSubmission = () => {
|
||||
if (promptSubmissionFinished) return;
|
||||
promptSubmissionFinished = true;
|
||||
activePromptSubmissionsRef.current = Math.max(
|
||||
0,
|
||||
activePromptSubmissionsRef.current - 1,
|
||||
);
|
||||
};
|
||||
const precedingPromptDispatch = promptDispatchTailRef.current;
|
||||
let resolvePromptDispatch: (() => void) | undefined;
|
||||
const ownPromptDispatch = new Promise<void>((resolve) => {
|
||||
resolvePromptDispatch = resolve;
|
||||
});
|
||||
const promptDispatchTail = precedingPromptDispatch.then(
|
||||
() => ownPromptDispatch,
|
||||
);
|
||||
promptDispatchTailRef.current = promptDispatchTail;
|
||||
let promptDispatchReleased = false;
|
||||
const releasePromptDispatch = () => {
|
||||
if (promptDispatchReleased) return;
|
||||
promptDispatchReleased = true;
|
||||
resolvePromptDispatch?.();
|
||||
};
|
||||
void promptDispatchTail.then(() => {
|
||||
if (promptDispatchTailRef.current === promptDispatchTail) {
|
||||
promptDispatchTailRef.current = Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
activeSessionId = await startSession(parsed);
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
const now = Date.now();
|
||||
const shouldQueue = Boolean(activeSessionId) && BUSY_STATUSES.has(status);
|
||||
const serializedAttachments = await serializeAttachments(attachedFiles);
|
||||
const hasAttachments =
|
||||
serializedAttachments.userImages.length > 0 ||
|
||||
serializedAttachments.userFiles.length > 0;
|
||||
|
||||
const userLabel = hasAttachments
|
||||
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
|
||||
: trimmed;
|
||||
const serializedAttachmentsTask = serializeAttachments(
|
||||
attachedFiles,
|
||||
).then(
|
||||
(attachments) => ({ ok: true as const, attachments }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
const userLabel =
|
||||
attachedFiles.length > 0
|
||||
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
|
||||
: trimmed;
|
||||
const shouldQueue =
|
||||
Boolean(activeSessionId) &&
|
||||
(hasEarlierPromptSubmission ||
|
||||
Boolean(pendingSessionStart) ||
|
||||
BUSY_STATUSES.has(status));
|
||||
const optimisticQueuedPromptId = shouldQueue
|
||||
? makeId("queued_prompt")
|
||||
: null;
|
||||
const plannedSessionId = activeSessionId ?? makeId("session");
|
||||
|
||||
if (!shouldQueue) {
|
||||
addMessage({
|
||||
id: makeId("user"),
|
||||
sessionId: activeSessionId,
|
||||
sessionId: plannedSessionId,
|
||||
role: "user",
|
||||
content: userLabel,
|
||||
createdAt: now,
|
||||
});
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
activeSessionIdRef.current = plannedSessionId;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
@@ -1054,8 +1105,90 @@ export function useChatSession() {
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
let sendTask: ReturnType<typeof postSession> | null = null;
|
||||
try {
|
||||
const payload = await postSession({
|
||||
if (pendingSessionStart) {
|
||||
try {
|
||||
activeSessionId = await pendingSessionStart;
|
||||
} catch (err) {
|
||||
if (optimisticQueuedPromptId) {
|
||||
setPromptsInQueue((prev) =>
|
||||
prev.filter((item) => item.id !== optimisticQueuedPromptId),
|
||||
);
|
||||
}
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
activeSessionId &&
|
||||
hydratedHistorySessionId === activeSessionId
|
||||
) {
|
||||
const startPromise = startSession(
|
||||
{
|
||||
...parsed,
|
||||
sessionId: activeSessionId,
|
||||
},
|
||||
{ preserveStatus: true },
|
||||
);
|
||||
sessionStartPromiseRef.current = startPromise;
|
||||
try {
|
||||
activeSessionId = await startPromise;
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
const startPromise = startSession(
|
||||
{
|
||||
...parsed,
|
||||
sessionId: plannedSessionId,
|
||||
},
|
||||
{ preserveStatus: true },
|
||||
);
|
||||
sessionStartPromiseRef.current = startPromise;
|
||||
try {
|
||||
activeSessionId = await startPromise;
|
||||
} catch (err) {
|
||||
if (activeSessionIdRef.current === plannedSessionId) {
|
||||
activeSessionIdRef.current = null;
|
||||
}
|
||||
setErrorState(errorMessage(err));
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const serializedAttachmentsResult = await serializedAttachmentsTask;
|
||||
if (!serializedAttachmentsResult.ok) {
|
||||
setErrorState(
|
||||
errorMessage(serializedAttachmentsResult.error),
|
||||
activeSessionId,
|
||||
);
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
const serializedAttachments = serializedAttachmentsResult.attachments;
|
||||
const hasAttachments =
|
||||
serializedAttachments.userImages.length > 0 ||
|
||||
serializedAttachments.userFiles.length > 0;
|
||||
if (!shouldQueue) {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
setStatus("starting");
|
||||
}
|
||||
await precedingPromptDispatch;
|
||||
sendTask = postSession({
|
||||
action: "send",
|
||||
sessionId: activeSessionId,
|
||||
prompt: trimmed,
|
||||
@@ -1063,6 +1196,15 @@ export function useChatSession() {
|
||||
config: parsed,
|
||||
attachments: hasAttachments ? serializedAttachments : undefined,
|
||||
});
|
||||
} finally {
|
||||
releasePromptDispatch();
|
||||
}
|
||||
if (!sendTask) {
|
||||
finishPromptSubmission();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await sendTask;
|
||||
if (payload.ok && payload.queued) {
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
setStatus("running");
|
||||
@@ -1266,6 +1408,7 @@ export function useChatSession() {
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
}
|
||||
finishPromptSubmission();
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1431,6 +1574,7 @@ export function useChatSession() {
|
||||
sessionId: undefined,
|
||||
}));
|
||||
activeSessionIdRef.current = null;
|
||||
sessionStartPromiseRef.current = null;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
setHydratedHistorySessionId(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SessionThread {
|
||||
id: string;
|
||||
title: string;
|
||||
codebase: string;
|
||||
workspacePath: string;
|
||||
time: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -224,10 +225,12 @@ function inferStatusFromMessages(
|
||||
}
|
||||
|
||||
function toThread(session: SessionHistoryItem): SessionThread {
|
||||
const workspacePath = (session.workspaceRoot || session.cwd).trim();
|
||||
return {
|
||||
id: session.sessionId,
|
||||
title: toTitle(session),
|
||||
codebase: basenamePath(session.workspaceRoot || session.cwd),
|
||||
codebase: basenamePath(workspacePath),
|
||||
workspacePath,
|
||||
time: formatRelativeTime(session.endedAt || session.startedAt),
|
||||
provider: session.provider || "",
|
||||
model: session.model || "",
|
||||
@@ -355,6 +358,7 @@ function areThreadsEquivalent(
|
||||
a.id !== b.id ||
|
||||
a.title !== b.title ||
|
||||
a.codebase !== b.codebase ||
|
||||
a.workspacePath !== b.workspacePath ||
|
||||
a.time !== b.time ||
|
||||
a.provider !== b.provider ||
|
||||
a.model !== b.model ||
|
||||
@@ -1222,6 +1226,10 @@ export function useSessionHistory({
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
const loadOlderSessions = useCallback(
|
||||
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
|
||||
[loadMoreSessions],
|
||||
);
|
||||
|
||||
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
|
||||
const sessionById = useMemo(
|
||||
@@ -1233,6 +1241,7 @@ export function useSessionHistory({
|
||||
getSessionByThreadId,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread,
|
||||
|
||||
@@ -35,8 +35,9 @@ let resolvedEndpointCache: string | null = null;
|
||||
* or an integration test harness.
|
||||
* 2. Tauri `get_desktop_backend_endpoint` command — used when running inside
|
||||
* the full Tauri app shell.
|
||||
* 3. Fallback to `ws://127.0.0.1:3126/transport` — the sidecar's default port
|
||||
* when running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
|
||||
* 3. `NEXT_PUBLIC_SIDECAR_WS_ENDPOINT` (inlined at build time), then fallback
|
||||
* to `ws://127.0.0.1:3126/transport` — the sidecar's default port when
|
||||
* running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
|
||||
*/
|
||||
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
if (resolvedEndpointCache) return resolvedEndpointCache;
|
||||
@@ -64,8 +65,10 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
|
||||
throw new Error("Tauri returned an empty desktop backend endpoint");
|
||||
}
|
||||
|
||||
// 3. Default sidecar port for local dev mode without the Tauri bridge.
|
||||
resolvedEndpointCache = "ws://127.0.0.1:3126/transport";
|
||||
// 3. Env override, then default sidecar port for local dev mode without
|
||||
// the Tauri bridge.
|
||||
const envEndpoint = process.env.NEXT_PUBLIC_SIDECAR_WS_ENDPOINT?.trim();
|
||||
resolvedEndpointCache = envEndpoint || "ws://127.0.0.1:3126/transport";
|
||||
return resolvedEndpointCache;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import {
|
||||
groupThreadsByProject,
|
||||
workspaceDisplayName,
|
||||
} from "./sidebar-session-organization";
|
||||
|
||||
function thread(
|
||||
id: string,
|
||||
workspacePath: string,
|
||||
overrides: Partial<SessionThread> = {},
|
||||
): SessionThread {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
codebase: workspaceDisplayName(workspacePath),
|
||||
workspacePath,
|
||||
time: "now",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar session organization", () => {
|
||||
it("groups every loaded thread before applying per-project visibility", () => {
|
||||
const threads = [
|
||||
...Array.from({ length: 12 }, (_, index) =>
|
||||
thread(`alpha-${index + 1}`, "/work/acme/repo"),
|
||||
),
|
||||
thread("beta-1", "/work/other/repo"),
|
||||
];
|
||||
|
||||
const groups = groupThreadsByProject(threads);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
"acme/repo",
|
||||
"other/repo",
|
||||
]);
|
||||
expect(groups[0]?.threads).toHaveLength(12);
|
||||
expect(groups[1]?.threads.map((item) => item.id)).toEqual(["beta-1"]);
|
||||
});
|
||||
|
||||
it("uses the repository directory instead of the full workspace path", () => {
|
||||
expect(workspaceDisplayName("/Users/saoud/code/cline/")).toBe("cline");
|
||||
expect(workspaceDisplayName("C:\\Users\\saoud\\code\\cline\\")).toBe(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
export const INITIAL_VISIBLE_THREAD_COUNT = 10;
|
||||
|
||||
export type SidebarProjectGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
workspacePath: string;
|
||||
threads: SessionThread[];
|
||||
};
|
||||
|
||||
export function workspaceDisplayName(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return "";
|
||||
const segments = trimmed.split(/[\\/]/).filter(Boolean);
|
||||
return segments.at(-1) || trimmed;
|
||||
}
|
||||
|
||||
function uniqueWorkspaceLabel(path: string, workspacePaths: string[]): string {
|
||||
if (!path) return "Other";
|
||||
const segments = path
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean);
|
||||
const allSegments = workspacePaths.map((workspacePath) =>
|
||||
workspacePath
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (let depth = 1; depth <= segments.length; depth += 1) {
|
||||
const candidate = segments.slice(-depth).join("/");
|
||||
const matches = allSegments.filter(
|
||||
(other) => other.slice(-depth).join("/") === candidate,
|
||||
).length;
|
||||
if (matches === 1) return candidate;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function groupThreadsByProject(
|
||||
threads: SessionThread[],
|
||||
): SidebarProjectGroup[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ workspacePath: string; threads: SessionThread[] }
|
||||
>();
|
||||
for (const thread of threads) {
|
||||
const workspacePath = thread.workspacePath.trim();
|
||||
const projectId = normalizeWorkspacePath(workspacePath) || "__other__";
|
||||
const current = groups.get(projectId);
|
||||
if (current) current.threads.push(thread);
|
||||
else groups.set(projectId, { workspacePath, threads: [thread] });
|
||||
}
|
||||
const workspacePaths = [...groups.values()].map(
|
||||
(group) => group.workspacePath,
|
||||
);
|
||||
return [...groups.entries()].map(([id, group]) => ({
|
||||
id,
|
||||
label: uniqueWorkspaceLabel(group.workspacePath, workspacePaths),
|
||||
workspacePath: group.workspacePath,
|
||||
threads: group.threads,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeWorkspacePaths,
|
||||
normalizeWorkspacePath,
|
||||
parseWorkspaceSelectionStorage,
|
||||
workspacePathsFromSessions,
|
||||
} from "./workspace-paths";
|
||||
|
||||
describe("workspace paths", () => {
|
||||
it("normalizes trailing separators and Windows path casing", () => {
|
||||
expect(normalizeWorkspacePath(" /workspace/cline/ ")).toBe(
|
||||
"/workspace/cline",
|
||||
);
|
||||
expect(normalizeWorkspacePath("C:\\Users\\Saoud\\Cline\\")).toBe(
|
||||
"c:\\users\\saoud\\cline",
|
||||
);
|
||||
expect(normalizeWorkspacePath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("retains known projects when discovery returns an incomplete subset", () => {
|
||||
const known = ["/projects/a", "/projects/b", "/projects/c", "/projects/d"];
|
||||
const afterFirstPick = mergeWorkspacePaths(known, [
|
||||
"/projects/e",
|
||||
"/projects/a/",
|
||||
]);
|
||||
const afterSecondPick = mergeWorkspacePaths(afterFirstPick, [
|
||||
"/projects/f",
|
||||
"/projects/b",
|
||||
]);
|
||||
|
||||
expect(afterFirstPick).toEqual([
|
||||
"/projects/a",
|
||||
"/projects/b",
|
||||
"/projects/c",
|
||||
"/projects/d",
|
||||
"/projects/e",
|
||||
]);
|
||||
expect(afterSecondPick).toEqual([
|
||||
"/projects/a",
|
||||
"/projects/b",
|
||||
"/projects/c",
|
||||
"/projects/d",
|
||||
"/projects/e",
|
||||
"/projects/f",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds the project catalog from every loaded history workspace", () => {
|
||||
const sessions = Array.from({ length: 25 }, (_, index) => ({
|
||||
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
|
||||
}));
|
||||
sessions.push({ workspaceRoot: "/projects/project-01/" });
|
||||
|
||||
const paths = workspacePathsFromSessions(sessions);
|
||||
|
||||
expect(paths).toHaveLength(25);
|
||||
expect(paths).toContain("/projects/project-25");
|
||||
});
|
||||
|
||||
it("restores the selected project and catalog across thread remounts", () => {
|
||||
expect(
|
||||
parseWorkspaceSelectionStorage(
|
||||
JSON.stringify({
|
||||
lastWorkspace: "/projects/selected/",
|
||||
workspaces: ["/projects/one", "/projects/selected"],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
lastWorkspace: "/projects/selected/",
|
||||
workspaces: ["/projects/one", "/projects/selected"],
|
||||
});
|
||||
expect(parseWorkspaceSelectionStorage("not json")).toEqual({
|
||||
lastWorkspace: "",
|
||||
workspaces: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
export const WORKSPACE_SELECTION_STORAGE_KEY =
|
||||
"cline.code.workspace-selection.v1";
|
||||
|
||||
export type WorkspaceSelectionStorage = {
|
||||
lastWorkspace: string;
|
||||
workspaces: string[];
|
||||
};
|
||||
|
||||
export type WorkspacePathSource = {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
export function normalizeWorkspacePath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/, "");
|
||||
const normalized = withoutTrailingSeparators || trimmed[0] || "";
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function mergeWorkspacePaths(
|
||||
...pathGroups: ReadonlyArray<readonly string[]>
|
||||
): string[] {
|
||||
const byNormalizedPath = new Map<string, string>();
|
||||
for (const paths of pathGroups) {
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim();
|
||||
const normalized = normalizeWorkspacePath(trimmed);
|
||||
if (normalized && !byNormalizedPath.has(normalized)) {
|
||||
byNormalizedPath.set(normalized, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function workspacePathsFromSessions(
|
||||
sessions: readonly WorkspacePathSource[],
|
||||
): string[] {
|
||||
return mergeWorkspacePaths(
|
||||
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseWorkspaceSelectionStorage(
|
||||
raw: string | null,
|
||||
): WorkspaceSelectionStorage {
|
||||
if (!raw) {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
lastWorkspace?: unknown;
|
||||
workspaces?: unknown;
|
||||
};
|
||||
const lastWorkspace =
|
||||
typeof parsed?.lastWorkspace === "string"
|
||||
? parsed.lastWorkspace.trim()
|
||||
: "";
|
||||
const workspaces = Array.isArray(parsed?.workspaces)
|
||||
? parsed.workspaces.filter(
|
||||
(workspace): workspace is string => typeof workspace === "string",
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
lastWorkspace,
|
||||
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
|
||||
};
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage {
|
||||
if (typeof window === "undefined") {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
try {
|
||||
return parseWorkspaceSelectionStorage(
|
||||
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return { lastWorkspace: "", workspaces: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkspaceSelectionToWindow(
|
||||
value: WorkspaceSelectionStorage,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastWorkspace: value.lastWorkspace.trim(),
|
||||
workspaces: mergeWorkspacePaths(value.workspaces, [
|
||||
value.lastWorkspace,
|
||||
]),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Keep workspace switching functional when storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -11,6 +11,10 @@ const nextConfig = {
|
||||
turbopack: {
|
||||
root: workspaceRoot,
|
||||
},
|
||||
// Dev-only: Next blocks HMR/font/dev-resource requests from origins that
|
||||
// don't match the dev server's own hostname. Both loopback spellings are
|
||||
// legitimate ways to reach a local or port-forwarded dev server.
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1"],
|
||||
reactStrictMode: true,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 847 B |
@@ -0,0 +1,10 @@
|
||||
# Cline web visual foundation
|
||||
|
||||
The shared visual contract now lives in the internal
|
||||
[`@cline/ui`](../../../../../sdk/packages/ui/README.md) workspace package
|
||||
instead of beside the desktop app.
|
||||
|
||||
The desktop imports the complete `@cline/ui/theme/index.css` entry point. Other
|
||||
Cline surfaces can import `@cline/ui/theme/tokens.css` without React or
|
||||
Tailwind, or compose the Tailwind adapter and optional base styles in order.
|
||||
Consuming apps still own their font files and shell-specific layout.
|
||||
@@ -1,125 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import postcss from "postcss";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("@cline/ui theme integration", () => {
|
||||
it("compiles the shared theme and standard Tailwind utilities", async () => {
|
||||
const from = fileURLToPath(new URL("./theme-fixture.css", import.meta.url));
|
||||
const result = await postcss([tailwindcss()]).process(
|
||||
[
|
||||
'@import "tailwindcss";',
|
||||
'@import "@cline/ui/theme/index.css";',
|
||||
'@source inline("bg-background bg-primary-emphasis font-sans text-xs");',
|
||||
].join("\n"),
|
||||
{ from },
|
||||
);
|
||||
|
||||
expect(result.css).toContain("--text-xs: 12px");
|
||||
expect(result.css).toContain("--font-weight-normal: 480");
|
||||
expect(result.css).toContain('--font-sans: "Schibsted Grotesk Variable"');
|
||||
expect(result.css).toContain("--primary-emphasis:");
|
||||
expect(result.css).toContain(".bg-background");
|
||||
expect(result.css).toContain(".bg-primary-emphasis");
|
||||
expect(result.css).toContain(".font-sans");
|
||||
expect(result.css).toContain(".text-xs");
|
||||
expect(result.css).toContain(
|
||||
"letter-spacing: var(--tw-tracking, var(--text-xs--letter-spacing))",
|
||||
);
|
||||
expect(result.css).not.toContain("--cline-");
|
||||
});
|
||||
});
|
||||
@@ -54,9 +54,9 @@ The extension also keeps track of total tokens and API usage cost for the entire
|
||||
|
||||
### Run Commands in Terminal
|
||||
|
||||
Thanks to the new [shell integration updates in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api), Cline can execute commands directly in your terminal and receive the output. This allows him to perform a wide range of tasks, from installing packages and running build scripts to deploying applications, managing databases, and executing tests, all while adapting to your dev environment & toolchain to get the job done right.
|
||||
Cline can execute commands and receive their output to install packages, run build scripts, deploy applications, manage databases, and run tests, all while adapting to your dev environment & toolchain to get the job done right.
|
||||
|
||||
For long running processes like dev servers, use the "Proceed While Running" button to let Cline continue in the task while the command runs in the background. As Cline works he’ll be notified of any new terminal output along the way, letting him react to issues that may come up, such as compile-time errors when editing files.
|
||||
By default, commands run in a background process, which works everywhere without extra setup. You can switch to running commands in a visible VS Code terminal instead (Settings → Terminal → Terminal Execution Mode), which uses the [shell integration API introduced in VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) to stream a command's output as it runs and lets you watch or interact with it directly.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ const esbuildProblemMatcherPlugin = {
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
|
||||
// Always inline these values so ordinary builds cannot be mislabeled by a
|
||||
// user's runtime environment. Only the combined rollout workflow sets them.
|
||||
"process.env.CLINE_ROLLOUT_VARIANT": JSON.stringify(process.env.CLINE_ROLLOUT_VARIANT || ""),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
@@ -145,6 +148,7 @@ const baseConfig = {
|
||||
format: "cjs",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node22.15",
|
||||
banner: {
|
||||
js: "const _importMetaUrl=require('url').pathToFileURL(__filename)",
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user