mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
feat(desktop): use the shared Cline Hub runtime (#12508)
* feat(desktop): use the shared Cline Hub runtime * fix(hub): group code-sidecar-observer clients under Code App The desktop observer client type was renamed from code-sidecar-approvals to code-sidecar-observer, but the Code App grouping matchers in the hub dashboard and menubar sidecar still only matched the old type. Since the observer now registers on the shared Hub, it showed up as a separate ungrouped client. Keep the old type matched for older desktop builds. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
|
||||
@@ -100,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
|
||||
Startup flow:
|
||||
|
||||
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
|
||||
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
|
||||
2. The desktop backend starts the Bun sidecar, which discovers or starts the
|
||||
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
|
||||
for desktop commands, queries, and pushed events.
|
||||
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
|
||||
4. Tool approval updates are pushed from the backend instead of polled from the UI.
|
||||
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
|
||||
@@ -121,8 +123,8 @@ Desktop transport envelope:
|
||||
## Key Files
|
||||
|
||||
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
|
||||
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
|
||||
@@ -157,5 +159,7 @@ Logging can be configured with the same environment variables as the CLI:
|
||||
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
|
||||
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
|
||||
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
|
||||
The next desktop or CLI Hub connection will reuse a compatible running Hub or
|
||||
replace an incompatible one through the shared discovery path.
|
||||
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The sidecar is a single Bun process that handles the desktop backend runtime directly.
|
||||
The sidecar is a Bun process that adapts the desktop UI and native operations to
|
||||
the shared Cline Hub.
|
||||
|
||||
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
|
||||
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
|
||||
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
|
||||
sidecar does not own a private agent runtime Hub.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,7 +17,7 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # In-process chat session management
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Chat Sessions — In-Process via LocalRuntimeHost
|
||||
### 1. Chat Sessions — Shared Hub Client
|
||||
|
||||
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
|
||||
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
|
||||
the same compatible Hub discovered by the CLI or starts the canonical detached
|
||||
Hub when the desktop is the first client:
|
||||
|
||||
```typescript
|
||||
import { LocalRuntimeHost } from "@cline/core";
|
||||
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
// Push approval request to frontend via WebSocket event
|
||||
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Tool Approval — In-Memory Promise Resolution
|
||||
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
No more file-system watchers. Tool approvals use in-memory promise maps:
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
session. Desktop approvals use in-memory promise maps while the webview is
|
||||
online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
|
||||
|
||||
### 5. Routine Schedules — Direct Hub Commands
|
||||
|
||||
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
|
||||
Routine operations use the same connected Hub client as chat session
|
||||
observation. They never start a second in-process Hub:
|
||||
|
||||
```typescript
|
||||
import { ensureHubServer, sendHubCommand } from "@cline/core";
|
||||
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
|
||||
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
|
||||
await ctx.hubClient.command("schedule.list", { limit: 200 });
|
||||
```
|
||||
|
||||
### 6. Native Commands
|
||||
@@ -122,7 +138,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | `LocalRuntimeHost` in-process |
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
@@ -144,7 +160,7 @@ Supported commands:
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | local hub schedule commands |
|
||||
| `list_routine_schedules` | shared Hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
| `open_mcp_settings_file` | OS `open` command |
|
||||
|
||||
@@ -18,16 +18,12 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
ensureCustomProvidersLoaded,
|
||||
ensureHubServer,
|
||||
executeClineAccountAction,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
@@ -43,7 +39,6 @@ import {
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
@@ -65,7 +60,11 @@ import {
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
|
||||
import {
|
||||
broadcastEvent,
|
||||
ensureSharedHubClient,
|
||||
resolveSidecarAskQuestion,
|
||||
} from "./context";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -553,33 +552,16 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
}
|
||||
|
||||
async function handleRoutineScheduleCommand(
|
||||
ctx: SidecarContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
let useLocalScheduleService = false;
|
||||
try {
|
||||
if (!useLocalScheduleService) {
|
||||
await ensureHubServer({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
useLocalScheduleService = true;
|
||||
}
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const clientCommand = async (
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => {
|
||||
const reply = useLocalScheduleService
|
||||
? await localRoutineScheduleCommand(hubCommand, payload)
|
||||
: await sendHubCommand(
|
||||
{},
|
||||
{
|
||||
clientId: "code-sidecar-routines",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
);
|
||||
const reply = await hubClient.command(hubCommand as never, payload);
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -587,186 +569,155 @@ async function handleRoutineScheduleCommand(
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns, lastExecutions] =
|
||||
await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.list_executions", { limit: 50 }),
|
||||
]);
|
||||
const scheduleRecords = (schedules.schedules ?? []) as JsonRecord[];
|
||||
const executionRecords = (lastExecutions.executions ??
|
||||
[]) as JsonRecord[];
|
||||
// The bulk query returns the newest executions across ALL schedules,
|
||||
// so a few chatty schedules can evict everyone else's latest run.
|
||||
// Backfill the latest execution for schedules that have run
|
||||
// (lastRunAt set) but fell out of that window.
|
||||
const covered = new Set<string>();
|
||||
for (const execution of executionRecords) {
|
||||
if (typeof execution.scheduleId === "string") {
|
||||
covered.add(execution.scheduleId);
|
||||
}
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns, lastExecutions] =
|
||||
await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.list_executions", { limit: 50 }),
|
||||
]);
|
||||
const scheduleRecords = (schedules.schedules ?? []) as JsonRecord[];
|
||||
const executionRecords = (lastExecutions.executions ?? []) as JsonRecord[];
|
||||
// The bulk query returns the newest executions across ALL schedules,
|
||||
// so a few chatty schedules can evict everyone else's latest run.
|
||||
// Backfill the latest execution for schedules that have run
|
||||
// (lastRunAt set) but fell out of that window.
|
||||
const covered = new Set<string>();
|
||||
for (const execution of executionRecords) {
|
||||
if (typeof execution.scheduleId === "string") {
|
||||
covered.add(execution.scheduleId);
|
||||
}
|
||||
const missing = scheduleRecords.filter(
|
||||
(schedule) =>
|
||||
typeof schedule.scheduleId === "string" &&
|
||||
schedule.lastRunAt != null &&
|
||||
!covered.has(schedule.scheduleId),
|
||||
);
|
||||
const concurrency = 8;
|
||||
for (let index = 0; index < missing.length; index += concurrency) {
|
||||
const chunk = missing.slice(index, index + concurrency);
|
||||
const replies = await Promise.all(
|
||||
chunk.map((schedule) =>
|
||||
clientCommand("schedule.list_executions", {
|
||||
scheduleId: schedule.scheduleId,
|
||||
limit: 1,
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
for (const reply of replies) {
|
||||
const executions = (reply?.executions ?? []) as JsonRecord[];
|
||||
if (executions[0]) {
|
||||
executionRecords.push(executions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
schedules: scheduleRecords,
|
||||
activeExecutions: activeExecutions.executions ?? [],
|
||||
upcomingRuns: upcomingRuns.runs ?? [],
|
||||
lastExecutions: executionRecords,
|
||||
};
|
||||
}
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const workspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !timing || !prompt || !workspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: readHubScheduleMode(args, "yolo"),
|
||||
workspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
maxIterations: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: asTrimmedStringArray(args?.tags),
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const mode = readHubScheduleMode(args);
|
||||
const name = asTrimmedString(args?.name);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const workspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !timing || !prompt || !workspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
workspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
args?.system_prompt === null
|
||||
? null
|
||||
: asTrimmedString(args?.system_prompt),
|
||||
maxIterations:
|
||||
args?.max_iterations === null
|
||||
? null
|
||||
: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds:
|
||||
args?.timeout_seconds === null
|
||||
? null
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: asTrimmedStringArray(args?.tags) ?? [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
// wait: false queues the run and returns immediately; the default
|
||||
// path blocks until the whole agent run finishes, which outlives the
|
||||
// webview's request timeout.
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
let localRoutineScheduleService: HubScheduleService | undefined;
|
||||
let localRoutineScheduleCommands: HubScheduleCommandService | undefined;
|
||||
|
||||
function getLocalRoutineScheduleCommands(): HubScheduleCommandService {
|
||||
if (!localRoutineScheduleService || !localRoutineScheduleCommands) {
|
||||
localRoutineScheduleService = new HubScheduleService({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
localRoutineScheduleCommands = new HubScheduleCommandService(
|
||||
localRoutineScheduleService,
|
||||
const missing = scheduleRecords.filter(
|
||||
(schedule) =>
|
||||
typeof schedule.scheduleId === "string" &&
|
||||
schedule.lastRunAt != null &&
|
||||
!covered.has(schedule.scheduleId),
|
||||
);
|
||||
const concurrency = 8;
|
||||
for (let index = 0; index < missing.length; index += concurrency) {
|
||||
const chunk = missing.slice(index, index + concurrency);
|
||||
const replies = await Promise.all(
|
||||
chunk.map((schedule) =>
|
||||
clientCommand("schedule.list_executions", {
|
||||
scheduleId: schedule.scheduleId,
|
||||
limit: 1,
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
for (const reply of replies) {
|
||||
const executions = (reply?.executions ?? []) as JsonRecord[];
|
||||
if (executions[0]) {
|
||||
executionRecords.push(executions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
schedules: scheduleRecords,
|
||||
activeExecutions: activeExecutions.executions ?? [],
|
||||
upcomingRuns: upcomingRuns.runs ?? [],
|
||||
lastExecutions: executionRecords,
|
||||
};
|
||||
}
|
||||
return localRoutineScheduleCommands;
|
||||
}
|
||||
|
||||
async function localRoutineScheduleCommand(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) {
|
||||
return await getLocalRoutineScheduleCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "code-sidecar-routines-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const workspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !timing || !prompt || !workspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: readHubScheduleMode(args, "yolo"),
|
||||
workspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
maxIterations: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: asTrimmedStringArray(args?.tags),
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const mode = readHubScheduleMode(args);
|
||||
const name = asTrimmedString(args?.name);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const workspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !timing || !prompt || !workspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
workspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
args?.system_prompt === null
|
||||
? null
|
||||
: asTrimmedString(args?.system_prompt),
|
||||
maxIterations:
|
||||
args?.max_iterations === null
|
||||
? null
|
||||
: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds:
|
||||
args?.timeout_seconds === null
|
||||
? null
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: asTrimmedStringArray(args?.tags) ?? [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
// wait: false queues the run and returns immediately; the default
|
||||
// path blocks until the whole agent run finishes, which outlives the
|
||||
// webview's request timeout.
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1189,12 +1140,21 @@ export async function handleCommand(
|
||||
|
||||
// ── Process context ───────────────────────────────────────────────
|
||||
if (command === "get_process_context") {
|
||||
const hubUrl =
|
||||
ctx.hubClient?.getUrl() ??
|
||||
ctx.sessionManager?.runtimeAddress?.trim() ??
|
||||
null;
|
||||
return {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
homeDir: homedir(),
|
||||
platform: process.platform,
|
||||
appVersion: packageJson.version,
|
||||
hub: {
|
||||
status: ctx.hubClient?.isConnected() ? "connected" : "disconnected",
|
||||
url: hubUrl,
|
||||
error: ctx.hubClient?.getConnectionError()?.message ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === "get_chat_ws_endpoint") {
|
||||
@@ -1685,7 +1645,7 @@ export async function handleCommand(
|
||||
command === "trigger_routine_schedule" ||
|
||||
command === "delete_routine_schedule"
|
||||
) {
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
return await handleRoutineScheduleCommand(ctx, command, args);
|
||||
}
|
||||
|
||||
// ── User instruction configs ──────────────────────────────────────
|
||||
|
||||
@@ -8,9 +8,12 @@ import type { LiveSession, SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubCommandMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -21,19 +24,16 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
command = hubCommandMock;
|
||||
getConnectionError = hubGetConnectionErrorMock;
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
@@ -57,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
ensureCompatibleLocalHubUrlMock.mockReset();
|
||||
hubCommandMock.mockReset();
|
||||
hubGetConnectionErrorMock.mockReset();
|
||||
hubGetUrlMock.mockReset();
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
|
||||
hubGetConnectionErrorMock.mockReturnValue(null);
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -87,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -106,23 +98,27 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const hubOptions = createCoreMock.mock.calls[0][0].hub;
|
||||
expect(hubOptions).not.toHaveProperty("endpoint");
|
||||
expect(hubOptions).not.toHaveProperty("authToken");
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the client and embedded hub", async () => {
|
||||
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
@@ -139,9 +135,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logger, telemetry }),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
@@ -151,6 +144,49 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the connected shared Hub endpoint in process context", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
hub: {
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts or reuses the shared Hub when a command needs a client", async () => {
|
||||
const { createSidecarContext, ensureSharedHubClient } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
});
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
}),
|
||||
);
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serializes queued image data when a queued prompt starts", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
|
||||
@@ -245,8 +281,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
@@ -306,6 +341,31 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "pause_routine_schedule", {
|
||||
schedule_id: "schedule-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
type BasicLogger,
|
||||
ClineCore,
|
||||
type CoreSessionEvent,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
ensureCompatibleLocalHubUrl,
|
||||
type ITelemetryService,
|
||||
NodeHubClient,
|
||||
type RuntimeCapabilities,
|
||||
resolveHubOwnerContext,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
@@ -34,6 +32,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -443,7 +445,6 @@ export function createSidecarContext(
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
@@ -495,12 +496,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -753,15 +748,6 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
@@ -769,8 +755,7 @@ export async function initializeSessionManager(
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -783,25 +768,64 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
const runtimeAddress = sessionManager.runtimeAddress?.trim();
|
||||
let hubClient: NodeHubClient | null = null;
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
return await pending;
|
||||
}
|
||||
|
||||
const initialization = (async () => {
|
||||
const url =
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
hubClientInitialization.delete(ctx);
|
||||
});
|
||||
|
||||
hubClientInitialization.set(ctx, initialization);
|
||||
return await initialization;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { homedir } from "node:os";
|
||||
import { setHomeDirIfUnset } from "@cline/core";
|
||||
import { isHubDaemonProcess } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import {
|
||||
createSidecarContext,
|
||||
@@ -133,7 +134,15 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
async function runEntrypoint(): Promise<void> {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
}
|
||||
|
||||
runEntrypoint().catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
activeObservability?.logger.error?.("Desktop sidecar process failed", {
|
||||
error,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AgentToolContext,
|
||||
BasicLogger,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
ITelemetryService,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
@@ -112,7 +111,6 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
|
||||
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
|
||||
"@cline/core/hub/daemon-entry": [
|
||||
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
],
|
||||
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/storage": [
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
|
||||
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
|
||||
"@cline/core/hub/daemon-entry": [
|
||||
"../../../sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
],
|
||||
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/storage": [
|
||||
|
||||
@@ -70,6 +70,15 @@ async function click(element: Element): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function hover(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("pointerover", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function buttonWithText(text: string, rootNode: ParentNode = container) {
|
||||
const button = [
|
||||
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
|
||||
@@ -345,11 +354,18 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
|
||||
it("shows the desktop app version and connected Hub when the logo is hovered", async () => {
|
||||
const onHome = vi.fn();
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "get_process_context") {
|
||||
return { appVersion: "1.2.3" };
|
||||
return {
|
||||
appVersion: "1.2.3",
|
||||
hub: {
|
||||
error: null,
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("No Cline account auth token found");
|
||||
});
|
||||
@@ -377,15 +393,63 @@ describe("AgentSidebar session organization", () => {
|
||||
expect(logoButton).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Version 1.2.3");
|
||||
|
||||
await click(logoButton as Element);
|
||||
await hover(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Version 1.2.3");
|
||||
expect(document.body.textContent).toContain("Cline Hub @25463");
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
});
|
||||
expect(onHome).not.toHaveBeenCalled();
|
||||
|
||||
await click(logoButton as Element);
|
||||
expect(onHome).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith("get_process_context");
|
||||
});
|
||||
|
||||
it("shows a disconnected Hub when process context has no live connection", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
appVersion: "1.2.3",
|
||||
hub: {
|
||||
error: "Hub connection closed (code=1006)",
|
||||
status: "disconnected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const logoButton = container.querySelector('[aria-label="Cline home"]');
|
||||
expect(logoButton).not.toBeNull();
|
||||
await hover(logoButton as Element);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Cline Hub @25463");
|
||||
expect(document.body.textContent).toContain(
|
||||
"Hub connection closed (code=1006)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("hosts back and forward navigation in the draggable sidebar title bar", async () => {
|
||||
const onNavigateBack = vi.fn();
|
||||
const onNavigateForward = vi.fn();
|
||||
|
||||
@@ -67,11 +67,6 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { normalizeTitle } from "@/components/utils";
|
||||
@@ -101,6 +96,31 @@ type AppView = "chat" | "sessions" | "settings";
|
||||
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type DesktopProcessContext = {
|
||||
appVersion?: unknown;
|
||||
hub?: {
|
||||
error?: unknown;
|
||||
status?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
type HubStatus = {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
function hubPort(url: string | null): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(url).port || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SETTINGS_SECTION_ICONS = {
|
||||
General: SlidersHorizontal,
|
||||
Models: Bot,
|
||||
@@ -246,10 +266,11 @@ export function AgentSidebar({
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [hubStatus, setHubStatus] = useState<HubStatus | null>(null);
|
||||
|
||||
const loadAppVersion = useCallback(async () => {
|
||||
const loadProcessContext = useCallback(async () => {
|
||||
try {
|
||||
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
|
||||
const context = await desktopClient.invoke<DesktopProcessContext>(
|
||||
"get_process_context",
|
||||
);
|
||||
const version =
|
||||
@@ -257,14 +278,33 @@ export function AgentSidebar({
|
||||
? context.appVersion.trim()
|
||||
: "";
|
||||
setAppVersion(version || null);
|
||||
} catch {
|
||||
// Leave the version hidden; an older sidecar build has no appVersion.
|
||||
const hubUrl =
|
||||
typeof context?.hub?.url === "string"
|
||||
? context.hub.url.trim() || null
|
||||
: null;
|
||||
setHubStatus({
|
||||
connected: context?.hub?.status === "connected",
|
||||
error:
|
||||
typeof context?.hub?.error === "string"
|
||||
? context.hub.error.trim() || null
|
||||
: null,
|
||||
url: hubUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
setHubStatus({
|
||||
connected: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to read Cline Hub status.",
|
||||
url: null,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAppVersion();
|
||||
}, [loadAppVersion]);
|
||||
void loadProcessContext();
|
||||
}, [loadProcessContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed && searchOpen) {
|
||||
@@ -546,14 +586,16 @@ export function AgentSidebar({
|
||||
isCollapsed && "px-1.5",
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
<HoverCard
|
||||
closeDelay={100}
|
||||
openDelay={0}
|
||||
onOpenChange={(open) => {
|
||||
if (open && !appVersion) {
|
||||
void loadAppVersion();
|
||||
if (open) {
|
||||
void loadProcessContext();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
@@ -563,14 +605,35 @@ export function AgentSidebar({
|
||||
>
|
||||
<ClineLogo className="size-6" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-3" side="bottom">
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-64 p-3" side="bottom">
|
||||
<p className="text-sm font-medium">Cline Code</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="mt-3 border-border border-t pt-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
hubStatus?.connected
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
Cline Hub @{hubPort(hubStatus?.url ?? null) ?? "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{hubStatus && !hubStatus.connected && (
|
||||
<p className="mt-1 text-[11px] text-destructive">
|
||||
{hubStatus.error ?? "Cline Hub is not connected."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{!isCollapsed ? (
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
|
||||
@@ -258,6 +258,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -155,7 +155,10 @@ describe("NodeHubClient", () => {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const client = new NodeHubClient({ url: "ws://127.0.0.1:25463/hub" });
|
||||
expect(client.isConnected()).toBe(false);
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(client.getConnectionError()).toBeNull();
|
||||
client.subscribe(() => {});
|
||||
|
||||
const firstSocket = MockWebSocket.instances[0];
|
||||
@@ -165,8 +168,15 @@ describe("NodeHubClient", () => {
|
||||
});
|
||||
|
||||
firstSocket.emit("close", { code: 1006, reason: "" });
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(client.getConnectionError()).toMatchObject({
|
||||
code: "hub_connection_closed",
|
||||
message: "Hub connection closed (code=1006)",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(client.getConnectionError()).toBeNull();
|
||||
|
||||
const secondSocket = MockWebSocket.instances[1];
|
||||
expect(secondSocket.sentFrames).toContainEqual({
|
||||
|
||||
@@ -323,6 +323,14 @@ export class NodeHubClient {
|
||||
return this.currentUrl;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.socket?.readyState === 1 && this.registered;
|
||||
}
|
||||
|
||||
getConnectionError(): HubTransportError | null {
|
||||
return this.isConnected() ? null : this.lastCloseError;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (
|
||||
this.socket &&
|
||||
|
||||
Reference in New Issue
Block a user