mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
refactor(desktop): group the suggestion tray, composer suggestions hook and Hub notice into folders
- Sidecar: the Hub capability helpers move from plugin-ui.ts to hub-capability.ts. The doc comment on DESKTOP_REQUIRED_HUB_CAPABILITIES now names the slots it means (desktopUiSlots in plugin-ui.ts), since that declaration stays behind. The plugin reload helpers (pluginReloads, claimPluginReload, markLocalSessionsPluginsStale, reloadInstalledPluginInHub, failedPluginIssues, emitPluginFailureNotices) move from commands.ts and chat-session.ts to plugin-reload.ts. - The Hub capability notice and its test move into components/hub-capability-notice/. Its notice-only helpers and their tests move there as helpers.ts and helpers.test.ts, and hub-update-required-helpers.test.ts is back to main's content. - The suggestion tray parts drop their suggestion-tray- prefix. The source link becomes source-link.tsx and takes asHttpUrl from keycaps.ts. - useComposerSuggestions keeps its entry file and splits into hooks/composer-suggestions/: types, exposed-memory, sent-prompt, timing, and the useSlotBinding, useSuggestionVerbs and useUndoSent sub-hooks. use-suggestion-tray-keys moves there as use-tray-keys. - The hook's tests split by facet into use-slot-binding.test.tsx and use-verbs.test.tsx, and the harness splits into test-support/fixtures.ts and test-support/harness.tsx beside fake-client.ts. - test:sidecar filters the new folders. - Behaviour is unchanged: code moves verbatim apart from that one comment, and the sub-hooks only add stable dispatch, setter and ref values to their dependency lists. Test names, the webview tsc output and test:sidecar (1326 tests) match the baseline.
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
|
||||
"pretest:settings-ui": "bun run build:ui",
|
||||
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-composer-suggestions.test.tsx webview/hooks/use-suggestion-tray-keys.test.ts webview/components/hub-capability-notice.test.tsx webview/components/hub-update-required-helpers.test.ts webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-composer-suggestions.test.tsx webview/hooks/composer-suggestions webview/components/hub-capability-notice webview/components/hub-update-required-helpers.test.ts webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
|
||||
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
|
||||
@@ -63,6 +63,12 @@ import {
|
||||
import { isCloudAgentsEnabled } from "./feature-flags";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { runPluginSlashCommand } from "./plugin-commands";
|
||||
import {
|
||||
claimPluginReload,
|
||||
emitPluginFailureNotices,
|
||||
failedPluginIssues,
|
||||
pluginReloads,
|
||||
} from "./plugin-reload";
|
||||
import { desktopUiSlots, reattachPluginUiViewers } from "./plugin-ui";
|
||||
import { derivePromptFromMessages } from "./session-data/common";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
@@ -1164,12 +1170,6 @@ async function handleStart(
|
||||
};
|
||||
}
|
||||
|
||||
function failedPluginIssues(
|
||||
issues: ReadonlyArray<SessionPluginIssue> | undefined,
|
||||
): SessionPluginIssue[] {
|
||||
return (issues ?? []).filter((issue) => issue.state === "failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins the session asked for but could not load, as display lines.
|
||||
* Failures that happen later (setup and hooks run on the first turn) arrive
|
||||
@@ -1184,30 +1184,6 @@ function pluginWarningsOf(
|
||||
return pluginWarnings.length > 0 ? { pluginWarnings } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* A rebuild has no start reply to carry plugin warnings, so its load
|
||||
* failures go to the chat the way later plugin failures do.
|
||||
*/
|
||||
function emitPluginFailureNotices(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
issues: ReadonlyArray<SessionPluginIssue> | undefined,
|
||||
): void {
|
||||
for (const issue of failedPluginIssues(issues)) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({
|
||||
level: "warn",
|
||||
message: formatSessionPluginIssue(issue),
|
||||
noticeType: "status",
|
||||
metadata: { pluginIssue: issue },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
@@ -1665,23 +1641,6 @@ async function recoverSessionAfterHubLoss(
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// Restarts that load changed plugins, by session. A send that may restart a
|
||||
// stale chat claims the restart before it expands its prompt, and every other
|
||||
// send to that chat waits for the claim before it reaches the Hub, so it lands
|
||||
// in the restarted session instead of the one being replaced.
|
||||
const pluginReloads = new Map<string, Promise<void>>();
|
||||
|
||||
function claimPluginReload(sessionId: string): () => void {
|
||||
const claim = Promise.withResolvers<void>();
|
||||
pluginReloads.set(sessionId, claim.promise);
|
||||
return () => {
|
||||
if (pluginReloads.get(sessionId) === claim.promise) {
|
||||
pluginReloads.delete(sessionId);
|
||||
}
|
||||
claim.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the Hub confirms the chat has no queued prompts. Asks the Hub
|
||||
* directly because ClineCore's list turns a Hub error reply into an empty
|
||||
|
||||
@@ -134,6 +134,11 @@ import {
|
||||
isCloudAgentsEnabled,
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
import {
|
||||
broadcastHubBuildMismatch,
|
||||
DESKTOP_REQUIRED_HUB_CAPABILITIES,
|
||||
refreshHubCapabilityNotice,
|
||||
} from "./hub-capability";
|
||||
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
@@ -168,12 +173,10 @@ import {
|
||||
warmPluginCommandService,
|
||||
} from "./plugin-commands";
|
||||
import {
|
||||
broadcastHubBuildMismatch,
|
||||
DESKTOP_REQUIRED_HUB_CAPABILITIES,
|
||||
getPluginUiStates,
|
||||
invokePluginUi,
|
||||
refreshHubCapabilityNotice,
|
||||
} from "./plugin-ui";
|
||||
markLocalSessionsPluginsStale,
|
||||
reloadInstalledPluginInHub,
|
||||
} from "./plugin-reload";
|
||||
import { getPluginUiStates, invokePluginUi } from "./plugin-ui";
|
||||
import { getPullRequestStatus } from "./pull-request";
|
||||
import { capturePullRequestEvent } from "./pull-request-telemetry";
|
||||
import { resolveDesktopRemoteHelper } from "./remote-helper";
|
||||
@@ -1475,49 +1478,6 @@ async function toggleHubSetting(
|
||||
return reply.payload?.snapshot as CoreSettingsSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins load once per session start, so open local chats restart before
|
||||
* their next turn to pick up a plugin that was added or turned on. Removing
|
||||
* or turning one off restarts nothing, as before: each restart leaves the
|
||||
* replaced runtime resident in the Hub, and a plugin that is no longer
|
||||
* active withdraws its slot state on its own.
|
||||
*/
|
||||
function markLocalSessionsPluginsStale(ctx: SidecarContext): void {
|
||||
for (const session of ctx.liveSessions.values()) {
|
||||
if (session.environmentId === LOCAL_ENVIRONMENT_ID) {
|
||||
session.pluginsStale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hub keeps a plugin whose import failed blocked for its lifetime, so an
|
||||
* install asks it to import the plugin again. A Hub that never tried has
|
||||
* nothing to clear, and a failed request never fails the install.
|
||||
*/
|
||||
async function reloadInstalledPluginInHub(
|
||||
ctx: SidecarContext,
|
||||
details: JsonRecord | undefined,
|
||||
): Promise<void> {
|
||||
const entryPaths = Array.isArray(details?.entryPaths)
|
||||
? details.entryPaths.filter(
|
||||
(entryPath): entryPath is string => typeof entryPath === "string",
|
||||
)
|
||||
: [];
|
||||
for (const entryPath of entryPaths) {
|
||||
try {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
await hubClient.command("plugins.reload", { plugin: entryPath });
|
||||
} catch (error) {
|
||||
ctx.logger?.log("Failed to reload the installed plugin in the hub", {
|
||||
entryPath,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserInstructionConfigs(
|
||||
ctx: SidecarContext,
|
||||
settingsSnapshot?: CoreSettingsSnapshot,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
resolveAgentPluginPathsWithDiagnostics,
|
||||
type WatchManagedHubBuildOptions,
|
||||
} from "@cline/core";
|
||||
import type { HubCapabilityName } from "@cline/shared";
|
||||
import { broadcastEvent } from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
/**
|
||||
* The local Hub capabilities the desktop's plugin UI slots (desktopUiSlots in
|
||||
* plugin-ui.ts) depend on. A Hub without them ignores the declared slots, so
|
||||
* the desktop offers to restart it.
|
||||
*/
|
||||
export const DESKTOP_REQUIRED_HUB_CAPABILITIES: readonly HubCapabilityName[] = [
|
||||
"plugins.ui",
|
||||
];
|
||||
|
||||
/**
|
||||
* The mismatch the webview should see. A Hub without plugin UI costs nothing
|
||||
* until a plugin is enabled, so its notice waits for one; the mismatch stays
|
||||
* recorded for when it does.
|
||||
*/
|
||||
export function visibleHubBuildMismatch(
|
||||
ctx: SidecarContext,
|
||||
): ManagedHubBuildMismatchEvent | null {
|
||||
const mismatch = ctx.hubBuildMismatch;
|
||||
if (mismatch?.reason !== "missing_capability") return mismatch;
|
||||
const enabledPlugins = resolveAgentPluginPathsWithDiagnostics({
|
||||
workspacePath: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
}).paths;
|
||||
return enabledPlugins.length > 0 ? mismatch : null;
|
||||
}
|
||||
|
||||
export function broadcastHubBuildMismatch(ctx: SidecarContext): void {
|
||||
broadcastEvent(ctx, "hub_build_mismatch", visibleHubBuildMismatch(ctx));
|
||||
}
|
||||
|
||||
/** Shows the managed Hub watcher's findings in every connected webview. */
|
||||
export function hubBuildWatchCallbacks(
|
||||
ctx: SidecarContext,
|
||||
): Pick<WatchManagedHubBuildOptions, "onMismatch" | "onResolved"> {
|
||||
return {
|
||||
onMismatch: (mismatch) => {
|
||||
ctx.hubBuildMismatch = mismatch;
|
||||
ctx.logger?.log("Managed hub build mismatch detected", {
|
||||
hubBuildId: mismatch.hubBuildId,
|
||||
hubCoreVersion: mismatch.hubCoreVersion,
|
||||
reason: mismatch.reason,
|
||||
missingCapabilities: mismatch.missingCapabilities,
|
||||
});
|
||||
broadcastHubBuildMismatch(ctx);
|
||||
},
|
||||
// Another Cline app can replace the Hub without going through this
|
||||
// app's Restart Hub, so its notice is withdrawn once the Hub is fixed.
|
||||
onResolved: () => {
|
||||
if (!ctx.hubBuildMismatch) return;
|
||||
ctx.hubBuildMismatch = null;
|
||||
ctx.logger?.log("Managed hub build mismatch resolved");
|
||||
broadcastEvent(ctx, "hub_build_mismatch", null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Shows or withdraws the plugin UI notice after the enabled plugins changed. */
|
||||
export function refreshHubCapabilityNotice(ctx: SidecarContext): void {
|
||||
if (ctx.hubBuildMismatch?.reason === "missing_capability") {
|
||||
broadcastHubBuildMismatch(ctx);
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ import {
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { configurePluginBootstrap } from "./plugin-bootstrap";
|
||||
import {
|
||||
broadcastHubBuildMismatch,
|
||||
DESKTOP_REQUIRED_HUB_CAPABILITIES,
|
||||
hubBuildWatchCallbacks,
|
||||
} from "./plugin-ui";
|
||||
} from "./hub-capability";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { configurePluginBootstrap } from "./plugin-bootstrap";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { formatSessionPluginIssue } from "@cline/core";
|
||||
import type { SessionPluginIssue } from "@cline/shared";
|
||||
import { emitChunk, ensureSharedHubClient } from "./context";
|
||||
import {
|
||||
type JsonRecord,
|
||||
LOCAL_ENVIRONMENT_ID,
|
||||
type SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
// Restarts that load changed plugins, by session. A send that may restart a
|
||||
// stale chat claims the restart before it expands its prompt, and every other
|
||||
// send to that chat waits for the claim before it reaches the Hub, so it lands
|
||||
// in the restarted session instead of the one being replaced.
|
||||
export const pluginReloads = new Map<string, Promise<void>>();
|
||||
|
||||
export function claimPluginReload(sessionId: string): () => void {
|
||||
const claim = Promise.withResolvers<void>();
|
||||
pluginReloads.set(sessionId, claim.promise);
|
||||
return () => {
|
||||
if (pluginReloads.get(sessionId) === claim.promise) {
|
||||
pluginReloads.delete(sessionId);
|
||||
}
|
||||
claim.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins load once per session start, so open local chats restart before
|
||||
* their next turn to pick up a plugin that was added or turned on. Removing
|
||||
* or turning one off restarts nothing, as before: each restart leaves the
|
||||
* replaced runtime resident in the Hub, and a plugin that is no longer
|
||||
* active withdraws its slot state on its own.
|
||||
*/
|
||||
export function markLocalSessionsPluginsStale(ctx: SidecarContext): void {
|
||||
for (const session of ctx.liveSessions.values()) {
|
||||
if (session.environmentId === LOCAL_ENVIRONMENT_ID) {
|
||||
session.pluginsStale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hub keeps a plugin whose import failed blocked for its lifetime, so an
|
||||
* install asks it to import the plugin again. A Hub that never tried has
|
||||
* nothing to clear, and a failed request never fails the install.
|
||||
*/
|
||||
export async function reloadInstalledPluginInHub(
|
||||
ctx: SidecarContext,
|
||||
details: JsonRecord | undefined,
|
||||
): Promise<void> {
|
||||
const entryPaths = Array.isArray(details?.entryPaths)
|
||||
? details.entryPaths.filter(
|
||||
(entryPath): entryPath is string => typeof entryPath === "string",
|
||||
)
|
||||
: [];
|
||||
for (const entryPath of entryPaths) {
|
||||
try {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
await hubClient.command("plugins.reload", { plugin: entryPath });
|
||||
} catch (error) {
|
||||
ctx.logger?.log("Failed to reload the installed plugin in the hub", {
|
||||
entryPath,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function failedPluginIssues(
|
||||
issues: ReadonlyArray<SessionPluginIssue> | undefined,
|
||||
): SessionPluginIssue[] {
|
||||
return (issues ?? []).filter((issue) => issue.state === "failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* A rebuild has no start reply to carry plugin warnings, so its load
|
||||
* failures go to the chat the way later plugin failures do.
|
||||
*/
|
||||
export function emitPluginFailureNotices(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
issues: ReadonlyArray<SessionPluginIssue> | undefined,
|
||||
): void {
|
||||
for (const issue of failedPluginIssues(issues)) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({
|
||||
level: "warn",
|
||||
message: formatSessionPluginIssue(issue),
|
||||
noticeType: "status",
|
||||
metadata: { pluginIssue: issue },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import {
|
||||
type ClineCore,
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
resolveAgentPluginPathsWithDiagnostics,
|
||||
type WatchManagedHubBuildOptions,
|
||||
} from "@cline/core";
|
||||
import type { ClineCore } from "@cline/core";
|
||||
import type {
|
||||
HubCapabilityName,
|
||||
PluginUiInvokeInput,
|
||||
PluginUiInvokeResult,
|
||||
PluginUiStateEnvelope,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
broadcastEvent,
|
||||
getRuntimeBinding,
|
||||
getSessionRuntimeBinding,
|
||||
sendEvent,
|
||||
@@ -35,68 +28,6 @@ export function desktopUiSlots(
|
||||
return binding.kind === "local" ? [COMPOSER_SUGGESTIONS] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local Hub capabilities those slots depend on. A Hub without them
|
||||
* ignores the declared slots, so the desktop offers to restart it.
|
||||
*/
|
||||
export const DESKTOP_REQUIRED_HUB_CAPABILITIES: readonly HubCapabilityName[] = [
|
||||
"plugins.ui",
|
||||
];
|
||||
|
||||
/**
|
||||
* The mismatch the webview should see. A Hub without plugin UI costs nothing
|
||||
* until a plugin is enabled, so its notice waits for one; the mismatch stays
|
||||
* recorded for when it does.
|
||||
*/
|
||||
export function visibleHubBuildMismatch(
|
||||
ctx: SidecarContext,
|
||||
): ManagedHubBuildMismatchEvent | null {
|
||||
const mismatch = ctx.hubBuildMismatch;
|
||||
if (mismatch?.reason !== "missing_capability") return mismatch;
|
||||
const enabledPlugins = resolveAgentPluginPathsWithDiagnostics({
|
||||
workspacePath: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
}).paths;
|
||||
return enabledPlugins.length > 0 ? mismatch : null;
|
||||
}
|
||||
|
||||
export function broadcastHubBuildMismatch(ctx: SidecarContext): void {
|
||||
broadcastEvent(ctx, "hub_build_mismatch", visibleHubBuildMismatch(ctx));
|
||||
}
|
||||
|
||||
/** Shows the managed Hub watcher's findings in every connected webview. */
|
||||
export function hubBuildWatchCallbacks(
|
||||
ctx: SidecarContext,
|
||||
): Pick<WatchManagedHubBuildOptions, "onMismatch" | "onResolved"> {
|
||||
return {
|
||||
onMismatch: (mismatch) => {
|
||||
ctx.hubBuildMismatch = mismatch;
|
||||
ctx.logger?.log("Managed hub build mismatch detected", {
|
||||
hubBuildId: mismatch.hubBuildId,
|
||||
hubCoreVersion: mismatch.hubCoreVersion,
|
||||
reason: mismatch.reason,
|
||||
missingCapabilities: mismatch.missingCapabilities,
|
||||
});
|
||||
broadcastHubBuildMismatch(ctx);
|
||||
},
|
||||
// Another Cline app can replace the Hub without going through this
|
||||
// app's Restart Hub, so its notice is withdrawn once the Hub is fixed.
|
||||
onResolved: () => {
|
||||
if (!ctx.hubBuildMismatch) return;
|
||||
ctx.hubBuildMismatch = null;
|
||||
ctx.logger?.log("Managed hub build mismatch resolved");
|
||||
broadcastEvent(ctx, "hub_build_mismatch", null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Shows or withdraws the plugin UI notice after the enabled plugins changed. */
|
||||
export function refreshHubCapabilityNotice(ctx: SidecarContext): void {
|
||||
if (ctx.hubBuildMismatch?.reason === "missing_capability") {
|
||||
broadcastHubBuildMismatch(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionId(args: Record<string, unknown> | undefined): string {
|
||||
const sessionId =
|
||||
typeof args?.sessionId === "string" ? args.sessionId.trim() : "";
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
MAX_RECORDED_AUDIO_BASE64_BYTES,
|
||||
MAX_RECORDED_AUDIO_BYTES,
|
||||
} from "../webview/lib/voice-input-limits";
|
||||
import { hubBuildWatchCallbacks } from "./plugin-ui";
|
||||
import { hubBuildWatchCallbacks } from "./hub-capability";
|
||||
import { createFetchHandler, createWebSocketHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
sendEvent,
|
||||
syncSidecarApprovalReadiness,
|
||||
} from "./context";
|
||||
import { visibleHubBuildMismatch } from "./hub-capability";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
import { releasePluginUiViewer, visibleHubBuildMismatch } from "./plugin-ui";
|
||||
import { releasePluginUiViewer } from "./plugin-ui";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "react";
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { HubCapabilityNotice } from "@/components/hub-capability-notice";
|
||||
import { HubCapabilityNotice } from "@/components/hub-capability-notice/hub-capability-notice";
|
||||
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
|
||||
import { SessionCommandBar } from "@/components/session-command-bar";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isPersistableHubMismatchKey,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "../hub-update-required-helpers";
|
||||
import {
|
||||
describeHubRestartInterruption,
|
||||
persistHubCapabilityNoticeDismissal,
|
||||
readHubCapabilityNoticeDismissal,
|
||||
} from "./helpers";
|
||||
|
||||
describe("the plugin suggestions notice", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubStorage(): Map<string, string> {
|
||||
const entries = new Map<string, string>();
|
||||
return Object.assign(entries, {
|
||||
getItem: (key: string) => entries.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("never blocks, even with an app update staged", () => {
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"ready",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("missing_capability", state)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Not now for this app run only, apart from the dialog's Later", () => {
|
||||
const local = stubStorage();
|
||||
const session = stubStorage();
|
||||
vi.stubGlobal("localStorage", local);
|
||||
vi.stubGlobal("sessionStorage", session);
|
||||
|
||||
persistHubCapabilityNoticeDismissal("missing_capability:source-0.0.93");
|
||||
|
||||
expect(readHubCapabilityNoticeDismissal()).toBe(
|
||||
"missing_capability:source-0.0.93",
|
||||
);
|
||||
expect(local.size).toBe(0);
|
||||
expect(isPersistableHubMismatchKey("missing_capability:abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("forgets Not now when storage is unavailable", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
persistHubCapabilityNoticeDismissal("missing_capability:abc"),
|
||||
).not.toThrow();
|
||||
expect(readHubCapabilityNoticeDismissal()).toBeNull();
|
||||
});
|
||||
|
||||
it("words the interruption from a fresh reading, this app included", () => {
|
||||
expect(
|
||||
describeHubRestartInterruption({
|
||||
activeSessionCount: 3,
|
||||
participantClientCount: 2,
|
||||
}),
|
||||
).toBe(
|
||||
"The Hub is serving 3 active sessions from 2 connected Cline clients, including any in this app. Restarting interrupts their work in progress.",
|
||||
);
|
||||
expect(describeHubRestartInterruption({ activeSessionCount: 1 })).toBe(
|
||||
"The Hub is serving 1 active session, including any in this app. Restarting interrupts their work in progress.",
|
||||
);
|
||||
});
|
||||
|
||||
it("still states the cost when the Hub could not be read", () => {
|
||||
expect(describeHubRestartInterruption(null)).toBe(
|
||||
"Cline couldn't check what the Hub is serving. Restarting interrupts any work in progress in Cline apps that use the Hub, including this one.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describeOutdatedHubSessions } from "@cline/shared/browser";
|
||||
|
||||
/**
|
||||
* "Not now" on the plugin suggestions notice lasts until the app quits.
|
||||
* Every build of a release reports the same Hub identity, so a lasting
|
||||
* dismissal would also hide the notice for every later Hub of that release
|
||||
* without the capability, and nothing else offers the restart.
|
||||
*/
|
||||
const DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY =
|
||||
"cline.hub-capability-notice-dismissed";
|
||||
|
||||
export function readHubCapabilityNoticeDismissal(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistHubCapabilityNoticeDismissal(key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY, key);
|
||||
} catch {
|
||||
// Best effort: without storage the dismissal lasts this mount only.
|
||||
}
|
||||
}
|
||||
|
||||
/** Live sessions on the Hub, read when the person asks to restart it. */
|
||||
export type HubSessionActivity = {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
};
|
||||
|
||||
/** Said before any reading: the restart's cost does not depend on one. */
|
||||
export const HUB_RESTART_COST =
|
||||
"Restarting interrupts any work in progress in Cline apps that use the Hub, including this one.";
|
||||
|
||||
/**
|
||||
* The confirmation a forced Hub restart owes the person, from the reading
|
||||
* taken when they asked. The count includes this app's own chats.
|
||||
*/
|
||||
export function describeHubRestartInterruption(
|
||||
activity: HubSessionActivity | null,
|
||||
): string {
|
||||
const sessions = activity?.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return `Cline couldn't check what the Hub is serving. ${HUB_RESTART_COST}`;
|
||||
}
|
||||
return `The Hub is serving ${describeOutdatedHubSessions(activity ?? {})}, including any in this app. Restarting interrupts their work in progress.`;
|
||||
}
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { subscribeToPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { HubUpdateRequiredDialog } from "../hub-update-required-dialog";
|
||||
import { HubCapabilityNotice } from "./hub-capability-notice";
|
||||
import { HubUpdateRequiredDialog } from "./hub-update-required-dialog";
|
||||
|
||||
const { invoke, subscribe, emitHubMismatch } = vi.hoisted(() => {
|
||||
const handlers = new Set<(payload: unknown) => void>();
|
||||
+6
-4
@@ -14,15 +14,17 @@ import { desktopClient } from "@/lib/desktop-client";
|
||||
import { requestPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
describeHubRestartInterruption,
|
||||
HUB_RESTART_COST,
|
||||
HUB_UPGRADE_TIMEOUT_MS,
|
||||
type HubBuildMismatchPayload,
|
||||
type HubSessionActivity,
|
||||
hubMismatchKeyOf,
|
||||
} from "../hub-update-required-helpers";
|
||||
import {
|
||||
describeHubRestartInterruption,
|
||||
HUB_RESTART_COST,
|
||||
type HubSessionActivity,
|
||||
persistHubCapabilityNoticeDismissal,
|
||||
readHubCapabilityNoticeDismissal,
|
||||
} from "./hub-update-required-helpers";
|
||||
} from "./helpers";
|
||||
|
||||
type RestartStep =
|
||||
| { kind: "offer"; error?: string }
|
||||
@@ -1,9 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeHubRestartInterruption,
|
||||
isPersistableHubMismatchKey,
|
||||
persistHubCapabilityNoticeDismissal,
|
||||
readHubCapabilityNoticeDismissal,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
@@ -79,88 +76,6 @@ describe("shouldShowHubMismatchDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plugin suggestions notice", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubStorage(): Map<string, string> {
|
||||
const entries = new Map<string, string>();
|
||||
return Object.assign(entries, {
|
||||
getItem: (key: string) => entries.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("never blocks, even with an app update staged", () => {
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"ready",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("missing_capability", state)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Not now for this app run only, apart from the dialog's Later", () => {
|
||||
const local = stubStorage();
|
||||
const session = stubStorage();
|
||||
vi.stubGlobal("localStorage", local);
|
||||
vi.stubGlobal("sessionStorage", session);
|
||||
|
||||
persistHubCapabilityNoticeDismissal("missing_capability:source-0.0.93");
|
||||
|
||||
expect(readHubCapabilityNoticeDismissal()).toBe(
|
||||
"missing_capability:source-0.0.93",
|
||||
);
|
||||
expect(local.size).toBe(0);
|
||||
expect(isPersistableHubMismatchKey("missing_capability:abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("forgets Not now when storage is unavailable", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
persistHubCapabilityNoticeDismissal("missing_capability:abc"),
|
||||
).not.toThrow();
|
||||
expect(readHubCapabilityNoticeDismissal()).toBeNull();
|
||||
});
|
||||
|
||||
it("words the interruption from a fresh reading, this app included", () => {
|
||||
expect(
|
||||
describeHubRestartInterruption({
|
||||
activeSessionCount: 3,
|
||||
participantClientCount: 2,
|
||||
}),
|
||||
).toBe(
|
||||
"The Hub is serving 3 active sessions from 2 connected Cline clients, including any in this app. Restarting interrupts their work in progress.",
|
||||
);
|
||||
expect(describeHubRestartInterruption({ activeSessionCount: 1 })).toBe(
|
||||
"The Hub is serving 1 active session, including any in this app. Restarting interrupts their work in progress.",
|
||||
);
|
||||
});
|
||||
|
||||
it("still states the cost when the Hub could not be read", () => {
|
||||
expect(describeHubRestartInterruption(null)).toBe(
|
||||
"Cline couldn't check what the Hub is serving. Restarting interrupts any work in progress in Cline apps that use the Hub, including this one.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHubUpdateRestartDecision", () => {
|
||||
it("restarts only once an update is staged", () => {
|
||||
expect(resolveHubUpdateRestartDecision({ state: "ready" })).toEqual({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { describeOutdatedHubSessions } from "@cline/shared/browser";
|
||||
import type { AppUpdateStatus } from "@/hooks/use-app-update";
|
||||
|
||||
export type HubUpdateRestartDecision =
|
||||
@@ -61,31 +60,6 @@ export function isPersistableHubMismatchKey(key: string | null): key is string {
|
||||
return typeof key === "string" && key.startsWith("build_mismatch:");
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not now" on the plugin suggestions notice lasts until the app quits.
|
||||
* Every build of a release reports the same Hub identity, so a lasting
|
||||
* dismissal would also hide the notice for every later Hub of that release
|
||||
* without the capability, and nothing else offers the restart.
|
||||
*/
|
||||
const DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY =
|
||||
"cline.hub-capability-notice-dismissed";
|
||||
|
||||
export function readHubCapabilityNoticeDismissal(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistHubCapabilityNoticeDismissal(key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(DISMISSED_CAPABILITY_NOTICE_STORAGE_KEY, key);
|
||||
} catch {
|
||||
// Best effort: without storage the dismissal lasts this mount only.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a dismissal becomes when the sidecar delivers a mismatch again - it
|
||||
* replays the pending mismatch on every webview (re)connection, including
|
||||
@@ -108,30 +82,6 @@ export function retainDismissalForIncomingMismatch(
|
||||
return previousDismissedKey;
|
||||
}
|
||||
|
||||
/** Live sessions on the Hub, read when the person asks to restart it. */
|
||||
export type HubSessionActivity = {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
};
|
||||
|
||||
/** Said before any reading: the restart's cost does not depend on one. */
|
||||
export const HUB_RESTART_COST =
|
||||
"Restarting interrupts any work in progress in Cline apps that use the Hub, including this one.";
|
||||
|
||||
/**
|
||||
* The confirmation a forced Hub restart owes the person, from the reading
|
||||
* taken when they asked. The count includes this app's own chats.
|
||||
*/
|
||||
export function describeHubRestartInterruption(
|
||||
activity: HubSessionActivity | null,
|
||||
): string {
|
||||
const sessions = activity?.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return `Cline couldn't check what the Hub is serving. ${HUB_RESTART_COST}`;
|
||||
}
|
||||
return `The Hub is serving ${describeOutdatedHubSessions(activity ?? {})}, including any in this app. Restarting interrupts their work in progress.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what "Update and restart" should do after an on-demand updater
|
||||
* check. Restart only when an update is actually staged - relaunching the
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/use-composer-suggestions";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/composer-suggestions/types";
|
||||
import { usePromptDraft } from "@/hooks/use-prompt-draft";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/use-composer-suggestions";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/composer-suggestions/types";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
|
||||
|
||||
+3
-6
@@ -21,12 +21,9 @@ import {
|
||||
shiftKeyLabel,
|
||||
suggestionVerbAriaKeys,
|
||||
suggestionVerbKbd,
|
||||
} from "./suggestion-tray-keycaps";
|
||||
import {
|
||||
REFRESH_LABEL,
|
||||
type SuggestionTrayPartProps,
|
||||
} from "./suggestion-tray-shared";
|
||||
import { TONE_DOT_CLASSES } from "./suggestion-tray-tones";
|
||||
} from "./keycaps";
|
||||
import { REFRESH_LABEL, type SuggestionTrayPartProps } from "./shared";
|
||||
import { TONE_DOT_CLASSES } from "./tones";
|
||||
|
||||
/** The views that draw the header: a set, one loading, a line in its place, or one hidden for the turn. */
|
||||
export type HeaderView = Extract<
|
||||
-17
@@ -77,20 +77,3 @@ export function suggestionDigitVerb(
|
||||
if (!shift) return verb;
|
||||
return verb === "use" ? "send" : null;
|
||||
}
|
||||
|
||||
const LINK_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
/**
|
||||
* A link a row may open: an absolute http(s) URL; anything else reads as
|
||||
* none, so nothing else leaves for the browser.
|
||||
*/
|
||||
export function asHttpUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
return LINK_PROTOCOLS.has(new URL(value.trim()).protocol)
|
||||
? value.trim()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ import {
|
||||
suggestionVerbAriaKeys,
|
||||
suggestionVerbKbd,
|
||||
suggestionVerbWord,
|
||||
} from "./suggestion-tray-keycaps";
|
||||
import type { SuggestionTrayPartProps } from "./suggestion-tray-shared";
|
||||
} from "./keycaps";
|
||||
import type { SuggestionTrayPartProps } from "./shared";
|
||||
|
||||
/** A verb with a key button; Undo has its key (⌘3) and the sent line's button instead. */
|
||||
type LegendVerb = Exclude<SuggestionVerb, "undo">;
|
||||
+4
-4
@@ -8,19 +8,19 @@ import type {
|
||||
} from "@cline/shared/browser";
|
||||
import type { RefObject } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { suggestionIcon } from "./suggestion-tray-icons";
|
||||
import { suggestionIcon } from "./icons";
|
||||
import {
|
||||
OPTION_ID_PREFIX,
|
||||
type ReadySuggestionsView,
|
||||
ROW_GRID_WITH_END,
|
||||
type SuggestionTrayPartProps,
|
||||
} from "./suggestion-tray-shared";
|
||||
import { SourceLink } from "./suggestion-tray-source";
|
||||
} from "./shared";
|
||||
import { SourceLink } from "./source-link";
|
||||
import {
|
||||
TONE_CHIP_CLASSES,
|
||||
TONE_EDGE_CLASSES,
|
||||
TONE_TEXT_CLASSES,
|
||||
} from "./suggestion-tray-tones";
|
||||
} from "./tones";
|
||||
|
||||
/**
|
||||
* The word's ink on the card's why line: each tone's text step, which for
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { PluginUiInput, SuggestionsView } from "@cline/shared/browser";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/use-composer-suggestions";
|
||||
import type { SuggestionVerb } from "./suggestion-tray-keycaps";
|
||||
import type { ComposerSuggestionsController } from "@/hooks/composer-suggestions/types";
|
||||
import type { SuggestionVerb } from "./keycaps";
|
||||
|
||||
/**
|
||||
* Runs a verb on the highlighted suggestion exactly as its key does: the same
|
||||
+17
-1
@@ -8,7 +8,23 @@ import { ExternalLink } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { asHttpUrl } from "./suggestion-tray-keycaps";
|
||||
|
||||
const LINK_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
/**
|
||||
* A link a row may open: an absolute http(s) URL; anything else reads as
|
||||
* none, so nothing else leaves for the browser.
|
||||
*/
|
||||
function asHttpUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
return LINK_PROTOCOLS.has(new URL(value.trim()).protocol)
|
||||
? value.trim()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A source link: 18 px, 11 px medium muted words underlined at 45 %, the accent surface on hover. */
|
||||
const LINK_CLASSES = [
|
||||
+4
-7
@@ -8,18 +8,15 @@ import type {
|
||||
import { EyeOff, MessageCircle, TriangleAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
suggestionVerbAriaKeys,
|
||||
suggestionVerbKbd,
|
||||
} from "./suggestion-tray-keycaps";
|
||||
import { ToneTile } from "./suggestion-tray-rows";
|
||||
import { suggestionVerbAriaKeys, suggestionVerbKbd } from "./keycaps";
|
||||
import { ToneTile } from "./rows";
|
||||
import {
|
||||
ROW_GRID,
|
||||
ROW_GRID_WITH_END,
|
||||
type SuggestionTrayPartProps,
|
||||
TEXT_BUTTON_CLASSES,
|
||||
} from "./suggestion-tray-shared";
|
||||
import { TONE_DOT_CLASSES } from "./suggestion-tray-tones";
|
||||
} from "./shared";
|
||||
import { TONE_DOT_CLASSES } from "./tones";
|
||||
|
||||
type ViewOf<M extends SuggestionsView["mode"]> = Extract<
|
||||
SuggestionsView,
|
||||
+8
-12
@@ -18,20 +18,16 @@ import {
|
||||
import type {
|
||||
ComposerSuggestionsController,
|
||||
SuggestionComposer,
|
||||
} from "@/hooks/use-composer-suggestions";
|
||||
} from "@/hooks/composer-suggestions/types";
|
||||
import {
|
||||
runSuggestionVerb,
|
||||
useSuggestionTrayKeys,
|
||||
} from "@/hooks/use-suggestion-tray-keys";
|
||||
import {
|
||||
type HeaderView,
|
||||
headerLabel,
|
||||
SuggestionTrayHeader,
|
||||
} from "./suggestion-tray-header";
|
||||
import { suggestionVerbAriaKeys } from "./suggestion-tray-keycaps";
|
||||
import { SuggestionTrayLegend } from "./suggestion-tray-legend";
|
||||
import { SuggestionTrayRows } from "./suggestion-tray-rows";
|
||||
import type { SuggestionTrayDispatch } from "./suggestion-tray-shared";
|
||||
} from "@/hooks/composer-suggestions/use-tray-keys";
|
||||
import { type HeaderView, headerLabel, SuggestionTrayHeader } from "./header";
|
||||
import { suggestionVerbAriaKeys } from "./keycaps";
|
||||
import { SuggestionTrayLegend } from "./legend";
|
||||
import { SuggestionTrayRows } from "./rows";
|
||||
import type { SuggestionTrayDispatch } from "./shared";
|
||||
import {
|
||||
SuggestionTrayCollapsed,
|
||||
SuggestionTrayLine,
|
||||
@@ -41,7 +37,7 @@ import {
|
||||
SuggestionTrayUsed,
|
||||
SuggestionTrayWorking,
|
||||
SuggestionTrayYielded,
|
||||
} from "./suggestion-tray-states";
|
||||
} from "./states";
|
||||
|
||||
export type SuggestionTrayProps = {
|
||||
suggestions: ComposerSuggestionsController;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Sessions, and revisions per session, the webview remembers as exposed; the view model keeps 32 revisions too. */
|
||||
const EXPOSED_SESSIONS_KEPT = 32;
|
||||
const EXPOSED_REVISIONS_KEPT = 32;
|
||||
|
||||
/**
|
||||
* Revisions each session's tray already reported exposed, outliving the pane:
|
||||
* ChatThreadPane is keyed per thread, so leaving a session and coming back
|
||||
* remounts the hook, and the view model's own set would start empty.
|
||||
*/
|
||||
function createExposedSuggestionMemory() {
|
||||
const bySession = new Map<string, readonly string[]>();
|
||||
const keyOf = (environmentId: string, sessionId: string) =>
|
||||
`${environmentId}\n${sessionId}`;
|
||||
return {
|
||||
revisions(environmentId: string, sessionId: string): readonly string[] {
|
||||
return bySession.get(keyOf(environmentId, sessionId)) ?? [];
|
||||
},
|
||||
remember(environmentId: string, sessionId: string, revision: string) {
|
||||
const key = keyOf(environmentId, sessionId);
|
||||
const kept = (bySession.get(key) ?? []).filter(
|
||||
(remembered) => remembered !== revision,
|
||||
);
|
||||
bySession.delete(key);
|
||||
bySession.set(key, [...kept, revision].slice(-EXPOSED_REVISIONS_KEPT));
|
||||
for (const oldest of bySession.keys()) {
|
||||
if (bySession.size <= EXPOSED_SESSIONS_KEPT) break;
|
||||
bySession.delete(oldest);
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
bySession.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const exposedSuggestionMemory = createExposedSuggestionMemory();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared/browser";
|
||||
import { buildUserRunCountMap } from "@/components/views/chat/messages/group-messages";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
|
||||
/** A prompt taken back from the queue goes in front of anything typed since, never over it. */
|
||||
export function takenBackDraft(prompt: string, draft: string): string {
|
||||
return draft.trim() && !samePrompt(draft, prompt)
|
||||
? `${prompt}\n\n${draft}`
|
||||
: prompt;
|
||||
}
|
||||
|
||||
export function samePrompt(text: string, prompt: string): boolean {
|
||||
return formatDisplayUserInput(text.trim()).trim() === prompt.trim();
|
||||
}
|
||||
|
||||
/** The last user message showing `prompt`, with the run count a fork from before it needs. */
|
||||
export function findSentMessage(
|
||||
messages: ChatMessage[],
|
||||
prompt: string,
|
||||
): { messageId: string; runCount: number } | undefined {
|
||||
const runCounts = buildUserRunCountMap(messages);
|
||||
const message = messages.findLast(
|
||||
(candidate) =>
|
||||
candidate.role === "user" && samePrompt(candidate.content, prompt),
|
||||
);
|
||||
const runCount = message ? runCounts.get(message) : undefined;
|
||||
return message && runCount !== undefined
|
||||
? { messageId: message.id, runCount }
|
||||
: undefined;
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
type ComposerSuggestionItem,
|
||||
type ComposerSuggestionsAction,
|
||||
type ComposerSuggestionsState,
|
||||
type PluginUiStateEnvelope,
|
||||
parsePluginUiAction,
|
||||
} from "@cline/shared/browser";
|
||||
import { expect } from "vitest";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import { fakeDesktop } from "./fake-client";
|
||||
|
||||
export const SESSION_ID = "ses-b";
|
||||
export const OLDER_SESSION_ID = "ses-a";
|
||||
export const ENVIRONMENT_ID = "local";
|
||||
const SLOT = "composer.suggestions";
|
||||
const PLUGIN = "composer-suggestions";
|
||||
|
||||
export const ITEMS: ComposerSuggestionItem[] = [
|
||||
{
|
||||
id: "verify",
|
||||
label: "Verify",
|
||||
tone: "check",
|
||||
icon: "verify",
|
||||
prompt: "Run the tests and fix what fails",
|
||||
detail: "the last edit touched src",
|
||||
sendable: true,
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
tone: "look",
|
||||
icon: "review",
|
||||
prompt: "Review the diff",
|
||||
sendable: true,
|
||||
},
|
||||
{
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
tone: "finish",
|
||||
icon: "commit",
|
||||
prompt: "Commit the change",
|
||||
sendable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function readyState(
|
||||
revision: string,
|
||||
overrides: Partial<ComposerSuggestionsState> = {},
|
||||
): ComposerSuggestionsState {
|
||||
return {
|
||||
status: "ready",
|
||||
title: "Next",
|
||||
cue: { text: "after a command failed", tone: "repair" },
|
||||
revision,
|
||||
items: ITEMS,
|
||||
canRefresh: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function envelope(
|
||||
state: ComposerSuggestionsState | null,
|
||||
{
|
||||
sessionId = SESSION_ID,
|
||||
epoch = 100,
|
||||
seq = 1,
|
||||
}: { sessionId?: string; epoch?: number; seq?: number } = {},
|
||||
): PluginUiStateEnvelope<"composer.suggestions"> {
|
||||
return { sessionId, slot: SLOT, plugin: PLUGIN, epoch, seq, state };
|
||||
}
|
||||
|
||||
export type HubScript = {
|
||||
/** `plugin_ui_get`'s reply, per session. */
|
||||
states?: Record<string, PluginUiStateEnvelope[]>;
|
||||
/** A verb's reply, or undefined for the default: `attach` gets epoch 100, everything else `ok`. */
|
||||
reply?: (
|
||||
sessionId: string,
|
||||
action: ComposerSuggestionsAction,
|
||||
) => unknown | undefined;
|
||||
};
|
||||
|
||||
export function scriptHub(script: HubScript = {}): void {
|
||||
fakeDesktop.invoke.mockImplementation(async (command, args) => {
|
||||
const sessionId = String(args?.sessionId ?? "");
|
||||
if (command === "plugin_ui_get") return script.states?.[sessionId] ?? [];
|
||||
if (command !== "plugin_ui_invoke") return undefined;
|
||||
const action = args?.action as ComposerSuggestionsAction;
|
||||
const scripted = script.reply?.(sessionId, action);
|
||||
if (scripted !== undefined) return scripted;
|
||||
return action.verb === "attach"
|
||||
? { outcome: "ok", epoch: 100 }
|
||||
: { outcome: "ok" };
|
||||
});
|
||||
}
|
||||
|
||||
export type InvokedAction = {
|
||||
sessionId: string;
|
||||
action: ComposerSuggestionsAction;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export function invokedActions(verb?: string): InvokedAction[] {
|
||||
const { calls, invocationCallOrder } = fakeDesktop.invoke.mock;
|
||||
return calls.flatMap(([command, args], index) => {
|
||||
if (command !== "plugin_ui_invoke") return [];
|
||||
const action = args?.action as ComposerSuggestionsAction;
|
||||
if (verb && action.verb !== verb) return [];
|
||||
return [
|
||||
{
|
||||
sessionId: String(args?.sessionId),
|
||||
action,
|
||||
order: invocationCallOrder[index] ?? 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function commandCalls(command: string): unknown[] {
|
||||
return fakeDesktop.invoke.mock.calls
|
||||
.filter(([name]) => name === command)
|
||||
.map(([, args]) => args);
|
||||
}
|
||||
|
||||
/** Every action the hook sent must survive the hub's parser, or it would come back `rejected`. */
|
||||
export function expectEveryActionParses(): void {
|
||||
for (const { action } of invokedActions()) {
|
||||
expect(parsePluginUiAction(SLOT, action)).toMatchObject({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
id: string,
|
||||
content: string,
|
||||
runCount?: number,
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
sessionId: SESSION_ID,
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: 1,
|
||||
...(runCount === undefined ? {} : { meta: { runCount } }),
|
||||
};
|
||||
}
|
||||
|
||||
export const READY_R1 = { [SESSION_ID]: [envelope(readyState("r1"))] };
|
||||
export const SEND_KEYS = {
|
||||
code: "Digit1",
|
||||
key: "!",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
};
|
||||
+12
-158
@@ -1,82 +1,16 @@
|
||||
import {
|
||||
type ComposerSuggestionItem,
|
||||
type ComposerSuggestionsAction,
|
||||
type ComposerSuggestionsState,
|
||||
type PluginUiStateEnvelope,
|
||||
parsePluginUiAction,
|
||||
} from "@cline/shared/browser";
|
||||
import type { PluginUiStateEnvelope } from "@cline/shared/browser";
|
||||
import { act, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, vi } from "vitest";
|
||||
import { SuggestionTray } from "@/components/views/chat/suggestion-tray/suggestion-tray";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import {
|
||||
type ComposerSuggestionsController,
|
||||
type ComposerSuggestionsOptions,
|
||||
useComposerSuggestions,
|
||||
} from "./use-composer-suggestions";
|
||||
import { fakeDesktop } from "./use-composer-suggestions.fake-client";
|
||||
|
||||
export const SESSION_ID = "ses-b";
|
||||
export const OLDER_SESSION_ID = "ses-a";
|
||||
export const ENVIRONMENT_ID = "local";
|
||||
const SLOT = "composer.suggestions";
|
||||
const PLUGIN = "composer-suggestions";
|
||||
|
||||
export const ITEMS: ComposerSuggestionItem[] = [
|
||||
{
|
||||
id: "verify",
|
||||
label: "Verify",
|
||||
tone: "check",
|
||||
icon: "verify",
|
||||
prompt: "Run the tests and fix what fails",
|
||||
detail: "the last edit touched src",
|
||||
sendable: true,
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
tone: "look",
|
||||
icon: "review",
|
||||
prompt: "Review the diff",
|
||||
sendable: true,
|
||||
},
|
||||
{
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
tone: "finish",
|
||||
icon: "commit",
|
||||
prompt: "Commit the change",
|
||||
sendable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function readyState(
|
||||
revision: string,
|
||||
overrides: Partial<ComposerSuggestionsState> = {},
|
||||
): ComposerSuggestionsState {
|
||||
return {
|
||||
status: "ready",
|
||||
title: "Next",
|
||||
cue: { text: "after a command failed", tone: "repair" },
|
||||
revision,
|
||||
items: ITEMS,
|
||||
canRefresh: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function envelope(
|
||||
state: ComposerSuggestionsState | null,
|
||||
{
|
||||
sessionId = SESSION_ID,
|
||||
epoch = 100,
|
||||
seq = 1,
|
||||
}: { sessionId?: string; epoch?: number; seq?: number } = {},
|
||||
): PluginUiStateEnvelope<"composer.suggestions"> {
|
||||
return { sessionId, slot: SLOT, plugin: PLUGIN, epoch, seq, state };
|
||||
}
|
||||
import { useComposerSuggestions } from "@/hooks/use-composer-suggestions";
|
||||
import type {
|
||||
ComposerSuggestionsController,
|
||||
ComposerSuggestionsOptions,
|
||||
} from "../types";
|
||||
import { fakeDesktop } from "./fake-client";
|
||||
import { ENVIRONMENT_ID, SESSION_ID } from "./fixtures";
|
||||
|
||||
/** Delivers a state the way the sidecar does: nested, stamped with the environment. */
|
||||
export async function emitState(
|
||||
@@ -92,90 +26,6 @@ export async function emitState(
|
||||
await settle();
|
||||
}
|
||||
|
||||
export type HubScript = {
|
||||
/** `plugin_ui_get`'s reply, per session. */
|
||||
states?: Record<string, PluginUiStateEnvelope[]>;
|
||||
/** A verb's reply, or undefined for the default: `attach` gets epoch 100, everything else `ok`. */
|
||||
reply?: (
|
||||
sessionId: string,
|
||||
action: ComposerSuggestionsAction,
|
||||
) => unknown | undefined;
|
||||
};
|
||||
|
||||
export function scriptHub(script: HubScript = {}): void {
|
||||
fakeDesktop.invoke.mockImplementation(async (command, args) => {
|
||||
const sessionId = String(args?.sessionId ?? "");
|
||||
if (command === "plugin_ui_get") return script.states?.[sessionId] ?? [];
|
||||
if (command !== "plugin_ui_invoke") return undefined;
|
||||
const action = args?.action as ComposerSuggestionsAction;
|
||||
const scripted = script.reply?.(sessionId, action);
|
||||
if (scripted !== undefined) return scripted;
|
||||
return action.verb === "attach"
|
||||
? { outcome: "ok", epoch: 100 }
|
||||
: { outcome: "ok" };
|
||||
});
|
||||
}
|
||||
|
||||
export type InvokedAction = {
|
||||
sessionId: string;
|
||||
action: ComposerSuggestionsAction;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export function invokedActions(verb?: string): InvokedAction[] {
|
||||
const { calls, invocationCallOrder } = fakeDesktop.invoke.mock;
|
||||
return calls.flatMap(([command, args], index) => {
|
||||
if (command !== "plugin_ui_invoke") return [];
|
||||
const action = args?.action as ComposerSuggestionsAction;
|
||||
if (verb && action.verb !== verb) return [];
|
||||
return [
|
||||
{
|
||||
sessionId: String(args?.sessionId),
|
||||
action,
|
||||
order: invocationCallOrder[index] ?? 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function commandCalls(command: string): unknown[] {
|
||||
return fakeDesktop.invoke.mock.calls
|
||||
.filter(([name]) => name === command)
|
||||
.map(([, args]) => args);
|
||||
}
|
||||
|
||||
/** Every action the hook sent must survive the hub's parser, or it would come back `rejected`. */
|
||||
export function expectEveryActionParses(): void {
|
||||
for (const { action } of invokedActions()) {
|
||||
expect(parsePluginUiAction(SLOT, action)).toMatchObject({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
id: string,
|
||||
content: string,
|
||||
runCount?: number,
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
sessionId: SESSION_ID,
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: 1,
|
||||
...(runCount === undefined ? {} : { meta: { runCount } }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `queue` reads the pane's queue as last rendered; the default removal takes from it, as the sidecar does. */
|
||||
export function defaultOptions(
|
||||
queue: () => readonly PromptInQueue[],
|
||||
@@ -354,6 +204,10 @@ export async function typeInto(
|
||||
await settle();
|
||||
}
|
||||
|
||||
export function options(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
|
||||
export function legendButton(
|
||||
container: HTMLElement,
|
||||
verb: string,
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SuggestionsLocal } from "@cline/shared/browser";
|
||||
|
||||
export function msSinceExposed(local: SuggestionsLocal): {
|
||||
msSinceExposed?: number;
|
||||
} {
|
||||
return local.readyAt === undefined
|
||||
? {}
|
||||
: { msSinceExposed: Math.max(0, Math.round(Date.now() - local.readyAt)) };
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<undefined> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
ComposerSuggestionItem,
|
||||
ComposerSuggestionsAction,
|
||||
PluginUiInput,
|
||||
PluginUiInvokeResult,
|
||||
PluginUiStateEnvelope,
|
||||
SuggestionsLocal,
|
||||
SuggestionsLocalEvent,
|
||||
SuggestionsView,
|
||||
} from "@cline/shared/browser";
|
||||
import type { Dispatch } from "react";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
|
||||
export type SuggestionsEnvelope = PluginUiStateEnvelope<"composer.suggestions">;
|
||||
export type SuggestionsResult = PluginUiInvokeResult<"composer.suggestions">;
|
||||
export type ReadyView = Extract<SuggestionsView, { mode: "ready" }>;
|
||||
|
||||
/** The message box the tray fills and empties; owned by the composer. */
|
||||
export type SuggestionComposer = {
|
||||
draft(): string;
|
||||
setDraft(value: string): void;
|
||||
focus(): void;
|
||||
};
|
||||
|
||||
/** What the tray's view depends on that only the composer knows. */
|
||||
export type SuggestionComposerFacts = {
|
||||
draft: string;
|
||||
width: number;
|
||||
/** The slash or @ menu is open over the message box. */
|
||||
menuOpen: boolean;
|
||||
/** A dialog is open over the page. */
|
||||
dialogOpen: boolean;
|
||||
};
|
||||
|
||||
export type ComposerSuggestionsOptions = {
|
||||
/** Local execution only: an SSH or cloud pane never gets or attaches. */
|
||||
enabled: boolean;
|
||||
environmentId: string;
|
||||
sessionId: string | null;
|
||||
status: ChatSessionStatus;
|
||||
/** The displayed chat is still loading; its placeholder `starting` status is not a run. */
|
||||
loadingChat: boolean;
|
||||
/** A tool approval or a question is waiting on the person. */
|
||||
waiting: boolean;
|
||||
messages: ChatMessage[];
|
||||
promptsInQueue: readonly PromptInQueue[];
|
||||
onSend: (prompt: string) => void;
|
||||
/** Forks the session from before `runCount` with `content` as the new draft. */
|
||||
onUndoTurn: (
|
||||
messageId: string,
|
||||
content: string,
|
||||
runCount: number,
|
||||
) => Promise<void>;
|
||||
/** Resolves the prompt it took out of the queue, or undefined when it was no longer queued. */
|
||||
onRemoveQueuedPrompt: (
|
||||
promptId: string,
|
||||
) => Promise<PromptInQueue | undefined>;
|
||||
abort: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ComposerSuggestionsController = {
|
||||
enabled: boolean;
|
||||
/** A run is in progress; Esc belongs to its abort then. */
|
||||
busy: boolean;
|
||||
local: SuggestionsLocal;
|
||||
/** The clock renders derive with; key and button handlers pass `Date.now()`. */
|
||||
now: number;
|
||||
/** The item whose one-key Send is waiting for its verdict. */
|
||||
sendingItemId?: string;
|
||||
/** A skipped row can be brought back from the More menu. */
|
||||
canUnskip: boolean;
|
||||
view(facts: SuggestionComposerFacts, now?: number): SuggestionsView;
|
||||
/**
|
||||
* Render feedback: starts the exposure clock while a ready view is on
|
||||
* screen, stops it otherwise, and reports exposure once it has run.
|
||||
*/
|
||||
reportView(view: SuggestionsView, onScreen: boolean): void;
|
||||
move(delta: 1 | -1): void;
|
||||
highlight(itemId: string): void;
|
||||
use(
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
composer: SuggestionComposer,
|
||||
): void;
|
||||
/** One-key Send of the highlighted row, or `signal key_inert` when it may not be sent yet. */
|
||||
send(view: ReadyView, input: PluginUiInput): Promise<void>;
|
||||
skip(
|
||||
view: ReadyView,
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
): void;
|
||||
refresh(input: PluginUiInput): void;
|
||||
unskip(input: PluginUiInput): void;
|
||||
/** Hide for this turn: collapse here, and tell the plugin it was ignored. */
|
||||
collapse(input: PluginUiInput): void;
|
||||
hideForChat(input: PluginUiInput): void;
|
||||
show(input: PluginUiInput): void;
|
||||
clear(input: PluginUiInput, composer: SuggestionComposer): void;
|
||||
undoSent(composer: SuggestionComposer): Promise<void>;
|
||||
/** The composer is submitting `prompt` through its own send path. */
|
||||
submitted(prompt: string): void;
|
||||
/** The box emptied while it held a used suggestion; tells the plugin, as Esc's Clear does. */
|
||||
draftCleared(): void;
|
||||
};
|
||||
|
||||
export type SuggestionsDispatch = Dispatch<SuggestionsLocalEvent>;
|
||||
|
||||
export type SuggestionsInvoke = (
|
||||
action: ComposerSuggestionsAction,
|
||||
options?: { applyReply?: boolean },
|
||||
) => Promise<SuggestionsResult | undefined>;
|
||||
|
||||
export type LastSkip = { revision: string; itemId: string };
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { exposedSuggestionMemory } from "./exposed-memory";
|
||||
import { fakeDesktop } from "./test-support/fake-client";
|
||||
import {
|
||||
commandCalls,
|
||||
deferred,
|
||||
ENVIRONMENT_ID,
|
||||
envelope,
|
||||
expectEveryActionParses,
|
||||
ITEMS,
|
||||
invokedActions,
|
||||
OLDER_SESSION_ID,
|
||||
READY_R1,
|
||||
readyState,
|
||||
SESSION_ID,
|
||||
scriptHub,
|
||||
} from "./test-support/fixtures";
|
||||
import {
|
||||
emitState,
|
||||
highlightedPrompt,
|
||||
mountTray,
|
||||
options,
|
||||
settle,
|
||||
stubBrowserApis,
|
||||
unmountAll,
|
||||
} from "./test-support/harness";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/desktop-client", async () => {
|
||||
const { fakeDesktop } = await import("./test-support/fake-client");
|
||||
return {
|
||||
desktopClient: fakeDesktop.client,
|
||||
openExternalUrl: fakeDesktop.openExternalUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-toast", () => ({
|
||||
toast: toastMock,
|
||||
useToast: () => ({ toasts: [], toast: toastMock, dismiss: vi.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
stubBrowserApis();
|
||||
vi.useFakeTimers();
|
||||
fakeDesktop.reset();
|
||||
exposedSuggestionMemory.clear();
|
||||
toastMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountAll();
|
||||
expectEveryActionParses();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useComposerSuggestions", () => {
|
||||
it("binds to the displayed session: gets its states, then attaches from the desktop", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([
|
||||
{ environmentId: ENVIRONMENT_ID, sessionId: SESSION_ID },
|
||||
]);
|
||||
expect(invokedActions("attach")).toMatchObject([
|
||||
{ sessionId: SESSION_ID, action: { verb: "attach", surface: "desktop" } },
|
||||
]);
|
||||
const region = tray.container.querySelector(
|
||||
'[data-testid="suggestion-tray"]',
|
||||
);
|
||||
expect(region?.tagName).toBe("SECTION");
|
||||
expect(region?.getAttribute("aria-label")).toBe(
|
||||
"Next · after a command failed",
|
||||
);
|
||||
expect(
|
||||
region?.querySelector('[data-testid="suggestion-legend"]'),
|
||||
).not.toBeNull();
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[0]?.prompt);
|
||||
});
|
||||
|
||||
it("never gets or invokes for an SSH pane, and binds once the pane turns local", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray({
|
||||
enabled: false,
|
||||
environmentId: "ssh-devbox",
|
||||
});
|
||||
await settle(1_000);
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([]);
|
||||
expect(invokedActions()).toEqual([]);
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-tray-root"]'),
|
||||
).toBeNull();
|
||||
|
||||
await tray.update({ enabled: true, environmentId: ENVIRONMENT_ID });
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([
|
||||
{ environmentId: ENVIRONMENT_ID, sessionId: SESSION_ID },
|
||||
]);
|
||||
expect(invokedActions().map(({ action }) => action.verb)).toEqual([
|
||||
"attach",
|
||||
]);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("re-attaches once for each newer epoch a state carries", async () => {
|
||||
let attachEpoch = 100;
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "ok", epoch: attachEpoch }
|
||||
: undefined,
|
||||
});
|
||||
await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachEpoch = 200;
|
||||
await emitState(envelope(readyState("r2"), { epoch: 200, seq: 1 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r3"), { epoch: 200, seq: 2 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches once for the first state of a chat its attach found not yet running", async () => {
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? { outcome: "unavailable" } : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
await emitState(envelope(readyState("r1"), { epoch: 300, seq: 1 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r2"), { epoch: 300, seq: 2 }));
|
||||
await settle(1_000);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("re-attaches once within the epoch of an attach that timed out", async () => {
|
||||
let attachReply: unknown = { outcome: "timeout", epoch: 100 };
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? attachReply : undefined,
|
||||
});
|
||||
await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachReply = { outcome: "ok", epoch: 100 };
|
||||
await emitState(envelope(readyState("r2"), { seq: 2 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r3"), { seq: 3 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("after an attach that timed out with the plugin's state already held", () => {
|
||||
const dormant = envelope({
|
||||
status: "hidden",
|
||||
title: "Next",
|
||||
revision: "none",
|
||||
items: [],
|
||||
canRefresh: false,
|
||||
});
|
||||
|
||||
it("re-attaches once when the next run ends, when the timed-out reply carried the state", async () => {
|
||||
let attachReply: unknown = {
|
||||
outcome: "timeout",
|
||||
epoch: 100,
|
||||
state: dormant,
|
||||
};
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? attachReply : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachReply = { outcome: "ok", epoch: 100 };
|
||||
await tray.update({ status: "running" });
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches when the next run ends, when the state arrived before the reply", async () => {
|
||||
scriptHub({
|
||||
states: { [SESSION_ID]: [dormant] },
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "timeout", epoch: 100 }
|
||||
: undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches when the next run ends even after the state's own re-attach timed out", async () => {
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "timeout", epoch: 100 }
|
||||
: undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
await emitState(dormant);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides a set its restarted chat no longer has, through the next turn", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
|
||||
await emitState({
|
||||
...envelope(null, { epoch: 200, seq: 0 }),
|
||||
plugin: "",
|
||||
});
|
||||
await tray.update({ status: "running" });
|
||||
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-working"]'),
|
||||
).toBeNull();
|
||||
expect(options(tray.container)).toHaveLength(0);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("binds again when the desktop transport reconnects", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
await mountTray();
|
||||
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await settle();
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toHaveLength(2);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores a detach reply for the previous session that lands after a switch to an older one", async () => {
|
||||
const detachReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: {
|
||||
[SESSION_ID]: [envelope(readyState("rb"), { epoch: 200 })],
|
||||
[OLDER_SESSION_ID]: [
|
||||
envelope(readyState("ra", { items: [ITEMS[1]] as never }), {
|
||||
sessionId: OLDER_SESSION_ID,
|
||||
}),
|
||||
],
|
||||
},
|
||||
reply: (sessionId, action) => {
|
||||
if (action.verb === "detach" && sessionId === SESSION_ID)
|
||||
return detachReply.promise;
|
||||
if (action.verb === "attach")
|
||||
return { outcome: "ok", epoch: sessionId === SESSION_ID ? 200 : 100 };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const tray = await mountTray();
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[1]?.prompt);
|
||||
|
||||
await act(async () =>
|
||||
detachReply.resolve({
|
||||
outcome: "unavailable",
|
||||
epoch: 200,
|
||||
state: envelope(readyState("rb2"), { epoch: 200, seq: 9 }),
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("detach")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[1]?.prompt);
|
||||
expect(options(tray.container)).toHaveLength(1);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never attaches a chat left while its get was pending", async () => {
|
||||
const get = deferred<unknown>();
|
||||
scriptHub();
|
||||
const defaultReply = fakeDesktop.invoke.getMockImplementation();
|
||||
fakeDesktop.invoke.mockImplementation(async (command, args) =>
|
||||
command === "plugin_ui_get" ? get.promise : defaultReply?.(command, args),
|
||||
);
|
||||
const tray = await mountTray();
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () => get.resolve([envelope(readyState("r1"))]));
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("attach")).toEqual([]);
|
||||
expect(invokedActions("detach")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(invokedActions()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ComposerSuggestionsAction,
|
||||
type PluginUiInvokeOutcome,
|
||||
readPluginUiEnvelope,
|
||||
} from "@cline/shared/browser";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { eventEnvironmentId } from "@/lib/session-identity";
|
||||
import { exposedSuggestionMemory } from "./exposed-memory";
|
||||
import type {
|
||||
LastSkip,
|
||||
SuggestionsDispatch,
|
||||
SuggestionsEnvelope,
|
||||
SuggestionsResult,
|
||||
} from "./types";
|
||||
|
||||
const SLOT = "composer.suggestions";
|
||||
|
||||
const INVOKE_OUTCOMES: ReadonlySet<string> = new Set<PluginUiInvokeOutcome>([
|
||||
"ok",
|
||||
"refused",
|
||||
"rejected",
|
||||
"unavailable",
|
||||
"timeout",
|
||||
]);
|
||||
|
||||
function readSuggestionsEnvelope(
|
||||
value: unknown,
|
||||
): SuggestionsEnvelope | undefined {
|
||||
const envelope = readPluginUiEnvelope(value);
|
||||
return envelope?.slot === SLOT ? envelope : undefined;
|
||||
}
|
||||
|
||||
function readInvokeResult(value: unknown): SuggestionsResult {
|
||||
const record =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const { outcome, reason, epoch } = record;
|
||||
if (typeof outcome !== "string" || !INVOKE_OUTCOMES.has(outcome)) {
|
||||
return { outcome: "unavailable" };
|
||||
}
|
||||
const state = readSuggestionsEnvelope(record.state);
|
||||
return {
|
||||
outcome: outcome as PluginUiInvokeOutcome,
|
||||
...(reason === "stale" || reason === "unsendable" ? { reason } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(typeof epoch === "number" && Number.isFinite(epoch) ? { epoch } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSlotBinding({
|
||||
enabled,
|
||||
environmentId,
|
||||
sessionId,
|
||||
busy,
|
||||
attachDue,
|
||||
attachDueFor,
|
||||
dispatch,
|
||||
setLastSkip,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
environmentId: string;
|
||||
sessionId: string | null;
|
||||
busy: boolean;
|
||||
attachDue: boolean;
|
||||
attachDueFor: number | undefined;
|
||||
dispatch: SuggestionsDispatch;
|
||||
setLastSkip: Dispatch<SetStateAction<LastSkip | undefined>>;
|
||||
}) {
|
||||
const [connectedEpoch, setConnectedEpoch] = useState(0);
|
||||
|
||||
const busyRef = useRef(busy);
|
||||
busyRef.current = busy;
|
||||
/**
|
||||
* The session verbs go to; empty while unbound or disabled. An unmount
|
||||
* leaves it set, so a Send the person already made still reaches the chat they left.
|
||||
*/
|
||||
const boundRef = useRef("");
|
||||
/** Bumped on every bind and unbind, so a reply to an earlier bind is never applied. */
|
||||
const generationRef = useRef(0);
|
||||
/** The due re-attach already sent (its epoch, or null without one); undefined while none is due. */
|
||||
const attachingForRef = useRef<number | null | undefined>(undefined);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribeTransportState((state) => {
|
||||
if (state !== "connected") return;
|
||||
setConnectedEpoch((epoch) => epoch + 1);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const invokeFor = useCallback(
|
||||
async (
|
||||
targetSessionId: string,
|
||||
action: ComposerSuggestionsAction,
|
||||
): Promise<SuggestionsResult> => {
|
||||
try {
|
||||
return readInvokeResult(
|
||||
await desktopClient.invoke("plugin_ui_invoke", {
|
||||
environmentId,
|
||||
sessionId: targetSessionId,
|
||||
slot: SLOT,
|
||||
action,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return { outcome: "unavailable" };
|
||||
}
|
||||
},
|
||||
[environmentId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Invokes on the bound session, or resolves undefined while unbound. The
|
||||
* reply reaches the reducer only while that bind lasts; the caller always
|
||||
* gets the plugin's verdict, so a refusal still stops a Send whose pane left.
|
||||
*/
|
||||
const invoke = useCallback(
|
||||
async (
|
||||
action: ComposerSuggestionsAction,
|
||||
{ applyReply = true }: { applyReply?: boolean } = {},
|
||||
): Promise<SuggestionsResult | undefined> => {
|
||||
const targetSessionId = boundRef.current;
|
||||
const generation = generationRef.current;
|
||||
if (!targetSessionId) return undefined;
|
||||
const result = await invokeFor(targetSessionId, action);
|
||||
if (applyReply && generation === generationRef.current) {
|
||||
dispatch({
|
||||
type: "reply",
|
||||
sessionId: targetSessionId,
|
||||
result,
|
||||
now: Date.now(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[dispatch, invokeFor],
|
||||
);
|
||||
|
||||
const attach = useCallback(
|
||||
async (targetSessionId: string, generation: number) => {
|
||||
const result = await invokeFor(targetSessionId, {
|
||||
verb: "attach",
|
||||
surface: "desktop",
|
||||
});
|
||||
if (generation !== generationRef.current) return;
|
||||
dispatch({
|
||||
type: "reply",
|
||||
sessionId: targetSessionId,
|
||||
result,
|
||||
now: Date.now(),
|
||||
});
|
||||
dispatch({
|
||||
type: "attached",
|
||||
sessionId: targetSessionId,
|
||||
outcome: result.outcome,
|
||||
epoch: result.epoch,
|
||||
});
|
||||
},
|
||||
[dispatch, invokeFor],
|
||||
);
|
||||
|
||||
const live = enabled && Boolean(sessionId) && connectedEpoch > 0;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: connectedEpoch rebinds on every transport reconnect
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
const generation = generationRef.current;
|
||||
const boundSessionId = live && sessionId ? sessionId : "";
|
||||
boundRef.current = boundSessionId;
|
||||
attachingForRef.current = undefined;
|
||||
setLastSkip(undefined);
|
||||
dispatch({ type: "bind", sessionId: sessionId ?? "" });
|
||||
if (sessionId) {
|
||||
for (const revision of exposedSuggestionMemory.revisions(
|
||||
environmentId,
|
||||
sessionId,
|
||||
)) {
|
||||
dispatch({ type: "exposed", revision });
|
||||
}
|
||||
}
|
||||
if (!boundSessionId) return;
|
||||
void (async () => {
|
||||
let states: unknown;
|
||||
try {
|
||||
states = await desktopClient.invoke("plugin_ui_get", {
|
||||
environmentId,
|
||||
sessionId: boundSessionId,
|
||||
});
|
||||
} catch {
|
||||
states = [];
|
||||
}
|
||||
if (generation !== generationRef.current) return;
|
||||
for (const value of Array.isArray(states) ? states : []) {
|
||||
const envelope = readSuggestionsEnvelope(value);
|
||||
if (envelope) dispatch({ type: "state", envelope, now: Date.now() });
|
||||
}
|
||||
if (busyRef.current) dispatch({ type: "run_started" });
|
||||
await attach(boundSessionId, generation);
|
||||
})();
|
||||
return () => {
|
||||
generationRef.current += 1;
|
||||
void invokeFor(boundSessionId, { verb: "detach" });
|
||||
};
|
||||
}, [attach, connectedEpoch, environmentId, invokeFor, live, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return desktopClient.subscribe("plugin_ui_state", (payload) => {
|
||||
if (eventEnvironmentId(payload) !== environmentId) return;
|
||||
const record = payload as { sessionId?: unknown; envelope?: unknown };
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!boundSessionId || record.sessionId !== boundSessionId) return;
|
||||
const envelope = readSuggestionsEnvelope(record.envelope);
|
||||
if (envelope) dispatch({ type: "state", envelope, now: Date.now() });
|
||||
});
|
||||
}, [dispatch, enabled, environmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachDue) {
|
||||
attachingForRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
const due = attachDueFor ?? null;
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!boundSessionId || attachingForRef.current === due) return;
|
||||
attachingForRef.current = due;
|
||||
void attach(boundSessionId, generationRef.current);
|
||||
}, [attach, attachDue, attachDueFor]);
|
||||
|
||||
return { invoke, boundRef };
|
||||
}
|
||||
+13
-13
@@ -2,36 +2,36 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { exposedSuggestionMemory } from "./use-composer-suggestions";
|
||||
import { fakeDesktop } from "./use-composer-suggestions.fake-client";
|
||||
import { exposedSuggestionMemory } from "./exposed-memory";
|
||||
import { fakeDesktop } from "./test-support/fake-client";
|
||||
import {
|
||||
click,
|
||||
deferred,
|
||||
emitState,
|
||||
envelope,
|
||||
expectEveryActionParses,
|
||||
highlightedPrompt,
|
||||
ITEMS,
|
||||
invokedActions,
|
||||
type MountedTray,
|
||||
mountTray,
|
||||
press,
|
||||
readyState,
|
||||
SESSION_ID,
|
||||
scriptHub,
|
||||
userMessage,
|
||||
} from "./test-support/fixtures";
|
||||
import {
|
||||
click,
|
||||
emitState,
|
||||
highlightedPrompt,
|
||||
type MountedTray,
|
||||
mountTray,
|
||||
press,
|
||||
settle,
|
||||
stubBrowserApis,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
userMessage,
|
||||
} from "./use-composer-suggestions.harness";
|
||||
} from "./test-support/harness";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/desktop-client", async () => {
|
||||
const { fakeDesktop } = await import(
|
||||
"./use-composer-suggestions.fake-client"
|
||||
);
|
||||
const { fakeDesktop } = await import("./test-support/fake-client");
|
||||
return {
|
||||
desktopClient: fakeDesktop.client,
|
||||
openExternalUrl: fakeDesktop.openExternalUrl,
|
||||
+2
-2
@@ -6,11 +6,11 @@ import {
|
||||
isMacPlatform,
|
||||
type SuggestionVerb,
|
||||
suggestionDigitVerb,
|
||||
} from "@/components/views/chat/suggestion-tray/suggestion-tray-keycaps";
|
||||
} from "@/components/views/chat/suggestion-tray/keycaps";
|
||||
import type {
|
||||
ComposerSuggestionsController,
|
||||
SuggestionComposer,
|
||||
} from "@/hooks/use-composer-suggestions";
|
||||
} from "./types";
|
||||
|
||||
export type SuggestionTrayKeysOptions = {
|
||||
controller: ComposerSuggestionsController;
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SUGGESTION_SEND_VERDICT_MS,
|
||||
type SuggestionsLocal,
|
||||
} from "@cline/shared/browser";
|
||||
import { type RefObject, useCallback, useRef } from "react";
|
||||
import { BUSY_STATUSES } from "@/hooks/use-chat-session";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { findSentMessage, samePrompt, takenBackDraft } from "./sent-prompt";
|
||||
import { delay } from "./timing";
|
||||
import type {
|
||||
ComposerSuggestionsOptions,
|
||||
SuggestionComposer,
|
||||
SuggestionsDispatch,
|
||||
SuggestionsInvoke,
|
||||
} from "./types";
|
||||
|
||||
/** ⌘3 waits this long for a stopped run to go idle before it forks. */
|
||||
const STOP_WAIT_MS = 5_000;
|
||||
const STOP_POLL_MS = 50;
|
||||
const STILL_STOPPING = "Still stopping — try again";
|
||||
/** The queue would not give the prompt back: it already started, or the sidecar does not know its id yet. */
|
||||
const NOT_TAKEN_BACK = "Couldn't take it back yet — try again";
|
||||
|
||||
export function useUndoSent({
|
||||
status,
|
||||
onUndoTurn,
|
||||
invoke,
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
}: {
|
||||
status: ChatSessionStatus;
|
||||
onUndoTurn: ComposerSuggestionsOptions["onUndoTurn"];
|
||||
invoke: SuggestionsInvoke;
|
||||
localRef: RefObject<SuggestionsLocal>;
|
||||
latest: RefObject<ComposerSuggestionsOptions>;
|
||||
dispatch: SuggestionsDispatch;
|
||||
}) {
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
const onUndoTurnRef = useRef(onUndoTurn);
|
||||
onUndoTurnRef.current = onUndoTurn;
|
||||
/** An Undo is taking the sent prompt back; another press then does nothing. */
|
||||
const undoInFlightRef = useRef(false);
|
||||
|
||||
/**
|
||||
* A sent prompt the queue still holds comes back into the box with no verb,
|
||||
* since no turn ran. Otherwise, including when the runtime started it before
|
||||
* the removal landed, the turn is forked away from before the sent message,
|
||||
* after stopping a run the fork would refuse.
|
||||
*/
|
||||
const undoSent = useCallback(
|
||||
async (composer: SuggestionComposer) => {
|
||||
const sent = localRef.current.sent;
|
||||
if (!sent || undoInFlightRef.current) return;
|
||||
undoInFlightRef.current = true;
|
||||
const { abort, onRemoveQueuedPrompt, promptsInQueue } = latest.current;
|
||||
try {
|
||||
const queuedPrompt = promptsInQueue.find((item) =>
|
||||
samePrompt(item.prompt, sent.prompt),
|
||||
);
|
||||
/** A prompt still queued has no message of its own, so a match now is an earlier copy. */
|
||||
let earlierCopyId: string | undefined;
|
||||
if (queuedPrompt) {
|
||||
earlierCopyId = findSentMessage(
|
||||
latest.current.messages,
|
||||
sent.prompt,
|
||||
)?.messageId;
|
||||
if (await onRemoveQueuedPrompt(queuedPrompt.id)) {
|
||||
composer.setDraft(takenBackDraft(sent.prompt, composer.draft()));
|
||||
composer.focus();
|
||||
dispatch({ type: "unsent" });
|
||||
return;
|
||||
}
|
||||
if (localRef.current.sent !== sent) return;
|
||||
}
|
||||
const target = findSentMessage(latest.current.messages, sent.prompt);
|
||||
if (!target || target.messageId === earlierCopyId) {
|
||||
if (queuedPrompt) toast({ title: NOT_TAKEN_BACK });
|
||||
return;
|
||||
}
|
||||
if (BUSY_STATUSES.has(statusRef.current)) {
|
||||
void abort();
|
||||
const deadline = Date.now() + STOP_WAIT_MS;
|
||||
while (
|
||||
BUSY_STATUSES.has(statusRef.current) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await delay(STOP_POLL_MS);
|
||||
}
|
||||
if (BUSY_STATUSES.has(statusRef.current)) {
|
||||
toast({ title: STILL_STOPPING });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Promise.race([
|
||||
invoke({
|
||||
verb: "undo",
|
||||
revision: sent.revision,
|
||||
itemId: sent.itemId,
|
||||
}),
|
||||
delay(SUGGESTION_SEND_VERDICT_MS),
|
||||
]);
|
||||
await onUndoTurnRef.current(
|
||||
target.messageId,
|
||||
sent.prompt,
|
||||
target.runCount,
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not undo the suggestion",
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
undoInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[dispatch, invoke, latest, localRef],
|
||||
);
|
||||
|
||||
return undoSent;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { exposedSuggestionMemory } from "./exposed-memory";
|
||||
import { fakeDesktop } from "./test-support/fake-client";
|
||||
import {
|
||||
deferred,
|
||||
envelope,
|
||||
expectEveryActionParses,
|
||||
ITEMS,
|
||||
invokedActions,
|
||||
OLDER_SESSION_ID,
|
||||
READY_R1,
|
||||
readyState,
|
||||
SEND_KEYS,
|
||||
SESSION_ID,
|
||||
scriptHub,
|
||||
userMessage,
|
||||
} from "./test-support/fixtures";
|
||||
import {
|
||||
click,
|
||||
emitState,
|
||||
legendButton,
|
||||
mountTray,
|
||||
options,
|
||||
pointerDown,
|
||||
press,
|
||||
settle,
|
||||
stubBrowserApis,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from "./test-support/harness";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/desktop-client", async () => {
|
||||
const { fakeDesktop } = await import("./test-support/fake-client");
|
||||
return {
|
||||
desktopClient: fakeDesktop.client,
|
||||
openExternalUrl: fakeDesktop.openExternalUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-toast", () => ({
|
||||
toast: toastMock,
|
||||
useToast: () => ({ toasts: [], toast: toastMock, dismiss: vi.fn() }),
|
||||
}));
|
||||
|
||||
const USE_KEYS = { code: "Digit1", key: "1", ctrlKey: true };
|
||||
|
||||
function menuItem(label: string): HTMLElement {
|
||||
const item = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((element) => element.textContent?.includes(label));
|
||||
expect(item).toBeDefined();
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubBrowserApis();
|
||||
vi.useFakeTimers();
|
||||
fakeDesktop.reset();
|
||||
exposedSuggestionMemory.clear();
|
||||
toastMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountAll();
|
||||
expectEveryActionParses();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useComposerSuggestions", () => {
|
||||
it("sends pointer from the key row's Skip and the More menu's New set, and key from Ctrl+2", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), { code: "Digit2", key: "2", ctrlKey: true });
|
||||
await click(legendButton(tray.container, "skip"));
|
||||
await pointerDown(
|
||||
tray.container.querySelector(
|
||||
'[data-testid="suggestion-more"]',
|
||||
) as Element,
|
||||
);
|
||||
await click(menuItem("Suggest different ones"));
|
||||
|
||||
expect(invokedActions("skip").map(({ action }) => action)).toMatchObject([
|
||||
{ verb: "skip", revision: "r1", itemId: "verify", input: "key" },
|
||||
{ verb: "skip", revision: "r1", itemId: "review", input: "pointer" },
|
||||
]);
|
||||
expect(invokedActions("refresh").map(({ action }) => action)).toEqual([
|
||||
{ verb: "refresh", revision: "r1", input: "pointer", cause: "new_set" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a one-key Send from the suggestion captured at the press, even when a newer state lands before the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await emitState(
|
||||
envelope(readyState("r2", { items: [ITEMS[2]] as never }), { seq: 2 }),
|
||||
);
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("send")).toMatchObject([
|
||||
{ action: { verb: "send", revision: "r1", itemId: "verify" } },
|
||||
]);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
|
||||
await tray.update({
|
||||
status: "running",
|
||||
messages: [userMessage("m1", ITEMS[0]?.prompt ?? "")],
|
||||
});
|
||||
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-sent"]')
|
||||
?.textContent,
|
||||
).toContain("Cline is working…");
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-undo"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a second Send, by key or by button, while the first waits for its verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await settle(50);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await click(legendButton(tray.container, "send"));
|
||||
expect(
|
||||
legendButton(tray.container, "send").getAttribute("aria-disabled"),
|
||||
).toBe("true");
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(invokedActions("signal")).toEqual([]);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
});
|
||||
|
||||
it("sends no clear for a refused Use whose box was edited before the refusal landed", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
expect(tray.composer().value).toBe(`${ITEMS[0]?.prompt} please`);
|
||||
await typeInto(tray.composer(), "");
|
||||
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the plugin when the box holding a used suggestion is emptied, edited or not", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), "");
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
await typeInto(tray.composer(), "");
|
||||
|
||||
expect(invokedActions("clear").map(({ action }) => action)).toEqual([
|
||||
{ verb: "clear", revision: "r1", itemId: "verify" },
|
||||
{ verb: "clear", revision: "r1", itemId: "verify" },
|
||||
]);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("still sends a one-key Send to the chat its pane left while it waited for the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
});
|
||||
|
||||
it("sends nothing when the plugin refuses a one-key Send after its pane left", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () =>
|
||||
verdict.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the plugin refuses a one-key Send that waited across a transport reconnect", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await act(async () =>
|
||||
verdict.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the box alone when a Use refusal lands after the pane switched sessions", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(tray.composer().value).toBe(ITEMS[0]?.prompt);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("empties the box when a Use refusal lands after a transport reconnect on the same chat", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await settle();
|
||||
expect(tray.composer().value).toBe(ITEMS[0]?.prompt);
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(tray.composer().value).toBe("");
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a one-key Send whose pane switched to another session while it waited for the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a used suggestion submitted unchanged without awaiting the verb, and an edited one with none", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
|
||||
await press(tray.composer(), { code: "Digit1", key: "1", ctrlKey: true });
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-used"]')
|
||||
?.textContent,
|
||||
).toContain("From a suggestion");
|
||||
await press(tray.composer(), { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
expect(invokedActions("send").map(({ action }) => action)).toEqual([
|
||||
{ verb: "send", revision: "r1", itemId: "verify", input: "key" },
|
||||
]);
|
||||
|
||||
await emitState(envelope(readyState("r2"), { seq: 2 }));
|
||||
await press(tray.composer(), { code: "Digit1", key: "1", ctrlKey: true });
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-used"]')
|
||||
?.textContent,
|
||||
).toContain("Edited suggestion");
|
||||
await press(tray.composer(), { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(onSend).toHaveBeenLastCalledWith(`${ITEMS[0]?.prompt} please`);
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ComposerSuggestionItem,
|
||||
type PluginUiInput,
|
||||
SUGGESTION_SEND_VERDICT_MS,
|
||||
type SuggestionRef,
|
||||
type SuggestionsLocal,
|
||||
} from "@cline/shared/browser";
|
||||
import {
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { delay, msSinceExposed } from "./timing";
|
||||
import type {
|
||||
ComposerSuggestionsOptions,
|
||||
LastSkip,
|
||||
ReadyView,
|
||||
SuggestionComposer,
|
||||
SuggestionsDispatch,
|
||||
SuggestionsInvoke,
|
||||
} from "./types";
|
||||
|
||||
export function useSuggestionVerbs({
|
||||
invoke,
|
||||
boundRef,
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
lastSkip,
|
||||
setLastSkip,
|
||||
setSendingItemId,
|
||||
}: {
|
||||
invoke: SuggestionsInvoke;
|
||||
boundRef: RefObject<string>;
|
||||
localRef: RefObject<SuggestionsLocal>;
|
||||
latest: RefObject<ComposerSuggestionsOptions>;
|
||||
dispatch: SuggestionsDispatch;
|
||||
lastSkip: LastSkip | undefined;
|
||||
setLastSkip: Dispatch<SetStateAction<LastSkip | undefined>>;
|
||||
setSendingItemId: Dispatch<SetStateAction<string | undefined>>;
|
||||
}) {
|
||||
/** A one-key Send is waiting for its verdict; another press then sends nothing. */
|
||||
const sendInFlightRef = useRef(false);
|
||||
|
||||
const signalInert = useCallback(
|
||||
(reason: "not_exposed" | "unsendable" | "stale") => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
void invoke(
|
||||
{
|
||||
verb: "signal",
|
||||
name: "key_inert",
|
||||
reason,
|
||||
...(revision ? { revision } : {}),
|
||||
},
|
||||
{ applyReply: false },
|
||||
);
|
||||
},
|
||||
[invoke, localRef],
|
||||
);
|
||||
|
||||
const use = useCallback(
|
||||
(
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
composer: SuggestionComposer,
|
||||
) => {
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
composer.setDraft(item.prompt);
|
||||
composer.focus();
|
||||
dispatch({ type: "used", itemId: item.id });
|
||||
const boundSessionId = boundRef.current;
|
||||
void invoke({
|
||||
verb: "use",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
}).then((result) => {
|
||||
if (result?.outcome !== "refused") return;
|
||||
if (boundRef.current !== boundSessionId) return;
|
||||
const used = localRef.current.used;
|
||||
if (used?.itemId === item.id && used.revision === revision) {
|
||||
dispatch({ type: "draft_cleared" });
|
||||
}
|
||||
if (composer.draft() === item.prompt) composer.setDraft("");
|
||||
});
|
||||
},
|
||||
[boundRef, dispatch, invoke, localRef],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (view: ReadyView, input: PluginUiInput) => {
|
||||
if (sendInFlightRef.current) return;
|
||||
const item = view.highlighted;
|
||||
if (!view.canSend) {
|
||||
signalInert(item.sendable ? "not_exposed" : "unsendable");
|
||||
return;
|
||||
}
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!revision || !boundSessionId) return;
|
||||
const ref: SuggestionRef = {
|
||||
itemId: item.id,
|
||||
revision,
|
||||
prompt: item.prompt,
|
||||
label: item.label,
|
||||
tone: item.tone,
|
||||
};
|
||||
sendInFlightRef.current = true;
|
||||
setSendingItemId(item.id);
|
||||
try {
|
||||
const verdict = await Promise.race([
|
||||
invoke({
|
||||
verb: "send",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
}),
|
||||
delay(SUGGESTION_SEND_VERDICT_MS),
|
||||
]);
|
||||
if (verdict?.outcome === "refused") return;
|
||||
if (boundRef.current !== boundSessionId) return;
|
||||
latest.current.onSend(ref.prompt);
|
||||
dispatch({ type: "sent", ref });
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
setSendingItemId(undefined);
|
||||
}
|
||||
},
|
||||
[
|
||||
boundRef,
|
||||
dispatch,
|
||||
invoke,
|
||||
latest,
|
||||
localRef,
|
||||
setSendingItemId,
|
||||
signalInert,
|
||||
],
|
||||
);
|
||||
|
||||
const skip = useCallback(
|
||||
(view: ReadyView, item: ComposerSuggestionItem, input: PluginUiInput) => {
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
const at = Date.now();
|
||||
if (view.skipMeans === "more") {
|
||||
dispatch({ type: "refresh_pending", now: at });
|
||||
void invoke({ verb: "refresh", revision, input, cause: "more" });
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "skip_pending", itemId: item.id, now: at });
|
||||
setLastSkip({ revision, itemId: item.id });
|
||||
void invoke({
|
||||
verb: "skip",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
});
|
||||
},
|
||||
[dispatch, invoke, localRef, setLastSkip],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
dispatch({ type: "refresh_pending", now: Date.now() });
|
||||
void invoke({ verb: "refresh", revision, input, cause: "new_set" });
|
||||
},
|
||||
[dispatch, invoke, localRef],
|
||||
);
|
||||
|
||||
const unskip = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
if (!lastSkip) return;
|
||||
setLastSkip(undefined);
|
||||
void invoke({ verb: "unskip", ...lastSkip, input });
|
||||
},
|
||||
[invoke, lastSkip, setLastSkip],
|
||||
);
|
||||
|
||||
const collapse = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
dispatch({ type: "collapse" });
|
||||
void invoke(
|
||||
{
|
||||
verb: "hide",
|
||||
scope: "turn",
|
||||
input,
|
||||
...(revision ? { revision } : {}),
|
||||
},
|
||||
{ applyReply: false },
|
||||
);
|
||||
},
|
||||
[dispatch, invoke, localRef],
|
||||
);
|
||||
|
||||
const hideForChat = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
void invoke({
|
||||
verb: "hide",
|
||||
scope: "chat",
|
||||
input,
|
||||
...(revision ? { revision } : {}),
|
||||
});
|
||||
},
|
||||
[invoke, localRef],
|
||||
);
|
||||
|
||||
const show = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
void invoke({ verb: "show", input });
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const draftCleared = useCallback(() => {
|
||||
const used = localRef.current.used;
|
||||
if (!used) return;
|
||||
dispatch({ type: "draft_cleared" });
|
||||
void invoke(
|
||||
{ verb: "clear", revision: used.revision, itemId: used.itemId },
|
||||
{ applyReply: false },
|
||||
);
|
||||
}, [dispatch, invoke, localRef]);
|
||||
|
||||
const clear = useCallback(
|
||||
(_input: PluginUiInput, composer: SuggestionComposer) => {
|
||||
if (!localRef.current.used) return;
|
||||
composer.setDraft("");
|
||||
draftCleared();
|
||||
},
|
||||
[draftCleared, localRef],
|
||||
);
|
||||
|
||||
const submitted = useCallback(
|
||||
(prompt: string) => {
|
||||
const used = localRef.current.used;
|
||||
if (!used) return;
|
||||
if (prompt.trim() !== used.prompt.trim()) {
|
||||
dispatch({ type: "draft_cleared" });
|
||||
return;
|
||||
}
|
||||
void invoke({
|
||||
verb: "send",
|
||||
revision: used.revision,
|
||||
itemId: used.itemId,
|
||||
input: "key",
|
||||
});
|
||||
dispatch({ type: "sent" });
|
||||
},
|
||||
[dispatch, invoke, localRef],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
(delta: 1 | -1) => {
|
||||
dispatch({ type: "move", delta });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const highlight = useCallback(
|
||||
(itemId: string) => {
|
||||
dispatch({ type: "highlight", itemId });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return {
|
||||
use,
|
||||
send,
|
||||
skip,
|
||||
refresh,
|
||||
unskip,
|
||||
collapse,
|
||||
hideForChat,
|
||||
show,
|
||||
clear,
|
||||
submitted,
|
||||
draftCleared,
|
||||
move,
|
||||
highlight,
|
||||
};
|
||||
}
|
||||
@@ -10,39 +10,33 @@ import {
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { exposedSuggestionMemory } from "./use-composer-suggestions";
|
||||
import { fakeDesktop } from "./use-composer-suggestions.fake-client";
|
||||
import { exposedSuggestionMemory } from "./composer-suggestions/exposed-memory";
|
||||
import { fakeDesktop } from "./composer-suggestions/test-support/fake-client";
|
||||
import {
|
||||
click,
|
||||
commandCalls,
|
||||
deferred,
|
||||
ENVIRONMENT_ID,
|
||||
emitState,
|
||||
envelope,
|
||||
expectEveryActionParses,
|
||||
highlightedPrompt,
|
||||
ITEMS,
|
||||
invokedActions,
|
||||
legendButton,
|
||||
mountTray,
|
||||
OLDER_SESSION_ID,
|
||||
pointerDown,
|
||||
press,
|
||||
READY_R1,
|
||||
readyState,
|
||||
SESSION_ID,
|
||||
SEND_KEYS,
|
||||
scriptHub,
|
||||
} from "./composer-suggestions/test-support/fixtures";
|
||||
import {
|
||||
emitState,
|
||||
mountTray,
|
||||
options,
|
||||
press,
|
||||
settle,
|
||||
stubBrowserApis,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
userMessage,
|
||||
} from "./use-composer-suggestions.harness";
|
||||
} from "./composer-suggestions/test-support/harness";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/desktop-client", async () => {
|
||||
const { fakeDesktop } = await import(
|
||||
"./use-composer-suggestions.fake-client"
|
||||
"./composer-suggestions/test-support/fake-client"
|
||||
);
|
||||
return {
|
||||
desktopClient: fakeDesktop.client,
|
||||
@@ -55,22 +49,6 @@ vi.mock("@/hooks/use-toast", () => ({
|
||||
useToast: () => ({ toasts: [], toast: toastMock, dismiss: vi.fn() }),
|
||||
}));
|
||||
|
||||
const READY_R1 = { [SESSION_ID]: [envelope(readyState("r1"))] };
|
||||
const USE_KEYS = { code: "Digit1", key: "1", ctrlKey: true };
|
||||
const SEND_KEYS = { code: "Digit1", key: "!", ctrlKey: true, shiftKey: true };
|
||||
|
||||
function options(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
|
||||
function menuItem(label: string): HTMLElement {
|
||||
const item = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((element) => element.textContent?.includes(label));
|
||||
expect(item).toBeDefined();
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function exposedRevisions(): string[] {
|
||||
return invokedActions("exposed").flatMap(({ action }) =>
|
||||
"revision" in action && action.revision ? [action.revision] : [],
|
||||
@@ -93,141 +71,6 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("useComposerSuggestions", () => {
|
||||
it("binds to the displayed session: gets its states, then attaches from the desktop", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([
|
||||
{ environmentId: ENVIRONMENT_ID, sessionId: SESSION_ID },
|
||||
]);
|
||||
expect(invokedActions("attach")).toMatchObject([
|
||||
{ sessionId: SESSION_ID, action: { verb: "attach", surface: "desktop" } },
|
||||
]);
|
||||
const region = tray.container.querySelector(
|
||||
'[data-testid="suggestion-tray"]',
|
||||
);
|
||||
expect(region?.tagName).toBe("SECTION");
|
||||
expect(region?.getAttribute("aria-label")).toBe(
|
||||
"Next · after a command failed",
|
||||
);
|
||||
expect(
|
||||
region?.querySelector('[data-testid="suggestion-legend"]'),
|
||||
).not.toBeNull();
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[0]?.prompt);
|
||||
});
|
||||
|
||||
it("sends pointer from the key row's Skip and the More menu's New set, and key from Ctrl+2", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), { code: "Digit2", key: "2", ctrlKey: true });
|
||||
await click(legendButton(tray.container, "skip"));
|
||||
await pointerDown(
|
||||
tray.container.querySelector(
|
||||
'[data-testid="suggestion-more"]',
|
||||
) as Element,
|
||||
);
|
||||
await click(menuItem("Suggest different ones"));
|
||||
|
||||
expect(invokedActions("skip").map(({ action }) => action)).toMatchObject([
|
||||
{ verb: "skip", revision: "r1", itemId: "verify", input: "key" },
|
||||
{ verb: "skip", revision: "r1", itemId: "review", input: "pointer" },
|
||||
]);
|
||||
expect(invokedActions("refresh").map(({ action }) => action)).toEqual([
|
||||
{ verb: "refresh", revision: "r1", input: "pointer", cause: "new_set" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never gets or invokes for an SSH pane, and binds once the pane turns local", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray({
|
||||
enabled: false,
|
||||
environmentId: "ssh-devbox",
|
||||
});
|
||||
await settle(1_000);
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([]);
|
||||
expect(invokedActions()).toEqual([]);
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-tray-root"]'),
|
||||
).toBeNull();
|
||||
|
||||
await tray.update({ enabled: true, environmentId: ENVIRONMENT_ID });
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toEqual([
|
||||
{ environmentId: ENVIRONMENT_ID, sessionId: SESSION_ID },
|
||||
]);
|
||||
expect(invokedActions().map(({ action }) => action.verb)).toEqual([
|
||||
"attach",
|
||||
]);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("records a one-key Send from the suggestion captured at the press, even when a newer state lands before the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await emitState(
|
||||
envelope(readyState("r2", { items: [ITEMS[2]] as never }), { seq: 2 }),
|
||||
);
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("send")).toMatchObject([
|
||||
{ action: { verb: "send", revision: "r1", itemId: "verify" } },
|
||||
]);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
|
||||
await tray.update({
|
||||
status: "running",
|
||||
messages: [userMessage("m1", ITEMS[0]?.prompt ?? "")],
|
||||
});
|
||||
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-sent"]')
|
||||
?.textContent,
|
||||
).toContain("Cline is working…");
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-undo"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a second Send, by key or by button, while the first waits for its verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await settle(50);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await click(legendButton(tray.container, "send"));
|
||||
expect(
|
||||
legendButton(tray.container, "send").getAttribute("aria-disabled"),
|
||||
).toBe("true");
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(invokedActions("signal")).toEqual([]);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
});
|
||||
|
||||
it("keeps the exposure clock stopped while the pane is inert or the window hidden", async () => {
|
||||
scriptHub();
|
||||
const tray = await mountTray();
|
||||
@@ -298,145 +141,6 @@ describe("useComposerSuggestions", () => {
|
||||
expect(exposedRevisions()).toEqual(["r1"]);
|
||||
});
|
||||
|
||||
it("re-attaches once for each newer epoch a state carries", async () => {
|
||||
let attachEpoch = 100;
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "ok", epoch: attachEpoch }
|
||||
: undefined,
|
||||
});
|
||||
await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachEpoch = 200;
|
||||
await emitState(envelope(readyState("r2"), { epoch: 200, seq: 1 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r3"), { epoch: 200, seq: 2 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches once for the first state of a chat its attach found not yet running", async () => {
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? { outcome: "unavailable" } : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
await emitState(envelope(readyState("r1"), { epoch: 300, seq: 1 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r2"), { epoch: 300, seq: 2 }));
|
||||
await settle(1_000);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("re-attaches once within the epoch of an attach that timed out", async () => {
|
||||
let attachReply: unknown = { outcome: "timeout", epoch: 100 };
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? attachReply : undefined,
|
||||
});
|
||||
await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachReply = { outcome: "ok", epoch: 100 };
|
||||
await emitState(envelope(readyState("r2"), { seq: 2 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await emitState(envelope(readyState("r3"), { seq: 3 }));
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("after an attach that timed out with the plugin's state already held", () => {
|
||||
const dormant = envelope({
|
||||
status: "hidden",
|
||||
title: "Next",
|
||||
revision: "none",
|
||||
items: [],
|
||||
canRefresh: false,
|
||||
});
|
||||
|
||||
it("re-attaches once when the next run ends, when the timed-out reply carried the state", async () => {
|
||||
let attachReply: unknown = {
|
||||
outcome: "timeout",
|
||||
epoch: 100,
|
||||
state: dormant,
|
||||
};
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach" ? attachReply : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
attachReply = { outcome: "ok", epoch: 100 };
|
||||
await tray.update({ status: "running" });
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches when the next run ends, when the state arrived before the reply", async () => {
|
||||
scriptHub({
|
||||
states: { [SESSION_ID]: [dormant] },
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "timeout", epoch: 100 }
|
||||
: undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
expect(invokedActions("attach")).toHaveLength(1);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-attaches when the next run ends even after the state's own re-attach timed out", async () => {
|
||||
scriptHub({
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "attach"
|
||||
? { outcome: "timeout", epoch: 100 }
|
||||
: undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
await emitState(dormant);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
|
||||
await tray.update({ status: "running" });
|
||||
await tray.update({ status: "idle" });
|
||||
expect(invokedActions("attach")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides a set its restarted chat no longer has, through the next turn", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
|
||||
await emitState({
|
||||
...envelope(null, { epoch: 200, seq: 0 }),
|
||||
plugin: "",
|
||||
});
|
||||
await tray.update({ status: "running" });
|
||||
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-working"]'),
|
||||
).toBeNull();
|
||||
expect(options(tray.container)).toHaveLength(0);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a set fetched while the chat loads, since loading is not a run", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray({ status: "starting", loadingChat: true });
|
||||
@@ -476,18 +180,6 @@ describe("useComposerSuggestions", () => {
|
||||
expect(options(tray.container)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("binds again when the desktop transport reconnects", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
await mountTray();
|
||||
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await settle();
|
||||
|
||||
expect(commandCalls("plugin_ui_get")).toHaveLength(2);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("allows Send at readyAt + 400 and reports exposure then, on the tick alone", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const onSend = vi.fn();
|
||||
@@ -545,233 +237,6 @@ describe("useComposerSuggestions", () => {
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends no clear for a refused Use whose box was edited before the refusal landed", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
expect(tray.composer().value).toBe(`${ITEMS[0]?.prompt} please`);
|
||||
await typeInto(tray.composer(), "");
|
||||
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the plugin when the box holding a used suggestion is emptied, edited or not", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), "");
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
await typeInto(tray.composer(), "");
|
||||
|
||||
expect(invokedActions("clear").map(({ action }) => action)).toEqual([
|
||||
{ verb: "clear", revision: "r1", itemId: "verify" },
|
||||
{ verb: "clear", revision: "r1", itemId: "verify" },
|
||||
]);
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("ignores a detach reply for the previous session that lands after a switch to an older one", async () => {
|
||||
const detachReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: {
|
||||
[SESSION_ID]: [envelope(readyState("rb"), { epoch: 200 })],
|
||||
[OLDER_SESSION_ID]: [
|
||||
envelope(readyState("ra", { items: [ITEMS[1]] as never }), {
|
||||
sessionId: OLDER_SESSION_ID,
|
||||
}),
|
||||
],
|
||||
},
|
||||
reply: (sessionId, action) => {
|
||||
if (action.verb === "detach" && sessionId === SESSION_ID)
|
||||
return detachReply.promise;
|
||||
if (action.verb === "attach")
|
||||
return { outcome: "ok", epoch: sessionId === SESSION_ID ? 200 : 100 };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const tray = await mountTray();
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[1]?.prompt);
|
||||
|
||||
await act(async () =>
|
||||
detachReply.resolve({
|
||||
outcome: "unavailable",
|
||||
epoch: 200,
|
||||
state: envelope(readyState("rb2"), { epoch: 200, seq: 9 }),
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("detach")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(highlightedPrompt(tray.container)).toBe(ITEMS[1]?.prompt);
|
||||
expect(options(tray.container)).toHaveLength(1);
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never attaches a chat left while its get was pending", async () => {
|
||||
const get = deferred<unknown>();
|
||||
scriptHub();
|
||||
const defaultReply = fakeDesktop.invoke.getMockImplementation();
|
||||
fakeDesktop.invoke.mockImplementation(async (command, args) =>
|
||||
command === "plugin_ui_get" ? get.promise : defaultReply?.(command, args),
|
||||
);
|
||||
const tray = await mountTray();
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () => get.resolve([envelope(readyState("r1"))]));
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("attach")).toEqual([]);
|
||||
expect(invokedActions("detach")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(invokedActions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still sends a one-key Send to the chat its pane left while it waited for the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
});
|
||||
|
||||
it("sends nothing when the plugin refuses a one-key Send after its pane left", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.unmount();
|
||||
|
||||
await act(async () =>
|
||||
verdict.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the plugin refuses a one-key Send that waited across a transport reconnect", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await act(async () =>
|
||||
verdict.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the box alone when a Use refusal lands after the pane switched sessions", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(tray.composer().value).toBe(ITEMS[0]?.prompt);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("empties the box when a Use refusal lands after a transport reconnect on the same chat", async () => {
|
||||
const useReply = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "use" ? useReply.promise : undefined,
|
||||
});
|
||||
const tray = await mountTray();
|
||||
|
||||
await press(tray.composer(), USE_KEYS);
|
||||
await act(async () => fakeDesktop.setTransportState("reconnecting"));
|
||||
await act(async () => fakeDesktop.setTransportState("connected"));
|
||||
await settle();
|
||||
expect(tray.composer().value).toBe(ITEMS[0]?.prompt);
|
||||
await act(async () =>
|
||||
useReply.resolve({ outcome: "refused", reason: "stale" }),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(invokedActions("attach")).toHaveLength(2);
|
||||
expect(tray.composer().value).toBe("");
|
||||
expect(options(tray.container)).toHaveLength(3);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a one-key Send whose pane switched to another session while it waited for the verdict", async () => {
|
||||
const verdict = deferred<unknown>();
|
||||
scriptHub({
|
||||
states: READY_R1,
|
||||
reply: (_sessionId, action) =>
|
||||
action.verb === "send" ? verdict.promise : undefined,
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
await settle(400);
|
||||
await press(tray.composer(), SEND_KEYS);
|
||||
await tray.update({ sessionId: OLDER_SESSION_ID });
|
||||
|
||||
await act(async () => verdict.resolve({ outcome: "ok" }));
|
||||
await settle(300);
|
||||
|
||||
expect(invokedActions("send")).toMatchObject([{ sessionId: SESSION_ID }]);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports msSinceExposed as a non-negative integer", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const tray = await mountTray();
|
||||
@@ -790,35 +255,4 @@ describe("useComposerSuggestions", () => {
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends a used suggestion submitted unchanged without awaiting the verb, and an edited one with none", async () => {
|
||||
scriptHub({ states: READY_R1 });
|
||||
const onSend = vi.fn();
|
||||
const tray = await mountTray({ onSend });
|
||||
|
||||
await press(tray.composer(), { code: "Digit1", key: "1", ctrlKey: true });
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-used"]')
|
||||
?.textContent,
|
||||
).toContain("From a suggestion");
|
||||
await press(tray.composer(), { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(ITEMS[0]?.prompt);
|
||||
expect(invokedActions("send").map(({ action }) => action)).toEqual([
|
||||
{ verb: "send", revision: "r1", itemId: "verify", input: "key" },
|
||||
]);
|
||||
|
||||
await emitState(envelope(readyState("r2"), { seq: 2 }));
|
||||
await press(tray.composer(), { code: "Digit1", key: "1", ctrlKey: true });
|
||||
await typeInto(tray.composer(), `${ITEMS[0]?.prompt} please`);
|
||||
expect(
|
||||
tray.container.querySelector('[data-testid="suggestion-used"]')
|
||||
?.textContent,
|
||||
).toContain("Edited suggestion");
|
||||
await press(tray.composer(), { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(onSend).toHaveBeenLastCalledWith(`${ITEMS[0]?.prompt} please`);
|
||||
expect(invokedActions("send")).toHaveLength(1);
|
||||
expect(invokedActions("clear")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ComposerSuggestionItem,
|
||||
type ComposerSuggestionsAction,
|
||||
createSuggestionsLocal,
|
||||
deriveSuggestionsView,
|
||||
formatDisplayUserInput,
|
||||
type PluginUiInput,
|
||||
type PluginUiInvokeOutcome,
|
||||
type PluginUiInvokeResult,
|
||||
type PluginUiStateEnvelope,
|
||||
readPluginUiEnvelope,
|
||||
reduceSuggestionsLocal,
|
||||
SUGGESTION_BREAKPOINTS,
|
||||
SUGGESTION_EXPOSURE_MS,
|
||||
SUGGESTION_PENDING_UNDO_MS,
|
||||
SUGGESTION_SEND_VERDICT_MS,
|
||||
type SuggestionRef,
|
||||
type SuggestionsLocal,
|
||||
type SuggestionsView,
|
||||
} from "@cline/shared/browser";
|
||||
import {
|
||||
@@ -28,219 +17,20 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { buildUserRunCountMap } from "@/components/views/chat/messages/group-messages";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import { exposedSuggestionMemory } from "@/hooks/composer-suggestions/exposed-memory";
|
||||
import {
|
||||
findSentMessage,
|
||||
samePrompt,
|
||||
} from "@/hooks/composer-suggestions/sent-prompt";
|
||||
import type {
|
||||
ComposerSuggestionsController,
|
||||
ComposerSuggestionsOptions,
|
||||
LastSkip,
|
||||
} from "@/hooks/composer-suggestions/types";
|
||||
import { useSlotBinding } from "@/hooks/composer-suggestions/use-slot-binding";
|
||||
import { useUndoSent } from "@/hooks/composer-suggestions/use-undo-sent";
|
||||
import { useSuggestionVerbs } from "@/hooks/composer-suggestions/use-verbs";
|
||||
import { BUSY_STATUSES } from "@/hooks/use-chat-session";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { eventEnvironmentId } from "@/lib/session-identity";
|
||||
|
||||
const SLOT = "composer.suggestions";
|
||||
/** ⌘3 waits this long for a stopped run to go idle before it forks. */
|
||||
const STOP_WAIT_MS = 5_000;
|
||||
const STOP_POLL_MS = 50;
|
||||
const STILL_STOPPING = "Still stopping — try again";
|
||||
/** The queue would not give the prompt back: it already started, or the sidecar does not know its id yet. */
|
||||
const NOT_TAKEN_BACK = "Couldn't take it back yet — try again";
|
||||
/** Sessions, and revisions per session, the webview remembers as exposed; the view model keeps 32 revisions too. */
|
||||
const EXPOSED_SESSIONS_KEPT = 32;
|
||||
const EXPOSED_REVISIONS_KEPT = 32;
|
||||
|
||||
type SuggestionsEnvelope = PluginUiStateEnvelope<"composer.suggestions">;
|
||||
type SuggestionsResult = PluginUiInvokeResult<"composer.suggestions">;
|
||||
type ReadyView = Extract<SuggestionsView, { mode: "ready" }>;
|
||||
|
||||
/** The message box the tray fills and empties; owned by the composer. */
|
||||
export type SuggestionComposer = {
|
||||
draft(): string;
|
||||
setDraft(value: string): void;
|
||||
focus(): void;
|
||||
};
|
||||
|
||||
/** What the tray's view depends on that only the composer knows. */
|
||||
export type SuggestionComposerFacts = {
|
||||
draft: string;
|
||||
width: number;
|
||||
/** The slash or @ menu is open over the message box. */
|
||||
menuOpen: boolean;
|
||||
/** A dialog is open over the page. */
|
||||
dialogOpen: boolean;
|
||||
};
|
||||
|
||||
export type ComposerSuggestionsOptions = {
|
||||
/** Local execution only: an SSH or cloud pane never gets or attaches. */
|
||||
enabled: boolean;
|
||||
environmentId: string;
|
||||
sessionId: string | null;
|
||||
status: ChatSessionStatus;
|
||||
/** The displayed chat is still loading; its placeholder `starting` status is not a run. */
|
||||
loadingChat: boolean;
|
||||
/** A tool approval or a question is waiting on the person. */
|
||||
waiting: boolean;
|
||||
messages: ChatMessage[];
|
||||
promptsInQueue: readonly PromptInQueue[];
|
||||
onSend: (prompt: string) => void;
|
||||
/** Forks the session from before `runCount` with `content` as the new draft. */
|
||||
onUndoTurn: (
|
||||
messageId: string,
|
||||
content: string,
|
||||
runCount: number,
|
||||
) => Promise<void>;
|
||||
/** Resolves the prompt it took out of the queue, or undefined when it was no longer queued. */
|
||||
onRemoveQueuedPrompt: (
|
||||
promptId: string,
|
||||
) => Promise<PromptInQueue | undefined>;
|
||||
abort: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ComposerSuggestionsController = {
|
||||
enabled: boolean;
|
||||
/** A run is in progress; Esc belongs to its abort then. */
|
||||
busy: boolean;
|
||||
local: SuggestionsLocal;
|
||||
/** The clock renders derive with; key and button handlers pass `Date.now()`. */
|
||||
now: number;
|
||||
/** The item whose one-key Send is waiting for its verdict. */
|
||||
sendingItemId?: string;
|
||||
/** A skipped row can be brought back from the More menu. */
|
||||
canUnskip: boolean;
|
||||
view(facts: SuggestionComposerFacts, now?: number): SuggestionsView;
|
||||
/**
|
||||
* Render feedback: starts the exposure clock while a ready view is on
|
||||
* screen, stops it otherwise, and reports exposure once it has run.
|
||||
*/
|
||||
reportView(view: SuggestionsView, onScreen: boolean): void;
|
||||
move(delta: 1 | -1): void;
|
||||
highlight(itemId: string): void;
|
||||
use(
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
composer: SuggestionComposer,
|
||||
): void;
|
||||
/** One-key Send of the highlighted row, or `signal key_inert` when it may not be sent yet. */
|
||||
send(view: ReadyView, input: PluginUiInput): Promise<void>;
|
||||
skip(
|
||||
view: ReadyView,
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
): void;
|
||||
refresh(input: PluginUiInput): void;
|
||||
unskip(input: PluginUiInput): void;
|
||||
/** Hide for this turn: collapse here, and tell the plugin it was ignored. */
|
||||
collapse(input: PluginUiInput): void;
|
||||
hideForChat(input: PluginUiInput): void;
|
||||
show(input: PluginUiInput): void;
|
||||
clear(input: PluginUiInput, composer: SuggestionComposer): void;
|
||||
undoSent(composer: SuggestionComposer): Promise<void>;
|
||||
/** The composer is submitting `prompt` through its own send path. */
|
||||
submitted(prompt: string): void;
|
||||
/** The box emptied while it held a used suggestion; tells the plugin, as Esc's Clear does. */
|
||||
draftCleared(): void;
|
||||
};
|
||||
|
||||
const INVOKE_OUTCOMES: ReadonlySet<string> = new Set<PluginUiInvokeOutcome>([
|
||||
"ok",
|
||||
"refused",
|
||||
"rejected",
|
||||
"unavailable",
|
||||
"timeout",
|
||||
]);
|
||||
|
||||
function readSuggestionsEnvelope(
|
||||
value: unknown,
|
||||
): SuggestionsEnvelope | undefined {
|
||||
const envelope = readPluginUiEnvelope(value);
|
||||
return envelope?.slot === SLOT ? envelope : undefined;
|
||||
}
|
||||
|
||||
function readInvokeResult(value: unknown): SuggestionsResult {
|
||||
const record =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const { outcome, reason, epoch } = record;
|
||||
if (typeof outcome !== "string" || !INVOKE_OUTCOMES.has(outcome)) {
|
||||
return { outcome: "unavailable" };
|
||||
}
|
||||
const state = readSuggestionsEnvelope(record.state);
|
||||
return {
|
||||
outcome: outcome as PluginUiInvokeOutcome,
|
||||
...(reason === "stale" || reason === "unsendable" ? { reason } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(typeof epoch === "number" && Number.isFinite(epoch) ? { epoch } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function msSinceExposed(local: SuggestionsLocal): { msSinceExposed?: number } {
|
||||
return local.readyAt === undefined
|
||||
? {}
|
||||
: { msSinceExposed: Math.max(0, Math.round(Date.now() - local.readyAt)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revisions each session's tray already reported exposed, outliving the pane:
|
||||
* ChatThreadPane is keyed per thread, so leaving a session and coming back
|
||||
* remounts the hook, and the view model's own set would start empty.
|
||||
*/
|
||||
function createExposedSuggestionMemory() {
|
||||
const bySession = new Map<string, readonly string[]>();
|
||||
const keyOf = (environmentId: string, sessionId: string) =>
|
||||
`${environmentId}\n${sessionId}`;
|
||||
return {
|
||||
revisions(environmentId: string, sessionId: string): readonly string[] {
|
||||
return bySession.get(keyOf(environmentId, sessionId)) ?? [];
|
||||
},
|
||||
remember(environmentId: string, sessionId: string, revision: string) {
|
||||
const key = keyOf(environmentId, sessionId);
|
||||
const kept = (bySession.get(key) ?? []).filter(
|
||||
(remembered) => remembered !== revision,
|
||||
);
|
||||
bySession.delete(key);
|
||||
bySession.set(key, [...kept, revision].slice(-EXPOSED_REVISIONS_KEPT));
|
||||
for (const oldest of bySession.keys()) {
|
||||
if (bySession.size <= EXPOSED_SESSIONS_KEPT) break;
|
||||
bySession.delete(oldest);
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
bySession.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const exposedSuggestionMemory = createExposedSuggestionMemory();
|
||||
|
||||
/** A prompt taken back from the queue goes in front of anything typed since, never over it. */
|
||||
function takenBackDraft(prompt: string, draft: string): string {
|
||||
return draft.trim() && !samePrompt(draft, prompt)
|
||||
? `${prompt}\n\n${draft}`
|
||||
: prompt;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<undefined> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
|
||||
function samePrompt(text: string, prompt: string): boolean {
|
||||
return formatDisplayUserInput(text.trim()).trim() === prompt.trim();
|
||||
}
|
||||
|
||||
/** The last user message showing `prompt`, with the run count a fork from before it needs. */
|
||||
export function findSentMessage(
|
||||
messages: ChatMessage[],
|
||||
prompt: string,
|
||||
): { messageId: string; runCount: number } | undefined {
|
||||
const runCounts = buildUserRunCountMap(messages);
|
||||
const message = messages.findLast(
|
||||
(candidate) =>
|
||||
candidate.role === "user" && samePrompt(candidate.content, prompt),
|
||||
);
|
||||
const runCount = message ? runCounts.get(message) : undefined;
|
||||
return message && runCount !== undefined
|
||||
? { messageId: message.id, runCount }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer.suggestions slot for one chat pane: binds to the displayed
|
||||
@@ -261,182 +51,23 @@ export function useComposerSuggestions(
|
||||
);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [sendingItemId, setSendingItemId] = useState<string>();
|
||||
const [lastSkip, setLastSkip] = useState<{
|
||||
revision: string;
|
||||
itemId: string;
|
||||
}>();
|
||||
const [connectedEpoch, setConnectedEpoch] = useState(0);
|
||||
const [lastSkip, setLastSkip] = useState<LastSkip>();
|
||||
|
||||
const localRef = useRef(local);
|
||||
localRef.current = local;
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
const busyRef = useRef(busy);
|
||||
busyRef.current = busy;
|
||||
const onUndoTurnRef = useRef(options.onUndoTurn);
|
||||
onUndoTurnRef.current = options.onUndoTurn;
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
/**
|
||||
* The session verbs go to; empty while unbound or disabled. An unmount
|
||||
* leaves it set, so a Send the person already made still reaches the chat they left.
|
||||
*/
|
||||
const boundRef = useRef("");
|
||||
/** Bumped on every bind and unbind, so a reply to an earlier bind is never applied. */
|
||||
const generationRef = useRef(0);
|
||||
/** The due re-attach already sent (its epoch, or null without one); undefined while none is due. */
|
||||
const attachingForRef = useRef<number | null | undefined>(undefined);
|
||||
/** A one-key Send is waiting for its verdict; another press then sends nothing. */
|
||||
const sendInFlightRef = useRef(false);
|
||||
/** An Undo is taking the sent prompt back; another press then does nothing. */
|
||||
const undoInFlightRef = useRef(false);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribeTransportState((state) => {
|
||||
if (state !== "connected") return;
|
||||
setConnectedEpoch((epoch) => epoch + 1);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const invokeFor = useCallback(
|
||||
async (
|
||||
targetSessionId: string,
|
||||
action: ComposerSuggestionsAction,
|
||||
): Promise<SuggestionsResult> => {
|
||||
try {
|
||||
return readInvokeResult(
|
||||
await desktopClient.invoke("plugin_ui_invoke", {
|
||||
environmentId,
|
||||
sessionId: targetSessionId,
|
||||
slot: SLOT,
|
||||
action,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return { outcome: "unavailable" };
|
||||
}
|
||||
},
|
||||
[environmentId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Invokes on the bound session, or resolves undefined while unbound. The
|
||||
* reply reaches the reducer only while that bind lasts; the caller always
|
||||
* gets the plugin's verdict, so a refusal still stops a Send whose pane left.
|
||||
*/
|
||||
const invoke = useCallback(
|
||||
async (
|
||||
action: ComposerSuggestionsAction,
|
||||
{ applyReply = true }: { applyReply?: boolean } = {},
|
||||
): Promise<SuggestionsResult | undefined> => {
|
||||
const targetSessionId = boundRef.current;
|
||||
const generation = generationRef.current;
|
||||
if (!targetSessionId) return undefined;
|
||||
const result = await invokeFor(targetSessionId, action);
|
||||
if (applyReply && generation === generationRef.current) {
|
||||
dispatch({
|
||||
type: "reply",
|
||||
sessionId: targetSessionId,
|
||||
result,
|
||||
now: Date.now(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[invokeFor],
|
||||
);
|
||||
|
||||
const attach = useCallback(
|
||||
async (targetSessionId: string, generation: number) => {
|
||||
const result = await invokeFor(targetSessionId, {
|
||||
verb: "attach",
|
||||
surface: "desktop",
|
||||
});
|
||||
if (generation !== generationRef.current) return;
|
||||
dispatch({
|
||||
type: "reply",
|
||||
sessionId: targetSessionId,
|
||||
result,
|
||||
now: Date.now(),
|
||||
});
|
||||
dispatch({
|
||||
type: "attached",
|
||||
sessionId: targetSessionId,
|
||||
outcome: result.outcome,
|
||||
epoch: result.epoch,
|
||||
});
|
||||
},
|
||||
[invokeFor],
|
||||
);
|
||||
|
||||
const live = enabled && Boolean(sessionId) && connectedEpoch > 0;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: connectedEpoch rebinds on every transport reconnect
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
const generation = generationRef.current;
|
||||
const boundSessionId = live && sessionId ? sessionId : "";
|
||||
boundRef.current = boundSessionId;
|
||||
attachingForRef.current = undefined;
|
||||
setLastSkip(undefined);
|
||||
dispatch({ type: "bind", sessionId: sessionId ?? "" });
|
||||
if (sessionId) {
|
||||
for (const revision of exposedSuggestionMemory.revisions(
|
||||
environmentId,
|
||||
sessionId,
|
||||
)) {
|
||||
dispatch({ type: "exposed", revision });
|
||||
}
|
||||
}
|
||||
if (!boundSessionId) return;
|
||||
void (async () => {
|
||||
let states: unknown;
|
||||
try {
|
||||
states = await desktopClient.invoke("plugin_ui_get", {
|
||||
environmentId,
|
||||
sessionId: boundSessionId,
|
||||
});
|
||||
} catch {
|
||||
states = [];
|
||||
}
|
||||
if (generation !== generationRef.current) return;
|
||||
for (const value of Array.isArray(states) ? states : []) {
|
||||
const envelope = readSuggestionsEnvelope(value);
|
||||
if (envelope) dispatch({ type: "state", envelope, now: Date.now() });
|
||||
}
|
||||
if (busyRef.current) dispatch({ type: "run_started" });
|
||||
await attach(boundSessionId, generation);
|
||||
})();
|
||||
return () => {
|
||||
generationRef.current += 1;
|
||||
void invokeFor(boundSessionId, { verb: "detach" });
|
||||
};
|
||||
}, [attach, connectedEpoch, environmentId, invokeFor, live, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return desktopClient.subscribe("plugin_ui_state", (payload) => {
|
||||
if (eventEnvironmentId(payload) !== environmentId) return;
|
||||
const record = payload as { sessionId?: unknown; envelope?: unknown };
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!boundSessionId || record.sessionId !== boundSessionId) return;
|
||||
const envelope = readSuggestionsEnvelope(record.envelope);
|
||||
if (envelope) dispatch({ type: "state", envelope, now: Date.now() });
|
||||
});
|
||||
}, [enabled, environmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!local.attachDue) {
|
||||
attachingForRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
const due = local.attachDueFor ?? null;
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!boundSessionId || attachingForRef.current === due) return;
|
||||
attachingForRef.current = due;
|
||||
void attach(boundSessionId, generationRef.current);
|
||||
}, [attach, local.attachDue, local.attachDueFor]);
|
||||
const { invoke, boundRef } = useSlotBinding({
|
||||
enabled,
|
||||
environmentId,
|
||||
sessionId,
|
||||
busy,
|
||||
attachDue: local.attachDue,
|
||||
attachDueFor: local.attachDueFor,
|
||||
dispatch,
|
||||
setLastSkip,
|
||||
});
|
||||
|
||||
const wasBusyRef = useRef(busy);
|
||||
useEffect(() => {
|
||||
@@ -522,302 +153,41 @@ export function useComposerSuggestions(
|
||||
{ applyReply: false },
|
||||
);
|
||||
},
|
||||
[invoke],
|
||||
[boundRef, invoke],
|
||||
);
|
||||
|
||||
const signalInert = useCallback(
|
||||
(reason: "not_exposed" | "unsendable" | "stale") => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
void invoke(
|
||||
{
|
||||
verb: "signal",
|
||||
name: "key_inert",
|
||||
reason,
|
||||
...(revision ? { revision } : {}),
|
||||
},
|
||||
{ applyReply: false },
|
||||
);
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const use = useCallback(
|
||||
(
|
||||
item: ComposerSuggestionItem,
|
||||
input: PluginUiInput,
|
||||
composer: SuggestionComposer,
|
||||
) => {
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
composer.setDraft(item.prompt);
|
||||
composer.focus();
|
||||
dispatch({ type: "used", itemId: item.id });
|
||||
const boundSessionId = boundRef.current;
|
||||
void invoke({
|
||||
verb: "use",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
}).then((result) => {
|
||||
if (result?.outcome !== "refused") return;
|
||||
if (boundRef.current !== boundSessionId) return;
|
||||
const used = localRef.current.used;
|
||||
if (used?.itemId === item.id && used.revision === revision) {
|
||||
dispatch({ type: "draft_cleared" });
|
||||
}
|
||||
if (composer.draft() === item.prompt) composer.setDraft("");
|
||||
});
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (view: ReadyView, input: PluginUiInput) => {
|
||||
if (sendInFlightRef.current) return;
|
||||
const item = view.highlighted;
|
||||
if (!view.canSend) {
|
||||
signalInert(item.sendable ? "not_exposed" : "unsendable");
|
||||
return;
|
||||
}
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
const boundSessionId = boundRef.current;
|
||||
if (!revision || !boundSessionId) return;
|
||||
const ref: SuggestionRef = {
|
||||
itemId: item.id,
|
||||
revision,
|
||||
prompt: item.prompt,
|
||||
label: item.label,
|
||||
tone: item.tone,
|
||||
};
|
||||
sendInFlightRef.current = true;
|
||||
setSendingItemId(item.id);
|
||||
try {
|
||||
const verdict = await Promise.race([
|
||||
invoke({
|
||||
verb: "send",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
}),
|
||||
delay(SUGGESTION_SEND_VERDICT_MS),
|
||||
]);
|
||||
if (verdict?.outcome === "refused") return;
|
||||
if (boundRef.current !== boundSessionId) return;
|
||||
latest.current.onSend(ref.prompt);
|
||||
dispatch({ type: "sent", ref });
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
setSendingItemId(undefined);
|
||||
}
|
||||
},
|
||||
[invoke, signalInert],
|
||||
);
|
||||
|
||||
const skip = useCallback(
|
||||
(view: ReadyView, item: ComposerSuggestionItem, input: PluginUiInput) => {
|
||||
const current = localRef.current;
|
||||
const revision = current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
const at = Date.now();
|
||||
if (view.skipMeans === "more") {
|
||||
dispatch({ type: "refresh_pending", now: at });
|
||||
void invoke({ verb: "refresh", revision, input, cause: "more" });
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "skip_pending", itemId: item.id, now: at });
|
||||
setLastSkip({ revision, itemId: item.id });
|
||||
void invoke({
|
||||
verb: "skip",
|
||||
revision,
|
||||
itemId: item.id,
|
||||
input,
|
||||
...msSinceExposed(current),
|
||||
});
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
if (!revision) return;
|
||||
dispatch({ type: "refresh_pending", now: Date.now() });
|
||||
void invoke({ verb: "refresh", revision, input, cause: "new_set" });
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const unskip = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
if (!lastSkip) return;
|
||||
setLastSkip(undefined);
|
||||
void invoke({ verb: "unskip", ...lastSkip, input });
|
||||
},
|
||||
[invoke, lastSkip],
|
||||
);
|
||||
|
||||
const collapse = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
dispatch({ type: "collapse" });
|
||||
void invoke(
|
||||
{
|
||||
verb: "hide",
|
||||
scope: "turn",
|
||||
input,
|
||||
...(revision ? { revision } : {}),
|
||||
},
|
||||
{ applyReply: false },
|
||||
);
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const hideForChat = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
const revision = localRef.current.envelope?.state?.revision;
|
||||
void invoke({
|
||||
verb: "hide",
|
||||
scope: "chat",
|
||||
input,
|
||||
...(revision ? { revision } : {}),
|
||||
});
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const show = useCallback(
|
||||
(input: PluginUiInput) => {
|
||||
void invoke({ verb: "show", input });
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const draftCleared = useCallback(() => {
|
||||
const used = localRef.current.used;
|
||||
if (!used) return;
|
||||
dispatch({ type: "draft_cleared" });
|
||||
void invoke(
|
||||
{ verb: "clear", revision: used.revision, itemId: used.itemId },
|
||||
{ applyReply: false },
|
||||
);
|
||||
}, [invoke]);
|
||||
|
||||
const clear = useCallback(
|
||||
(_input: PluginUiInput, composer: SuggestionComposer) => {
|
||||
if (!localRef.current.used) return;
|
||||
composer.setDraft("");
|
||||
draftCleared();
|
||||
},
|
||||
[draftCleared],
|
||||
);
|
||||
|
||||
/**
|
||||
* A sent prompt the queue still holds comes back into the box with no verb,
|
||||
* since no turn ran. Otherwise, including when the runtime started it before
|
||||
* the removal landed, the turn is forked away from before the sent message,
|
||||
* after stopping a run the fork would refuse.
|
||||
*/
|
||||
const undoSent = useCallback(
|
||||
async (composer: SuggestionComposer) => {
|
||||
const sent = localRef.current.sent;
|
||||
if (!sent || undoInFlightRef.current) return;
|
||||
undoInFlightRef.current = true;
|
||||
const { abort, onRemoveQueuedPrompt, promptsInQueue } = latest.current;
|
||||
try {
|
||||
const queuedPrompt = promptsInQueue.find((item) =>
|
||||
samePrompt(item.prompt, sent.prompt),
|
||||
);
|
||||
/** A prompt still queued has no message of its own, so a match now is an earlier copy. */
|
||||
let earlierCopyId: string | undefined;
|
||||
if (queuedPrompt) {
|
||||
earlierCopyId = findSentMessage(
|
||||
latest.current.messages,
|
||||
sent.prompt,
|
||||
)?.messageId;
|
||||
if (await onRemoveQueuedPrompt(queuedPrompt.id)) {
|
||||
composer.setDraft(takenBackDraft(sent.prompt, composer.draft()));
|
||||
composer.focus();
|
||||
dispatch({ type: "unsent" });
|
||||
return;
|
||||
}
|
||||
if (localRef.current.sent !== sent) return;
|
||||
}
|
||||
const target = findSentMessage(latest.current.messages, sent.prompt);
|
||||
if (!target || target.messageId === earlierCopyId) {
|
||||
if (queuedPrompt) toast({ title: NOT_TAKEN_BACK });
|
||||
return;
|
||||
}
|
||||
if (BUSY_STATUSES.has(statusRef.current)) {
|
||||
void abort();
|
||||
const deadline = Date.now() + STOP_WAIT_MS;
|
||||
while (
|
||||
BUSY_STATUSES.has(statusRef.current) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await delay(STOP_POLL_MS);
|
||||
}
|
||||
if (BUSY_STATUSES.has(statusRef.current)) {
|
||||
toast({ title: STILL_STOPPING });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Promise.race([
|
||||
invoke({
|
||||
verb: "undo",
|
||||
revision: sent.revision,
|
||||
itemId: sent.itemId,
|
||||
}),
|
||||
delay(SUGGESTION_SEND_VERDICT_MS),
|
||||
]);
|
||||
await onUndoTurnRef.current(
|
||||
target.messageId,
|
||||
sent.prompt,
|
||||
target.runCount,
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not undo the suggestion",
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
undoInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const submitted = useCallback(
|
||||
(prompt: string) => {
|
||||
const used = localRef.current.used;
|
||||
if (!used) return;
|
||||
if (prompt.trim() !== used.prompt.trim()) {
|
||||
dispatch({ type: "draft_cleared" });
|
||||
return;
|
||||
}
|
||||
void invoke({
|
||||
verb: "send",
|
||||
revision: used.revision,
|
||||
itemId: used.itemId,
|
||||
input: "key",
|
||||
});
|
||||
dispatch({ type: "sent" });
|
||||
},
|
||||
[invoke],
|
||||
);
|
||||
|
||||
const move = useCallback((delta: 1 | -1) => {
|
||||
dispatch({ type: "move", delta });
|
||||
}, []);
|
||||
|
||||
const highlight = useCallback((itemId: string) => {
|
||||
dispatch({ type: "highlight", itemId });
|
||||
}, []);
|
||||
const {
|
||||
use,
|
||||
send,
|
||||
skip,
|
||||
refresh,
|
||||
unskip,
|
||||
collapse,
|
||||
hideForChat,
|
||||
show,
|
||||
clear,
|
||||
submitted,
|
||||
draftCleared,
|
||||
move,
|
||||
highlight,
|
||||
} = useSuggestionVerbs({
|
||||
invoke,
|
||||
boundRef,
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
lastSkip,
|
||||
setLastSkip,
|
||||
setSendingItemId,
|
||||
});
|
||||
const undoSent = useUndoSent({
|
||||
status,
|
||||
onUndoTurn: options.onUndoTurn,
|
||||
invoke,
|
||||
localRef,
|
||||
latest,
|
||||
dispatch,
|
||||
});
|
||||
|
||||
return useMemo<ComposerSuggestionsController>(
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user