From 91a30705eb08e4d249b6145cb9babdbf353d7d4b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 13 Aug 2026 00:25:40 -0500 Subject: [PATCH] refactor(desktop): generalize the composer suggestion pills into a provider bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill strip from the inline-MCP work is worth more than one source, so the MCP-specific store splits into two layers with the same UX contract (session-scoped, capped, self-limiting, one-click with narrated idle→working→done): - store/composer-suggestions.ts — the bus. Draft providers register into the existing debounced sampler; event providers push/withdraw directly. Offerings merge (event before draft), dedupe by provider-namespaced key, and keep reference identity on no-ops. - store/suggestion-providers/mcp.ts — the founding provider, behavior unchanged: directory keyword/host matching, configured-server exclusion, one-click connect with OAuth cancel + config rollback. - composer/suggestion-pills.tsx — the generic strip; phases and cancel live here, action/rollback/toasts stay with the provider's invoke. No new pills yet — this is the seam for them. --- .../chat/composer/hooks/use-composer-draft.ts | 16 +- apps/desktop/src/app/chat/composer/index.tsx | 4 +- .../chat/composer/mcp-suggestion-pills.tsx | 142 ----------- .../app/chat/composer/suggestion-pills.tsx | 95 +++++++ .../assistant-ui/mcp-setup-tool.tsx | 2 +- .../desktop/src/store/composer-suggestions.ts | 234 ++++++++++++++++++ apps/desktop/src/store/mcp-suggestions.ts | 216 ---------------- .../mcp.test.ts} | 2 +- .../src/store/suggestion-providers/mcp.ts | 193 +++++++++++++++ 9 files changed, 536 insertions(+), 368 deletions(-) delete mode 100644 apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx create mode 100644 apps/desktop/src/app/chat/composer/suggestion-pills.tsx create mode 100644 apps/desktop/src/store/composer-suggestions.ts delete mode 100644 apps/desktop/src/store/mcp-suggestions.ts rename apps/desktop/src/store/{mcp-suggestions.test.ts => suggestion-providers/mcp.test.ts} (98%) create mode 100644 apps/desktop/src/store/suggestion-providers/mcp.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 04fd55f8ee1ce..4d5941c1a6c27 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -1,3 +1,7 @@ +// Register the built-in draft providers with the suggestion bus (side-effect +// import — the bus itself is provider-agnostic). +import '@/store/suggestion-providers/mcp' + import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' @@ -12,7 +16,7 @@ import { takeSessionDraft } from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' -import { clearMcpSuggestions, sampleComposerDraftForMcpSuggestions } from '@/store/mcp-suggestions' +import { clearDraftSuggestions, sampleComposerDraft } from '@/store/composer-suggestions' import { cloneAttachments, @@ -282,9 +286,9 @@ export function useComposerDraft({ const sync = () => { const text = composerRuntime.getState().text draftRef.current = text - // Keyword-triggered MCP suggestion pills for THIS session's draft - // (debounced + change-gated in the store — this is just a timer reset). - sampleComposerDraftForMcpSuggestions(sessionIdRef.current ?? null, text) + // Composer suggestion pills for THIS session's draft (debounced + + // change-gated in the bus — this is just a timer reset). + sampleComposerDraft(sessionIdRef.current ?? null, text) const editor = editorRef.current @@ -400,11 +404,11 @@ export function useComposerDraft({ stashAt(activeQueueSessionKey, latestText) } - // Withdraw the outgoing session's suggestion pills (and any pending + // Withdraw the outgoing session's draft suggestions (and any pending // sample timer). The incoming session re-earns its own from the draft // restore above — without this a leaving session's "Add GitHub" pill // lingers in the map and re-appears stale on the way back. - clearMcpSuggestions(sessionIdRef.current) + clearDraftSuggestions(sessionIdRef.current) } }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index f2417c3a018b8..d583affcce1bb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -58,7 +58,6 @@ import { useEmojiCompletions } from './hooks/use-emoji-completions' import { useComposerMicroActions } from './hooks/use-micro-actions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' -import { McpSuggestionPills } from './mcp-suggestion-pills' import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' @@ -74,6 +73,7 @@ import { import { useComposerScope } from './scope' import { ComposerStatusStack } from './status-stack' import { CodingStatusRow } from './status-stack/coding-row' +import { SuggestionPills } from './suggestion-pills' import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -1127,7 +1127,7 @@ export function ChatBar({ and share one left edge with it. */}
- +
{/* Session-scoped status stack (todos, subagents, background tasks, queue). An in-flow dock child: the dock is bottom-anchored, so it diff --git a/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx b/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx deleted file mode 100644 index d873a21105a5f..0000000000000 --- a/apps/desktop/src/app/chat/composer/mcp-suggestion-pills.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useState } from 'react' - -import { composerFloatingPill } from '@/components/chat/composer-dock' -import { Codicon } from '@/components/ui/codicon' -import { Tip } from '@/components/ui/tooltip' -import { addMcpServer, authMcpServer, cancelMcpOAuthFlow, getMcpOAuthFlow, removeMcpServer } from '@/hermes' -import { useI18n } from '@/i18n' -import { triggerHaptic } from '@/lib/haptics' -import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' -import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth' -import { directoryEntry } from '@/lib/mcp-directory' -import { prettyName } from '@/lib/text' -import { useSessionSlice } from '@/lib/use-session-slice' -import { cn } from '@/lib/utils' -import { $gateway } from '@/store/gateway' -import { $mcpSuggestionsBySession, invalidateMcpSuggestionIndex } from '@/store/mcp-suggestions' -import { notifyError } from '@/store/notifications' - -/** - * Keyword-triggered MCP suggestion pills — the "you typed jira, want - * Atlassian?" strip. Renders beside the micro-action badges in the floating - * lane above the composer, same pill treatment. Session-scoped like the - * badges: each composer shows only the pills its own draft earned. - * - * A click CONNECTS, right here — every directory entry is a hosted OAuth - * remote, so the whole install is one validated config write plus the - * browser OAuth round-trip. The pill narrates it: label → "Connecting…" - * (click again to cancel) → "Added". No composer indirection, no agent - * turn — the click is the consent. The `setup_mcp` transcript card remains - * the AGENT-initiated path; both run the same flow and the same rollback: - * a cancelled/failed connect removes the config entry it just wrote. - * - * No dismiss affordance ON PURPOSE. The pills are self-limiting — they only - * exist while a trigger word/link is in the draft, vanish once the server is - * configured, and cap at two — so a close button would mostly collect - * accidental permanent opt-outs. The escape hatch is simply not clicking. - * - * Same pointer-events rule as the micro-action pills: NEVER - * `pointer-events-none` — the pop-out drag region sits behind this strip. - */ - -type PillPhase = 'done' | 'idle' | 'working' - -export function McpSuggestionPills({ sessionId }: { sessionId: null | string }) { - const { t } = useI18n() - const copy = t.composer.mcpSuggestions - const suggestions = useSessionSlice($mcpSuggestionsBySession, sessionId) - const [phases, setPhases] = useState>({}) - // Cancel flags outlive renders but never trigger them (poll-boundary abort). - const [cancels] = useState(() => new Map()) - - const setPhase = (server: string, phase: PillPhase) => - setPhases(current => ({ ...current, [server]: phase })) - - const connect = async (server: string) => { - const known = directoryEntry(server) - - if (!known) { - return - } - - cancels.set(server, false) - setPhase(server, 'working') - triggerHaptic('selection') - - try { - await addMcpServer({ name: known.name, url: known.url }) - - try { - await completeMcpDesktopOAuth({ - serverName: known.name, - start: authMcpServer, - status: getMcpOAuthFlow, - cancelled: () => cancels.get(server) === true, - cancel: cancelMcpOAuthFlow, - openExternal: url => window.hermesDesktop.openExternal(url) - }) - } catch (error) { - // Decline/failure means "no server" — roll back the config write - // rather than stranding an unauthorized entry (authoritative-write - // rule). Best-effort; the primary error wins. - await removeMcpServer(known.name).catch(() => {}) - throw error - } - - // Tools reach the live session before the pill claims success — the - // same write-through the Capabilities tab and the setup card use. - await $gateway - .get() - ?.request('reload.mcp', { confirm: true, session_id: sessionId ?? undefined }) - .catch(() => {}) - - invalidateMcpSuggestionIndex() - triggerHaptic('submit') - setPhase(server, 'done') - } catch (error) { - setPhase(server, 'idle') - - if (!(error instanceof McpOAuthCancelled)) { - notifyError(error, copy.connectFailed(prettyName(server))) - } - } - } - - return suggestions.map(suggestion => { - const brand = brandFor(suggestion.server) - const phase = phases[suggestion.server] ?? 'idle' - const name = prettyName(suggestion.server) - - const label = phase === 'working' ? copy.connecting(name) : phase === 'done' ? copy.added(name) : copy.label(name) - - const tip = phase === 'working' ? copy.cancelTip : phase === 'done' ? copy.addedTip : copy.tip(suggestion.keyword) - - return ( - - - - ) - }) -} diff --git a/apps/desktop/src/app/chat/composer/suggestion-pills.tsx b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx new file mode 100644 index 0000000000000..6f87b40311043 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react' + +import { composerFloatingPill } from '@/components/chat/composer-dock' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { triggerHaptic } from '@/lib/haptics' +import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' +import { useSessionSlice } from '@/lib/use-session-slice' +import { cn } from '@/lib/utils' +import { $composerSuggestionsBySession, suggestionKey } from '@/store/composer-suggestions' + +/** + * The composer suggestion strip — generic pills fed by the suggestion bus + * (`store/composer-suggestions.ts`; the MCP connect pills of PR #85036 are + * provider one of N). Renders beside the micro-action badges in the floating + * lane above the composer, same pill treatment. Session-scoped like the + * badges: each composer shows only the suggestions its own session earned. + * + * Every pill is a one-click action with a narrated lifecycle: label → + * workingLabel (click again to request cancel) → doneLabel. The provider's + * `invoke` owns the work, cancellation, rollback, and error toasts; this + * component owns only the phase presentation. + * + * No dismiss affordance ON PURPOSE. Suggestions are self-limiting — a + * provider withdraws its offer when the trigger condition stops holding — + * so a close button would mostly collect accidental permanent opt-outs. + * The escape hatch is simply not clicking. + * + * Same pointer-events rule as the micro-action pills: NEVER + * `pointer-events-none` — the pop-out drag region sits behind this strip. + */ + +type PillPhase = 'done' | 'idle' | 'working' + +export function SuggestionPills({ sessionId }: { sessionId: null | string }) { + const suggestions = useSessionSlice($composerSuggestionsBySession, sessionId) + const [phases, setPhases] = useState>({}) + // Cancel flags outlive renders but never trigger them (poll-boundary abort). + const [cancels] = useState(() => new Map()) + + const setPhase = (key: string, phase: PillPhase) => setPhases(current => ({ ...current, [key]: phase })) + + return suggestions.map(suggestion => { + const key = suggestionKey(suggestion) + const brand = suggestion.brand ? brandFor(suggestion.brand) : null + const phase = phases[key] ?? 'idle' + + const label = phase === 'working' ? suggestion.workingLabel : phase === 'done' ? suggestion.doneLabel : suggestion.label + const tip = phase === 'working' ? suggestion.workingTip : phase === 'done' ? suggestion.doneTip : suggestion.tip + + const invoke = async () => { + cancels.set(key, false) + setPhase(key, 'working') + triggerHaptic('selection') + + try { + await suggestion.invoke({ cancelled: () => cancels.get(key) === true, sessionId }) + triggerHaptic('submit') + setPhase(key, 'done') + } catch { + // Provider owns error surfacing (and swallows its own cancels); + // the pill just returns to idle so it can be tried again. + setPhase(key, 'idle') + } + } + + return ( + + + + ) + }) +} diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx index b84f8a07366ea..2af772cd70429 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -32,8 +32,8 @@ import { prettyName } from '@/lib/text' import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup' -import { invalidateMcpSuggestionIndex } from '@/store/mcp-suggestions' import { notifyError } from '@/store/notifications' +import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp' import { selectMessageRunning } from './tool/fallback-model' import { parseMaybeObject } from './tool/fallback-model/format' diff --git a/apps/desktop/src/store/composer-suggestions.ts b/apps/desktop/src/store/composer-suggestions.ts new file mode 100644 index 0000000000000..6d13110fe7fff --- /dev/null +++ b/apps/desktop/src/store/composer-suggestions.ts @@ -0,0 +1,234 @@ +import { atom } from 'nanostores' + +/** + * The composer suggestion bus — a generic, session-scoped feed for the pill + * strip above the composer (rendered by `composer/suggestion-pills.tsx`). + * + * Providers, not sources baked into the store: anything can offer a pill — + * draft keywords (the MCP directory was the first), session state, tool + * results, connection events. Two provider shapes: + * + * - **Draft providers** react to what the user is typing. Registered with + * `registerDraftProvider`, they run inside the existing debounced draft + * sampler (600ms, change-gated) and return suggestions for that session's + * draft. Pure-ish: given a draft, they decide; the bus handles debounce, + * session scoping, reference identity, and the cap. + * - **Event providers** push and withdraw suggestions directly via + * `offerSuggestion` / `withdrawSuggestion` from wherever their signal + * lives (a store listener, a gateway event handler). The bus applies the + * same session scoping and cap. + * + * The UX contract every suggestion signs (see PR #85036's pills): + * session-scoped, capped at MAX_SUGGESTIONS with draft suggestions ranked + * after event ones, self-limiting (a suggestion exists only while its + * trigger condition holds — providers withdraw it, there is NO dismiss + * affordance), and one-click: `invoke` runs the whole action with the pill + * narrating idle → working → done. No suggestion may block or shift the + * composer. + */ +export interface ComposerSuggestion { + /** Stable per-suggestion identity, unique within its provider + * (e.g. the server name, the skill name). */ + id: string + /** Provider that offered it; `${provider}:${id}` is the bus-wide key. */ + provider: string + /** Pill label, already localized ("Add Atlassian"). */ + label: string + /** Tooltip explaining WHY this is being suggested. */ + tip: string + /** Brand identity for the glyph slot; falls back to `icon`. */ + brand?: string + /** Codicon name when there is no brand glyph (default: lightbulb). */ + icon?: string + /** Runs the whole action; the pill shows `workingLabel` while it's + * in flight and `doneLabel` on success. Reject to return to idle + * (provider surfaces its own error toast). */ + invoke: (context: { cancelled: () => boolean; sessionId: string | null }) => Promise + /** Label while `invoke` runs ("Connecting Atlassian…"). */ + workingLabel: string + /** Tooltip while working; clicking a working pill requests cancel. */ + workingTip: string + /** Label after `invoke` resolves ("Added Atlassian"). */ + doneLabel: string + /** Tooltip once done. */ + doneTip: string +} + +export const MAX_SUGGESTIONS = 2 + +/** Bus-wide key: provider-namespaced so two providers can't collide. */ +export const suggestionKey = (suggestion: Pick): string => + `${suggestion.provider}:${suggestion.id}` + +/** Suggestions keyed by RUNTIME session id, exactly like + * `$composerActionsBySession`: drafts are per-session state, so suggestions + * derived from them are too. */ +export const $composerSuggestionsBySession = atom>({}) + +const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' + +const sameSuggestions = (a: readonly ComposerSuggestion[], b: readonly ComposerSuggestion[]) => + a.length === b.length && a.every((x, i) => suggestionKey(x) === suggestionKey(b[i]!)) + +function write(sessionId: string | null | undefined, suggestions: ComposerSuggestion[]): void { + const key = keyFor(sessionId) + const current = $composerSuggestionsBySession.get() + const existing = current[key] ?? [] + + // Unchanged sets keep their reference so the strip doesn't re-render. + if (sameSuggestions(existing, suggestions)) { + return + } + + const next = { ...current } + + if (suggestions.length > 0) { + next[key] = suggestions + } else { + delete next[key] + } + + $composerSuggestionsBySession.set(next) +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +export interface DraftProviderContext { + sessionId: string | null + /** The draft text at sample time. */ + text: string +} + +export type DraftProvider = (context: DraftProviderContext) => Promise + +const draftProviders = new Map() + +/** Register a provider that derives suggestions from the draft. Runs inside + * the composer's debounced sampler; results replace that provider's previous + * offerings for the session. Returns an unregister fn (HMR hygiene). */ +export function registerDraftProvider(name: string, provider: DraftProvider): () => void { + draftProviders.set(name, provider) + + return () => { + draftProviders.delete(name) + } +} + +// Event-provider offerings, merged with draft results on every write. +// Keyed session → provider → suggestions. +const eventOfferings = new Map>() + +/** Offer suggestions from an event provider (session state, tool results, + * connection events…). Replaces that provider's previous offerings for the + * session; providers withdraw by offering []. */ +export function offerSuggestions(sessionId: string | null | undefined, provider: string, suggestions: ComposerSuggestion[]): void { + const key = keyFor(sessionId) + let providers = eventOfferings.get(key) + + if (!providers) { + providers = new Map() + eventOfferings.set(key, providers) + } + + if (suggestions.length > 0) { + providers.set(provider, suggestions) + } else { + providers.delete(provider) + } + + publish(sessionId ?? null) +} + +// Last draft-provider results per session, merged with event offerings. +const draftOfferings = new Map() + +/** Event offerings first (they carry session/tool state, stronger signal + * than draft keywords), then draft matches, capped. */ +function publish(sessionId: string | null): void { + const key = keyFor(sessionId) + const event = [...(eventOfferings.get(key)?.values() ?? [])].flat() + const draft = draftOfferings.get(key) ?? [] + const seen = new Set() + const merged: ComposerSuggestion[] = [] + + for (const suggestion of [...event, ...draft]) { + const k = suggestionKey(suggestion) + + if (!seen.has(k)) { + seen.add(k) + merged.push(suggestion) + } + + if (merged.length >= MAX_SUGGESTIONS) { + break + } + } + + write(sessionId, merged) +} + +// --------------------------------------------------------------------------- +// Draft sampling (the composer's runtime subscription feeds this) +// --------------------------------------------------------------------------- + +const SAMPLE_DEBOUNCE_MS = 600 + +// Per-session debounce/generation so a tile composer's sampling never stomps +// the primary's (each session settles independently). +const sampleTimers = new Map() +const sampleGenerations = new Map() + +/** + * Feed a session's draft snapshot to the draft providers. Called from that + * composer's runtime subscription on every change, but internally debounced + * and change-gated: the store only writes when the session's merged set + * actually differs, so typing within a line costs nothing downstream. + */ +export function sampleComposerDraft(sessionId: string | null | undefined, text: string): void { + const key = keyFor(sessionId) + + window.clearTimeout(sampleTimers.get(key)) + + const generation = (sampleGenerations.get(key) ?? 0) + 1 + sampleGenerations.set(key, generation) + + // Too short to mean anything — clear draft offerings without running providers. + if (text.trim().length < 3) { + draftOfferings.delete(key) + publish(sessionId ?? null) + + return + } + + sampleTimers.set( + key, + window.setTimeout(() => { + void Promise.all( + [...draftProviders.values()].map(provider => + provider({ sessionId: sessionId ?? null, text }).catch((): ComposerSuggestion[] => []) + ) + ).then(results => { + // A newer sample for THIS session superseded this one mid-flight. + if (generation !== sampleGenerations.get(key)) { + return + } + + draftOfferings.set(key, results.flat()) + publish(sessionId ?? null) + }) + }, SAMPLE_DEBOUNCE_MS) + ) +} + +/** Drop a session's suggestions outright (composer unmount / session leave). + * Draft offerings die with the draft; event offerings persist — their + * providers own that lifecycle and withdraw on their own signal. */ +export function clearDraftSuggestions(sessionId: string | null | undefined): void { + const key = keyFor(sessionId) + + window.clearTimeout(sampleTimers.get(key)) + draftOfferings.delete(key) + publish(sessionId ?? null) +} diff --git a/apps/desktop/src/store/mcp-suggestions.ts b/apps/desktop/src/store/mcp-suggestions.ts deleted file mode 100644 index 1e602084b5493..0000000000000 --- a/apps/desktop/src/store/mcp-suggestions.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { atom } from 'nanostores' - -import { listMcpServers } from '@/hermes' -import { MCP_DIRECTORY } from '@/lib/mcp-directory' - -/** - * Keyword-triggered MCP suggestions for the composer pill strip. - * - * Source: the desktop's own suggestion directory (`lib/mcp-directory.ts`) — - * NOT the Nous install catalog, whose contents are a reviewed trust boundary - * we don't grow from the renderer. While the user types, the composer samples - * the draft (debounced — never per keystroke) and this store matches it - * against directory keywords and pasted-link hosts, excluding servers already - * configured in `mcp_servers`. Matches surface as pills above the composer; a - * pill click drafts a setup request, and the agent's `setup_mcp` tool takes - * it from there with the inline consent card. - * - * The pills are self-limiting rather than dismissible: they only exist while - * a trigger is in the draft, vanish once the server is configured, and cap at - * MAX_SUGGESTIONS — so there is deliberately no per-server opt-out state. - */ -export interface McpSuggestion { - server: string - /** The keyword or host that matched, for the pill's tooltip. */ - keyword: string -} - -const SAMPLE_DEBOUNCE_MS = 600 -const CONFIGURED_TTL_MS = 5 * 60_000 -const MAX_SUGGESTIONS = 2 - -/** - * Suggestions keyed by RUNTIME session id, exactly like - * `$composerActionsBySession`: drafts are per-session state, so the pills - * computed from a draft are too. A single global slot made whichever session - * sampled last leak its pills into every other tab (#draft-restore re-samples - * on switch), which read as "Add GitHub" following you around the app. - */ -export const $mcpSuggestionsBySession = atom>({}) - -const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' - -const sameSuggestions = (a: readonly McpSuggestion[], b: readonly McpSuggestion[]) => - a.length === b.length && a.every((x, i) => x.server === b[i]!.server && x.keyword === b[i]!.keyword) - -function setSuggestions(sessionId: string | null, suggestions: McpSuggestion[]): void { - const key = keyFor(sessionId) - const current = $mcpSuggestionsBySession.get() - const existing = current[key] ?? [] - - // Unchanged sets keep their reference so the strip doesn't re-render. - if (sameSuggestions(existing, suggestions)) { - return - } - - const next = { ...current } - - if (suggestions.length > 0) { - next[key] = suggestions - } else { - delete next[key] - } - - $mcpSuggestionsBySession.set(next) -} - -interface KeywordEntry { - server: string - keywords: string[] - /** Hostname suffixes ("atlassian.net") matched against URLs in the draft. */ - hosts?: string[] -} - -// Names already present in mcp_servers config (enabled or not) — those need a -// toggle/auth at most, not a "add this server" pill. Cached briefly; a miss -// (older backend, transient error) suggests nothing rather than nagging. -let configuredNames: Set | null = null -let configuredAt = 0 - -/** Drop the configured-servers cache (profile switch / after an install). */ -export function invalidateMcpSuggestionIndex(): void { - configuredNames = null - configuredAt = 0 -} - -async function loadConfiguredNames(): Promise> { - if (configuredNames && Date.now() - configuredAt < CONFIGURED_TTL_MS) { - return configuredNames - } - - const { servers } = await listMcpServers() - - configuredNames = new Set(servers.map(server => server.name)) - configuredAt = Date.now() - - return configuredNames -} - -// Hostnames of http(s) URLs in the draft. Loose on purpose — a draft is not -// a document, so a trailing-punctuation host ("linear.app,") still counts. -const URL_HOST_RE = /https?:\/\/([^\s/,)\]}"'<>]+)/gi - -function draftHosts(text: string): string[] { - const hosts: string[] = [] - - for (const match of text.matchAll(URL_HOST_RE)) { - const host = match[1]?.split('@').pop()?.split(':')[0]?.toLowerCase() - - if (host) { - hosts.push(host) - } - } - - return hosts -} - -const hostMatches = (host: string, suffix: string) => host === suffix || host.endsWith(`.${suffix}`) - -/** Pure matcher, exported for tests: pasted-link host hits (the strongest - * intent signal) and whole-word (unicode-aware) keyword hits against the - * draft, capped at MAX_SUGGESTIONS. */ -export function matchSuggestions(text: string, index: KeywordEntry[]): McpSuggestion[] { - const haystack = ` ${text.toLowerCase()} ` - const hosts = draftHosts(text) - const matches: McpSuggestion[] = [] - - for (const entry of index) { - // A pasted vendor link beats any keyword: report the host as the trigger. - const host = entry.hosts?.find(suffix => hosts.some(candidate => hostMatches(candidate, suffix))) - - // Whole-word match so "linearly" doesn't suggest Linear. Directory - // keywords are lowercase; multi-word keywords match as phrases. - const keyword = - host ?? - entry.keywords.find(candidate => - new RegExp( - `(?= MAX_SUGGESTIONS) { - break - } - } - } - - return matches -} - -// Per-session debounce/generation so a tile composer's sampling never stomps -// the primary's (each session settles independently). -const sampleTimers = new Map() -const sampleGenerations = new Map() - -/** - * Feed a session's draft snapshot into the matcher. Called from that - * composer's runtime subscription on every change, but internally debounced - * and change-gated: the store only writes when the session's matched set - * actually differs, so typing within a line costs nothing downstream. - */ -export function sampleComposerDraftForMcpSuggestions(sessionId: string | null | undefined, text: string): void { - const key = keyFor(sessionId) - - window.clearTimeout(sampleTimers.get(key)) - - const generation = (sampleGenerations.get(key) ?? 0) + 1 - sampleGenerations.set(key, generation) - - // Too short to mean anything — clear instead of hitting the matcher. - if (text.trim().length < 3) { - setSuggestions(sessionId ?? null, []) - - return - } - - sampleTimers.set( - key, - window.setTimeout(() => { - // Fast path: no keyword hit at all → clear without touching the network. - const index = MCP_DIRECTORY.map(entry => ({ hosts: entry.hosts, keywords: entry.keywords, server: entry.name })) - const candidates = matchSuggestions(text, index) - - if (candidates.length === 0) { - setSuggestions(sessionId ?? null, []) - - return - } - - void loadConfiguredNames() - .then(configured => { - // A newer sample for THIS session superseded this one mid-load. - if (generation !== sampleGenerations.get(key)) { - return - } - - setSuggestions( - sessionId ?? null, - candidates.filter(candidate => !configured.has(candidate.server)) - ) - }) - .catch(() => { - // Server list unreachable — suggest nothing rather than mis-suggest. - }) - }, SAMPLE_DEBOUNCE_MS) - ) -} - -/** Drop a session's pills outright (composer unmount / session close). */ -export function clearMcpSuggestions(sessionId: string | null | undefined): void { - window.clearTimeout(sampleTimers.get(keyFor(sessionId))) - setSuggestions(sessionId ?? null, []) -} diff --git a/apps/desktop/src/store/mcp-suggestions.test.ts b/apps/desktop/src/store/suggestion-providers/mcp.test.ts similarity index 98% rename from apps/desktop/src/store/mcp-suggestions.test.ts rename to apps/desktop/src/store/suggestion-providers/mcp.test.ts index fb80f102aa263..614f29b03808e 100644 --- a/apps/desktop/src/store/mcp-suggestions.test.ts +++ b/apps/desktop/src/store/suggestion-providers/mcp.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { matchSuggestions } from './mcp-suggestions' +import { matchSuggestions } from './mcp' const INDEX = [ { keywords: ['linear', 'issue tracker', 'ticket'], server: 'linear' }, diff --git a/apps/desktop/src/store/suggestion-providers/mcp.ts b/apps/desktop/src/store/suggestion-providers/mcp.ts new file mode 100644 index 0000000000000..2be7bc15952b3 --- /dev/null +++ b/apps/desktop/src/store/suggestion-providers/mcp.ts @@ -0,0 +1,193 @@ +import { addMcpServer, authMcpServer, cancelMcpOAuthFlow, getMcpOAuthFlow, listMcpServers, removeMcpServer } from '@/hermes' +import { translateNow } from '@/i18n' +import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth' +import { directoryEntry, MCP_DIRECTORY } from '@/lib/mcp-directory' +import { prettyName } from '@/lib/text' +import { + type ComposerSuggestion, + registerDraftProvider +} from '@/store/composer-suggestions' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' + +/** + * The MCP draft provider — the suggestion bus's founding member (PR #85036). + * + * Matches the draft against the desktop's directory of official hosted MCP + * remotes (`lib/mcp-directory.ts` — deliberately NOT the reviewed install + * catalog) by whole-word keyword and pasted-link host suffix, excluding + * servers already configured. A suggestion's invoke runs the whole connect: + * validated config write → browser OAuth → live tool reload, with rollback + * on cancel/failure so a decline never strands a half-configured server. + */ + +const CONFIGURED_TTL_MS = 5 * 60_000 + +// Names already present in mcp_servers config (enabled or not) — those need a +// toggle/auth at most, not an "add this server" pill. Cached briefly; a miss +// (older backend, transient error) suggests nothing rather than nagging. +let configuredNames: Set | null = null +let configuredAt = 0 + +/** Drop the configured-servers cache (profile switch / after an install). */ +export function invalidateMcpSuggestionIndex(): void { + configuredNames = null + configuredAt = 0 +} + +async function loadConfiguredNames(): Promise> { + if (configuredNames && Date.now() - configuredAt < CONFIGURED_TTL_MS) { + return configuredNames + } + + const { servers } = await listMcpServers() + + configuredNames = new Set(servers.map(server => server.name)) + configuredAt = Date.now() + + return configuredNames +} + +interface KeywordEntry { + server: string + keywords: string[] + /** Hostname suffixes ("atlassian.net") matched against URLs in the draft. */ + hosts?: string[] +} + +// Hostnames of http(s) URLs in the draft. Loose on purpose — a draft is not +// a document, so a trailing-punctuation host ("linear.app,") still counts. +const URL_HOST_RE = /https?:\/\/([^\s/,)\]}"'<>]+)/gi + +const draftHosts = (text: string): string[] => + [...text.matchAll(URL_HOST_RE)].map(match => { + const raw = match[1]!.toLowerCase() + // Strip credentials and port: user@host:443 → host. + const withoutCredentials = raw.slice(raw.lastIndexOf('@') + 1) + + return withoutCredentials.replace(/:\d+$/, '') + }) + +// Strict suffix-on-dot-boundary: "myorg.atlassian.net" matches "atlassian.net"; +// "notlinear.app" and "linear.app.example.com" do not match "linear.app". +const hostMatches = (host: string, suffix: string): boolean => host === suffix || host.endsWith(`.${suffix}`) + +export interface McpMatch { + server: string + /** The keyword or host that matched, for the pill's tooltip. */ + keyword: string +} + +const MAX_MATCHES = 2 + +/** Pure matcher, exported for tests: pasted-link host hits (the strongest + * intent signal) and whole-word (unicode-aware) keyword hits against the + * draft, capped at MAX_MATCHES. */ +export function matchSuggestions(text: string, index: KeywordEntry[]): McpMatch[] { + const haystack = ` ${text.toLowerCase()} ` + const hosts = draftHosts(text) + const matches: McpMatch[] = [] + + for (const entry of index) { + // A pasted vendor link beats any keyword: report the host as the trigger. + const host = entry.hosts?.find(suffix => hosts.some(candidate => hostMatches(candidate, suffix))) + + // Whole-word match so "linearly" doesn't suggest Linear. Directory + // keywords are lowercase; multi-word keywords match as phrases. + const keyword = + host ?? + entry.keywords.find(candidate => + new RegExp( + `(?= MAX_MATCHES) { + break + } + } + } + + return matches +} + +async function connect(server: string, sessionId: string | null, cancelled: () => boolean): Promise { + const known = directoryEntry(server) + + if (!known) { + return + } + + try { + await addMcpServer({ name: known.name, url: known.url }) + + try { + await completeMcpDesktopOAuth({ + serverName: known.name, + start: authMcpServer, + status: getMcpOAuthFlow, + cancelled, + cancel: cancelMcpOAuthFlow, + openExternal: url => window.hermesDesktop.openExternal(url) + }) + } catch (error) { + // Decline/failure means "no server" — roll back the config write + // rather than stranding an unauthorized entry (authoritative-write + // rule). Best-effort; the primary error wins. + await removeMcpServer(known.name).catch(() => {}) + throw error + } + + // Tools reach the live session before the pill claims success — the + // same write-through the Capabilities tab and the setup card use. + await $gateway + .get() + ?.request('reload.mcp', { confirm: true, session_id: sessionId ?? undefined }) + .catch(() => {}) + + invalidateMcpSuggestionIndex() + } catch (error) { + if (!(error instanceof McpOAuthCancelled)) { + notifyError(error, translateNow('composer.mcpSuggestions.connectFailed', prettyName(server))) + } + + throw error + } +} + +function toSuggestion(match: McpMatch, sessionId: string | null): ComposerSuggestion { + const name = prettyName(match.server) + const copy = (key: string, ...args: unknown[]) => translateNow(`composer.mcpSuggestions.${key}`, ...args) + + return { + brand: match.server, + doneLabel: copy('added', name), + doneTip: copy('addedTip'), + id: match.server, + invoke: context => connect(match.server, sessionId, context.cancelled), + label: copy('label', name), + provider: 'mcp', + tip: copy('tip', match.keyword), + workingLabel: copy('connecting', name), + workingTip: copy('cancelTip') + } +} + +registerDraftProvider('mcp', async ({ sessionId, text }) => { + const index = MCP_DIRECTORY.map(entry => ({ hosts: entry.hosts, keywords: entry.keywords, server: entry.name })) + const candidates = matchSuggestions(text, index) + + // Fast path: no keyword hit at all → nothing, without touching the network. + if (candidates.length === 0) { + return [] + } + + // Server list unreachable — suggest nothing rather than mis-suggest. + const configured = await loadConfiguredNames() + + return candidates.filter(candidate => !configured.has(candidate.server)).map(match => toSuggestion(match, sessionId)) +})