refactor(desktop): generalize the composer suggestion pills into a provider bus
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.
This commit is contained in:
parent
c7a1bfea07
commit
91a30705eb
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
|
||||
<ActionBadges sessionId={statusSessionId} />
|
||||
<McpSuggestionPills sessionId={statusSessionId} />
|
||||
<SuggestionPills sessionId={statusSessionId} />
|
||||
</div>
|
||||
{/* Session-scoped status stack (todos, subagents, background tasks,
|
||||
queue). An in-flow dock child: the dock is bottom-anchored, so it
|
||||
|
|
|
|||
|
|
@ -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<Record<string, PillPhase>>({})
|
||||
// Cancel flags outlive renders but never trigger them (poll-boundary abort).
|
||||
const [cancels] = useState(() => new Map<string, boolean>())
|
||||
|
||||
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 (
|
||||
<Tip key={suggestion.server} label={tip}>
|
||||
<button
|
||||
className={cn(composerFloatingPill, 'max-w-56', phase === 'done' && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (phase === 'working') {
|
||||
// Second click cancels a stuck flow (closed OAuth tab, etc.).
|
||||
cancels.set(suggestion.server, true)
|
||||
} else if (phase === 'idle') {
|
||||
void connect(suggestion.server)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{phase === 'working' ? (
|
||||
<Codicon className="shrink-0 opacity-70" name="loading" size="0.75rem" spinning />
|
||||
) : phase === 'done' ? (
|
||||
<Codicon className="shrink-0 text-emerald-400" name="check" size="0.75rem" />
|
||||
) : brand ? (
|
||||
<brand.Icon aria-hidden className="size-3 shrink-0" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
<Codicon className="shrink-0 opacity-70" name="plug" size="0.75rem" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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<Record<string, PillPhase>>({})
|
||||
// Cancel flags outlive renders but never trigger them (poll-boundary abort).
|
||||
const [cancels] = useState(() => new Map<string, boolean>())
|
||||
|
||||
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 (
|
||||
<Tip key={key} label={tip}>
|
||||
<button
|
||||
className={cn(composerFloatingPill, 'max-w-56', phase === 'done' && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (phase === 'working') {
|
||||
// Second click requests cancel (a stuck OAuth tab, etc.).
|
||||
cancels.set(key, true)
|
||||
} else if (phase === 'idle') {
|
||||
void invoke()
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{phase === 'working' ? (
|
||||
<Codicon className="shrink-0 opacity-70" name="loading" size="0.75rem" spinning />
|
||||
) : phase === 'done' ? (
|
||||
<Codicon className="shrink-0 text-emerald-400" name="check" size="0.75rem" />
|
||||
) : brand ? (
|
||||
<brand.Icon aria-hidden className="size-3 shrink-0" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
<Codicon className="shrink-0 opacity-70" name={suggestion.icon ?? 'lightbulb'} size="0.75rem" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
/** 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<ComposerSuggestion, 'id' | 'provider'>): 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<Record<string, ComposerSuggestion[]>>({})
|
||||
|
||||
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<ComposerSuggestion[]>
|
||||
|
||||
const draftProviders = new Map<string, DraftProvider>()
|
||||
|
||||
/** 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<string, Map<string, ComposerSuggestion[]>>()
|
||||
|
||||
/** 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<string, ComposerSuggestion[]>()
|
||||
|
||||
/** 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<string>()
|
||||
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<string, number>()
|
||||
const sampleGenerations = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
|
@ -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<Record<string, McpSuggestion[]>>({})
|
||||
|
||||
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<string> | 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<Set<string>> {
|
||||
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(
|
||||
`(?<![\\p{L}\\p{N}])${candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}])`,
|
||||
'u'
|
||||
).test(haystack)
|
||||
)
|
||||
|
||||
if (keyword) {
|
||||
matches.push({ keyword, server: entry.server })
|
||||
|
||||
if (matches.length >= 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<string, number>()
|
||||
const sampleGenerations = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 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, [])
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
@ -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<string> | 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<Set<string>> {
|
||||
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(
|
||||
`(?<![\\p{L}\\p{N}])${candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}])`,
|
||||
'u'
|
||||
).test(haystack)
|
||||
)
|
||||
|
||||
if (keyword) {
|
||||
matches.push({ keyword, server: entry.server })
|
||||
|
||||
if (matches.length >= MAX_MATCHES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
async function connect(server: string, sessionId: string | null, cancelled: () => boolean): Promise<void> {
|
||||
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))
|
||||
})
|
||||
Loading…
Reference in New Issue