diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index 90976a6806..e55e224e7d 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -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)" diff --git a/apps/examples/desktop-app/sidecar/chat-session.ts b/apps/examples/desktop-app/sidecar/chat-session.ts index 417caaa91b..9ccdd78b30 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.ts @@ -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 | 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 | 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>(); - -function claimPluginReload(sessionId: string): () => void { - const claim = Promise.withResolvers(); - 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 diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index bc9476fc03..5b3ed2b64d 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -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 { - 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, diff --git a/apps/examples/desktop-app/sidecar/hub-capability.ts b/apps/examples/desktop-app/sidecar/hub-capability.ts new file mode 100644 index 0000000000..1da9c91e34 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/hub-capability.ts @@ -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 { + 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); + } +} diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index 99b546cca3..b2c16bae0a 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -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"; diff --git a/apps/examples/desktop-app/sidecar/plugin-reload.ts b/apps/examples/desktop-app/sidecar/plugin-reload.ts new file mode 100644 index 0000000000..b1a9083824 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/plugin-reload.ts @@ -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>(); + +export function claimPluginReload(sessionId: string): () => void { + const claim = Promise.withResolvers(); + 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 { + 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 | 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 | 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 }, + }), + ); + } +} diff --git a/apps/examples/desktop-app/sidecar/plugin-ui.ts b/apps/examples/desktop-app/sidecar/plugin-ui.ts index 8c225af489..c830ceb383 100644 --- a/apps/examples/desktop-app/sidecar/plugin-ui.ts +++ b/apps/examples/desktop-app/sidecar/plugin-ui.ts @@ -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 { - 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 | undefined): string { const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; diff --git a/apps/examples/desktop-app/sidecar/server.test.ts b/apps/examples/desktop-app/sidecar/server.test.ts index 2f433e7497..d3d790c2b7 100644 --- a/apps/examples/desktop-app/sidecar/server.test.ts +++ b/apps/examples/desktop-app/sidecar/server.test.ts @@ -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"; diff --git a/apps/examples/desktop-app/sidecar/server.ts b/apps/examples/desktop-app/sidecar/server.ts index 109b757010..a8d5f0b204 100644 --- a/apps/examples/desktop-app/sidecar/server.ts +++ b/apps/examples/desktop-app/sidecar/server.ts @@ -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, diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 8cf18343c7..b873e31c73 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -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 { diff --git a/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.test.ts b/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.test.ts new file mode 100644 index 0000000000..852d3ecde5 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.test.ts @@ -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 { + const entries = new Map(); + 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.", + ); + }); +}); diff --git a/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.ts b/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.ts new file mode 100644 index 0000000000..e498639ba6 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/hub-capability-notice/helpers.ts @@ -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.`; +} diff --git a/apps/examples/desktop-app/webview/components/hub-capability-notice.test.tsx b/apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.test.tsx similarity index 99% rename from apps/examples/desktop-app/webview/components/hub-capability-notice.test.tsx rename to apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.test.tsx index 532e5993a7..5d1f27a735 100644 --- a/apps/examples/desktop-app/webview/components/hub-capability-notice.test.tsx +++ b/apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.test.tsx @@ -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>(); diff --git a/apps/examples/desktop-app/webview/components/hub-capability-notice.tsx b/apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.tsx similarity index 99% rename from apps/examples/desktop-app/webview/components/hub-capability-notice.tsx rename to apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.tsx index 57abf3d2d3..ac1d2db9a0 100644 --- a/apps/examples/desktop-app/webview/components/hub-capability-notice.tsx +++ b/apps/examples/desktop-app/webview/components/hub-capability-notice/hub-capability-notice.tsx @@ -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 } diff --git a/apps/examples/desktop-app/webview/components/hub-update-required-helpers.test.ts b/apps/examples/desktop-app/webview/components/hub-update-required-helpers.test.ts index 119e66d952..b89a5c0679 100644 --- a/apps/examples/desktop-app/webview/components/hub-update-required-helpers.test.ts +++ b/apps/examples/desktop-app/webview/components/hub-update-required-helpers.test.ts @@ -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 { - const entries = new Map(); - 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({ diff --git a/apps/examples/desktop-app/webview/components/hub-update-required-helpers.ts b/apps/examples/desktop-app/webview/components/hub-update-required-helpers.ts index 9cebaedeba..7dae6f746e 100644 --- a/apps/examples/desktop-app/webview/components/hub-update-required-helpers.ts +++ b/apps/examples/desktop-app/webview/components/hub-update-required-helpers.ts @@ -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 diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx index 4cef9939ab..615542f5f3 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx @@ -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 { diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index 8c67620e75..026919c5d9 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -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"; diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-header.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/header.tsx similarity index 97% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-header.tsx rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/header.tsx index 354260bc43..7a77308ead 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-header.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/header.tsx @@ -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< diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-icons.ts b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/icons.ts similarity index 100% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-icons.ts rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/icons.ts diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-keycaps.ts b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/keycaps.ts similarity index 85% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-keycaps.ts rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/keycaps.ts index 2b331e3158..c0ff7edcfb 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-keycaps.ts +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/keycaps.ts @@ -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; - } -} diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-legend.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/legend.tsx similarity index 97% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-legend.tsx rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/legend.tsx index 48fa675255..7ece6b1ee1 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-legend.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/legend.tsx @@ -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; diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-rows.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/rows.tsx similarity index 97% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-rows.tsx rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/rows.tsx index db41fd2258..2e5254aa44 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-rows.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/rows.tsx @@ -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 diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-shared.ts b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/shared.ts similarity index 91% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-shared.ts rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/shared.ts index 1e34143cc5..853e0195e1 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-shared.ts +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/shared.ts @@ -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 diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-source.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/source-link.tsx similarity index 86% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-source.tsx rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/source-link.tsx index 1e39d76d29..3ef96ab3f7 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-source.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/source-link.tsx @@ -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 = [ diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-states.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/states.tsx similarity index 97% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-states.tsx rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/states.tsx index 8183eea5f6..34d8a23e97 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-states.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/states.tsx @@ -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 = Extract< SuggestionsView, diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx index 7c3c13a78d..121cf3715b 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray.tsx @@ -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; diff --git a/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-tones.ts b/apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/tones.ts similarity index 100% rename from apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/suggestion-tray-tones.ts rename to apps/examples/desktop-app/webview/components/views/chat/suggestion-tray/tones.ts diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/exposed-memory.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/exposed-memory.ts new file mode 100644 index 0000000000..c491b08b49 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/exposed-memory.ts @@ -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(); + 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(); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts new file mode 100644 index 0000000000..3501bd6822 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/sent-prompt.ts @@ -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; +} diff --git a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.fake-client.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/fake-client.ts similarity index 100% rename from apps/examples/desktop-app/webview/hooks/use-composer-suggestions.fake-client.ts rename to apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/fake-client.ts diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/fixtures.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/fixtures.ts new file mode 100644 index 0000000000..b31561dee3 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/fixtures.ts @@ -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 { + 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; + /** 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() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((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, +}; diff --git a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.harness.tsx b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx similarity index 61% rename from apps/examples/desktop-app/webview/hooks/use-composer-suggestions.harness.tsx rename to apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx index 7a0817ecb3..29ed82486a 100644 --- a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.harness.tsx +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/test-support/harness.tsx @@ -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 { - 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; - /** 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() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((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('[role="option"]')); +} + export function legendButton( container: HTMLElement, verb: string, diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/timing.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/timing.ts new file mode 100644 index 0000000000..dbb6a024f2 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/timing.ts @@ -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 { + return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)); +} diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts new file mode 100644 index 0000000000..43a8e886cc --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/types.ts @@ -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; + +/** 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; + /** Resolves the prompt it took out of the queue, or undefined when it was no longer queued. */ + onRemoveQueuedPrompt: ( + promptId: string, + ) => Promise; + abort: () => Promise | 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; + 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; + /** 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; + +export type SuggestionsInvoke = ( + action: ComposerSuggestionsAction, + options?: { applyReply?: boolean }, +) => Promise; + +export type LastSkip = { revision: string; itemId: string }; diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.test.tsx b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.test.tsx new file mode 100644 index 0000000000..521d473e49 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.test.tsx @@ -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(); + 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(); + 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); + }); +}); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.ts new file mode 100644 index 0000000000..81b007b940 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-slot-binding.ts @@ -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 = new Set([ + "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) + : {}; + 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>; +}) { + 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(undefined); + + useEffect( + () => + desktopClient.subscribeTransportState((state) => { + if (state !== "connected") return; + setConnectedEpoch((epoch) => epoch + 1); + }), + [], + ); + + const invokeFor = useCallback( + async ( + targetSessionId: string, + action: ComposerSuggestionsAction, + ): Promise => { + 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 => { + 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 }; +} diff --git a/apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.test.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts similarity index 98% rename from apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.test.ts rename to apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts index d8740c7b3b..aa65a06db8 100644 --- a/apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.test.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.test.ts @@ -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, diff --git a/apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts similarity index 97% rename from apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.ts rename to apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts index 4828c54096..14e555b4e1 100644 --- a/apps/examples/desktop-app/webview/hooks/use-suggestion-tray-keys.ts +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-tray-keys.ts @@ -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; diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts new file mode 100644 index 0000000000..d6997b47a7 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-undo-sent.ts @@ -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; + latest: RefObject; + 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; +} diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.test.tsx b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.test.tsx new file mode 100644 index 0000000000..0632cd407a --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.test.tsx @@ -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('[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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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([]); + }); +}); diff --git a/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts new file mode 100644 index 0000000000..57f9f0ca26 --- /dev/null +++ b/apps/examples/desktop-app/webview/hooks/composer-suggestions/use-verbs.ts @@ -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; + localRef: RefObject; + latest: RefObject; + dispatch: SuggestionsDispatch; + lastSkip: LastSkip | undefined; + setLastSkip: Dispatch>; + setSendingItemId: Dispatch>; +}) { + /** 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, + }; +} diff --git a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.test.tsx b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.test.tsx index e8d7f6db47..ea0709862c 100644 --- a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.test.tsx @@ -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('[role="option"]')); -} - -function menuItem(label: string): HTMLElement { - const item = Array.from( - document.querySelectorAll('[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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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([]); - }); }); diff --git a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts index 879dba4992..64b3462a71 100644 --- a/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts +++ b/apps/examples/desktop-app/webview/hooks/use-composer-suggestions.ts @@ -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; - -/** 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; - /** Resolves the prompt it took out of the queue, or undefined when it was no longer queued. */ - onRemoveQueuedPrompt: ( - promptId: string, - ) => Promise; - abort: () => Promise | 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; - 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; - /** 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 = new Set([ - "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) - : {}; - 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(); - 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 { - 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(); - const [lastSkip, setLastSkip] = useState<{ - revision: string; - itemId: string; - }>(); - const [connectedEpoch, setConnectedEpoch] = useState(0); + const [lastSkip, setLastSkip] = useState(); 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(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 => { - 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 => { - 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( () => ({