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:
Harrison
2026-10-16 10:48:18 -07:00
parent 0e56e5f62e
commit ca1113dd14
44 changed files with 2181 additions and 1806 deletions
+1 -1
View File
@@ -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
+9 -49
View File
@@ -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);
}
}
+4 -4
View File
@@ -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 -70
View File
@@ -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";
+2 -1
View File
@@ -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.`;
}
@@ -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>();
@@ -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";
@@ -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<
@@ -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;
}
}
@@ -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">;
@@ -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
@@ -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
@@ -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 = [
@@ -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,
@@ -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;
}
@@ -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,
};
@@ -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 };
@@ -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 };
}
@@ -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,
@@ -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>(
() => ({