| 1 | <template> |
| 2 | <div> |
| 3 | <div ref="container" class="w-full" /> |
| 4 | |
| 5 | <!-- All-in-one app drawer (auth + MCP chat + actions). Replaces the |
| 6 | top-level redirect to shuffler.io that <ShuffleMCP> would do |
| 7 | by default. Inline auth form for API-key/URL apps; OAuth apps |
| 8 | still redirect from inside the drawer when the user clicks |
| 9 | "Authenticate". --> |
| 10 | <AppDetailDrawerEmbed v-model:show="showAppDrawer" :app-name="selectedAppName" /> |
| 11 | </div> |
| 12 | </template> |
| 13 | |
| 14 | <script setup lang="ts"> |
| 15 | // Vue wrapper around `<ShuffleMCP>` (a React component from |
| 16 | // `@shuffleio/shuffle-mcps`). We don't pull in a Vue/React interop |
| 17 | // library — for a single component the manual mount via |
| 18 | // react-dom/client is cleaner and avoids the abstraction tax. Vite |
| 19 | // already handles JSX through esbuild so no build config changes |
| 20 | // were needed. |
| 21 | // |
| 22 | // Lifecycle: createRoot in onMounted, unmount in onBeforeUnmount, |
| 23 | // re-render with `root.render(...)` on prop change so the embed |
| 24 | // reacts to live updates (e.g. when the parent swaps the auth token |
| 25 | // after picking a different Shuffle org). |
| 26 | |
| 27 | // `@shuffleio/shuffle-mcps/dist/index.js` self-imports its CSS via a |
| 28 | // hash-suffixed filename (`./singul-GZKBHJNI.css`). The package's own |
| 29 | // `./singul.css` export entry points at `./dist/singul.css` which |
| 30 | // doesn't exist on disk — that's a package bug. We don't need to |
| 31 | // import the CSS here ourselves; importing `ShuffleMCP` pulls it in. |
| 32 | |
| 33 | import type { AlgoliaSearchApp, AppSelectedEvent } from "@shuffleio/shuffle-mcps" |
| 34 | import type { Root } from "react-dom/client" |
| 35 | import { ShuffleMCP } from "@shuffleio/shuffle-mcps" |
| 36 | import { storeToRefs } from "pinia" |
| 37 | import { createElement } from "react" |
| 38 | import { createRoot } from "react-dom/client" |
| 39 | import { onBeforeUnmount, onMounted, ref, watch } from "vue" |
| 40 | import { useThemeStore } from "@/stores/theme" |
| 41 | import { fetchShuffleConnectorCredentials } from "@/utils/shuffle/shuffleConnectorCredentials" |
| 42 | import { MuiProvider } from "@/utils/shuffle/shuffleMuiTheme" |
| 43 | import AppDetailDrawerEmbed from "./AppDetailDrawerEmbed.vue" |
| 44 | |
| 45 | interface Props { |
| 46 | authToken: string |
| 47 | apiKey?: string |
| 48 | apiBaseUrl?: string |
| 49 | inline?: boolean |
| 50 | layout?: "list" | "grid" |
| 51 | gridColumns?: number |
| 52 | placeholder?: string |
| 53 | preventDefault?: boolean |
| 54 | multiSelect?: boolean |
| 55 | showCheckbox?: boolean |
| 56 | initialFilterQuery?: string |
| 57 | showSourceFilter?: boolean |
| 58 | disableAppDrawer?: boolean |
| 59 | } |
| 60 | |
| 61 | const props = withDefaults(defineProps<Props>(), { |
| 62 | apiKey: undefined, |
| 63 | apiBaseUrl: undefined, |
| 64 | inline: true, |
| 65 | layout: "list", |
| 66 | gridColumns: 3, |
| 67 | placeholder: "Find an app…", |
| 68 | preventDefault: false, |
| 69 | multiSelect: false, |
| 70 | showCheckbox: false, |
| 71 | initialFilterQuery: undefined, |
| 72 | showSourceFilter: true, |
| 73 | disableAppDrawer: false |
| 74 | }) |
| 75 | |
| 76 | const emit = defineEmits<{ |
| 77 | (e: "app-selected", payload: AppSelectedEvent): void |
| 78 | (e: "selection-change", payload: AlgoliaSearchApp[]): void |
| 79 | }>() |
| 80 | |
| 81 | const container = ref<HTMLElement | null>(null) |
| 82 | let root: Root | null = null |
| 83 | const EMPTY_SELECTED_APPS: AlgoliaSearchApp[] = [] |
| 84 | |
| 85 | const themeStore = useThemeStore() |
| 86 | const { isThemeDark } = storeToRefs(themeStore) |
| 87 | |
| 88 | // Connector creds (URL + admin API key) read from CoPilot's `connectors` |
| 89 | // table. Without these, ShuffleMCP fetches private/authenticated apps |
| 90 | // unauthenticated and CORS-blocks against the default `shuffler.io` |
| 91 | // origin. Loaded lazily so the embed doesn't gate on the round-trip |
| 92 | // when the caller already passed explicit overrides. |
| 93 | const connectorApiKey = ref<string | null>(null) |
| 94 | const connectorBaseUrl = ref<string | null>(null) |
| 95 | |
| 96 | const showAppDrawer = ref(false) |
| 97 | const selectedAppName = ref<string | null>(null) |
| 98 | |
| 99 | function render() { |
| 100 | if (!root) return |
| 101 | // Drop undefined props so React doesn't override the package's |
| 102 | // own defaults with our `undefined` (different semantics in TS vs |
| 103 | // React's prop-default mechanism). |
| 104 | const reactProps: Record<string, unknown> = { |
| 105 | authToken: props.authToken, |
| 106 | inline: props.inline, |
| 107 | layout: props.layout, |
| 108 | gridColumns: props.gridColumns, |
| 109 | placeholder: props.placeholder, |
| 110 | preventDefault: props.preventDefault, |
| 111 | multiSelect: props.multiSelect, |
| 112 | showCheckbox: props.showCheckbox, |
| 113 | showSourceFilter: props.showSourceFilter, |
| 114 | // The package currently defaults `selectedApps` to a fresh `[]` |
| 115 | // inside the React component, then mirrors it into state from a |
| 116 | // useEffect. Passing a stable array avoids that render loop. |
| 117 | selectedApps: EMPTY_SELECTED_APPS, |
| 118 | onAppSelected: (payload: AppSelectedEvent) => onAppSelected(payload), |
| 119 | onSelectionChange: (payload: AlgoliaSearchApp[]) => emit("selection-change", payload) |
| 120 | } |
| 121 | // Caller-provided overrides win; fall back to the connector creds. |
| 122 | const effectiveApiKey = props.apiKey ?? connectorApiKey.value |
| 123 | const effectiveBaseUrl = props.apiBaseUrl ?? connectorBaseUrl.value |
| 124 | if (effectiveApiKey) reactProps.apiKey = effectiveApiKey |
| 125 | if (effectiveBaseUrl) reactProps.apiBaseUrl = effectiveBaseUrl |
| 126 | if (props.initialFilterQuery) reactProps.initialFilterQuery = props.initialFilterQuery |
| 127 | |
| 128 | root.render( |
| 129 | createElement( |
| 130 | MuiProvider as never, |
| 131 | { isDark: isThemeDark.value }, |
| 132 | createElement(ShuffleMCP as never, reactProps) |
| 133 | ) |
| 134 | ) |
| 135 | } |
| 136 | |
| 137 | function onAppSelected(payload: AppSelectedEvent) { |
| 138 | emit("app-selected", payload) |
| 139 | |
| 140 | if (props.disableAppDrawer) return |
| 141 | |
| 142 | const name = payload.app?.name |
| 143 | if (!name) return |
| 144 | selectedAppName.value = name |
| 145 | showAppDrawer.value = true |
| 146 | } |
| 147 | |
| 148 | watch(showAppDrawer, value => { |
| 149 | if (!value) { |
| 150 | selectedAppName.value = null |
| 151 | } |
| 152 | }) |
| 153 | |
| 154 | // Re-render when any reactive prop changes. The shallow watch is fine |
| 155 | // since all our props are primitives or strings — no nested object |
| 156 | // identity churn to worry about. |
| 157 | watch( |
| 158 | () => ({ ...props }), |
| 159 | () => render(), |
| 160 | { deep: true } |
| 161 | ) |
| 162 | |
| 163 | // Re-render when the host theme toggles so the MUI provider swaps to |
| 164 | // the matching light/dark palette. |
| 165 | watch(isThemeDark, () => render()) |
| 166 | |
| 167 | onBeforeUnmount(() => { |
| 168 | if (root) { |
| 169 | root.unmount() |
| 170 | root = null |
| 171 | } |
| 172 | }) |
| 173 | |
| 174 | onMounted(async () => { |
| 175 | if (!container.value) return |
| 176 | root = createRoot(container.value) |
| 177 | |
| 178 | // Wait for the connector creds before the first render. The package |
| 179 | // fires its private/authenticated apps fetches eagerly on mount; if we |
| 180 | // render with no apiKey/apiBaseUrl those fetches go to the package |
| 181 | // defaults (shuffler.io, unauthed) and 401/CORS-block. Better to hold |
| 182 | // off one async tick and render once with the right config. |
| 183 | const creds = await fetchShuffleConnectorCredentials() |
| 184 | if (creds) { |
| 185 | connectorApiKey.value = creds.api_key |
| 186 | connectorBaseUrl.value = creds.base_url |
| 187 | } |
| 188 | render() |
| 189 | }) |
| 190 | </script> |