main
vue 190 lines 6.34 KB
Raw
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>