@app-connect/core 1.7.18 → 1.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/connector/proxy/index.js +2 -1
  2. package/handlers/log.js +181 -10
  3. package/handlers/plugin.js +27 -0
  4. package/handlers/user.js +31 -2
  5. package/index.js +99 -22
  6. package/lib/authSession.js +21 -12
  7. package/lib/callLogComposer.js +1 -1
  8. package/lib/debugTracer.js +20 -2
  9. package/lib/util.js +21 -4
  10. package/mcp/README.md +392 -0
  11. package/mcp/mcpHandler.js +293 -82
  12. package/mcp/tools/checkAuthStatus.js +27 -34
  13. package/mcp/tools/createCallLog.js +13 -9
  14. package/mcp/tools/createContact.js +2 -6
  15. package/mcp/tools/doAuth.js +27 -157
  16. package/mcp/tools/findContactByName.js +6 -9
  17. package/mcp/tools/findContactByPhone.js +2 -6
  18. package/mcp/tools/getGoogleFilePicker.js +5 -9
  19. package/mcp/tools/getHelp.js +2 -3
  20. package/mcp/tools/getPublicConnectors.js +41 -28
  21. package/mcp/tools/index.js +11 -36
  22. package/mcp/tools/logout.js +5 -10
  23. package/mcp/tools/rcGetCallLogs.js +3 -20
  24. package/mcp/ui/App/App.tsx +361 -0
  25. package/mcp/ui/App/components/AuthInfoForm.tsx +113 -0
  26. package/mcp/ui/App/components/AuthSuccess.tsx +22 -0
  27. package/mcp/ui/App/components/ConnectorList.tsx +82 -0
  28. package/mcp/ui/App/components/DebugPanel.tsx +43 -0
  29. package/mcp/ui/App/components/OAuthConnect.tsx +270 -0
  30. package/mcp/ui/App/lib/callTool.ts +130 -0
  31. package/mcp/ui/App/lib/debugLog.ts +41 -0
  32. package/mcp/ui/App/lib/developerPortal.ts +111 -0
  33. package/mcp/ui/App/main.css +6 -0
  34. package/mcp/ui/App/root.tsx +13 -0
  35. package/mcp/ui/dist/index.html +53 -0
  36. package/mcp/ui/index.html +13 -0
  37. package/mcp/ui/package-lock.json +6356 -0
  38. package/mcp/ui/package.json +25 -0
  39. package/mcp/ui/tsconfig.json +26 -0
  40. package/mcp/ui/vite.config.ts +16 -0
  41. package/models/llmSessionModel.js +14 -0
  42. package/package.json +72 -72
  43. package/releaseNotes.json +12 -0
  44. package/test/handlers/plugin.test.js +287 -0
  45. package/test/lib/util.test.js +379 -1
  46. package/test/mcp/tools/createCallLog.test.js +3 -3
  47. package/test/mcp/tools/doAuth.test.js +40 -303
  48. package/test/mcp/tools/findContactByName.test.js +3 -3
  49. package/test/mcp/tools/findContactByPhone.test.js +3 -3
  50. package/test/mcp/tools/getGoogleFilePicker.test.js +7 -7
  51. package/test/mcp/tools/getPublicConnectors.test.js +49 -70
  52. package/test/mcp/tools/logout.test.js +2 -2
  53. package/mcp/SupportedPlatforms.md +0 -12
  54. package/mcp/tools/collectAuthInfo.js +0 -91
  55. package/mcp/tools/setConnector.js +0 -69
  56. package/test/mcp/tools/collectAuthInfo.test.js +0 -234
  57. package/test/mcp/tools/setConnector.test.js +0 -177
@@ -0,0 +1,361 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { ConnectorList, type Connector } from './components/ConnectorList'
3
+ import { AuthInfoForm } from './components/AuthInfoForm'
4
+ import { OAuthConnect } from './components/OAuthConnect'
5
+ import { AuthSuccess } from './components/AuthSuccess'
6
+ import { setServerUrl } from './lib/callTool'
7
+ import { fetchConnectors, fetchManifest } from './lib/developerPortal'
8
+ import { dbg } from './lib/debugLog'
9
+ import { DebugPanel } from './components/DebugPanel'
10
+
11
+ // Initial structuredContent from getPublicConnectors — serverUrl, rcAccountId, rcExtensionId, openaiSessionId
12
+ interface ToolOutput {
13
+ serverUrl?: string
14
+ rcAccountId?: string | null
15
+ rcExtensionId?: string | null
16
+ openaiSessionId?: string | null
17
+ error?: boolean
18
+ errorMessage?: string
19
+ }
20
+
21
+ type Step = 'loadingConnectors' | 'select' | 'loading' | 'authInfo' | 'oauth' | 'success' | 'error'
22
+
23
+ interface FlowState {
24
+ connectorManifest: any | null
25
+ connectorName: string | null
26
+ connectorDisplayName: string | null
27
+ hostname: string
28
+ userInfo: any | null
29
+ }
30
+
31
+ const INITIAL_FLOW_STATE: FlowState = {
32
+ connectorManifest: null,
33
+ connectorName: null,
34
+ connectorDisplayName: null,
35
+ hostname: '',
36
+ userInfo: null,
37
+ }
38
+
39
+ /**
40
+ * Extract and apply serverUrl from tool output as early as possible.
41
+ * Must be called synchronously so it's set before any tool calls.
42
+ */
43
+ function applyServerUrl(output: ToolOutput | null) {
44
+ dbg.info('applyServerUrl: received output keys:', output ? Object.keys(output).join(', ') : 'null');
45
+ dbg.info('applyServerUrl: serverUrl =', output?.serverUrl ?? '(none)');
46
+ if (output?.serverUrl) {
47
+ setServerUrl(output.serverUrl);
48
+ }
49
+ return output;
50
+ }
51
+
52
+ /**
53
+ * Hook to receive the initial tool output (serverUrl + rcAccountId).
54
+ *
55
+ * Listens via three mechanisms per the ChatGPT Apps SDK docs:
56
+ * 1. window.openai.toolOutput — synchronous read on mount
57
+ * 2. openai:set_globals event — ChatGPT pushes globals into the iframe
58
+ * 3. ui/notifications/tool-result postMessage — MCP Apps bridge notification
59
+ * 4. Polling window.openai.toolOutput — fallback for async population
60
+ */
61
+ function useToolResult() {
62
+ const [toolResult, setToolResult] = useState<ToolOutput | null>(() => {
63
+ const openai = (window as any).openai
64
+ dbg.info('init: window.openai exists?', !!openai);
65
+ dbg.info('init: window.openai.toolOutput?', JSON.stringify(openai?.toolOutput)?.slice(0, 200) ?? 'null');
66
+ const output = openai?.toolOutput
67
+ if (output && Object.keys(output).length > 0) {
68
+ return applyServerUrl(output)
69
+ }
70
+ return null
71
+ })
72
+
73
+ useEffect(() => {
74
+ if (toolResult) return
75
+
76
+ // MCP Apps bridge: tool-result notification
77
+ const onMessage = (event: MessageEvent) => {
78
+ if (event.source !== window.parent) return
79
+ const message = event.data
80
+ if (!message || message.jsonrpc !== '2.0') return
81
+ if (message.method !== 'ui/notifications/tool-result') return
82
+ const payload = message.params?.structuredContent ?? message.params ?? null
83
+ if (payload) setToolResult(applyServerUrl(payload))
84
+ }
85
+ window.addEventListener('message', onMessage, { passive: true })
86
+
87
+ // ChatGPT extension: openai:set_globals event
88
+ const onSetGlobals = (event: Event) => {
89
+ const detail = (event as CustomEvent).detail
90
+ const output = detail?.globals?.toolOutput ?? (window as any).openai?.toolOutput
91
+ if (output && Object.keys(output).length > 0) {
92
+ setToolResult(applyServerUrl(output))
93
+ }
94
+ }
95
+ window.addEventListener('openai:set_globals', onSetGlobals, { passive: true })
96
+
97
+ // Fallback: poll window.openai.toolOutput for async population
98
+ const interval = setInterval(() => {
99
+ const output = (window as any).openai?.toolOutput
100
+ if (output && Object.keys(output).length > 0) {
101
+ setToolResult(applyServerUrl(output))
102
+ clearInterval(interval)
103
+ }
104
+ }, 100)
105
+
106
+ return () => {
107
+ window.removeEventListener('message', onMessage)
108
+ window.removeEventListener('openai:set_globals', onSetGlobals)
109
+ clearInterval(interval)
110
+ }
111
+ }, [toolResult])
112
+
113
+ return toolResult
114
+ }
115
+
116
+ export function App() {
117
+ const data = useToolResult()
118
+ const [step, setStep] = useState<Step>('loadingConnectors')
119
+ const [connectors, setConnectors] = useState<Connector[]>([])
120
+ const [flow, setFlow] = useState<FlowState>(INITIAL_FLOW_STATE)
121
+ const [errorMsg, setErrorMsg] = useState<string | null>(null)
122
+
123
+ // Fetch connector list from developer portal once serverUrl (and optionally rcAccountId) is known
124
+ useEffect(() => {
125
+ if (!data || data.error) return
126
+
127
+ async function loadConnectors() {
128
+ try {
129
+ dbg.info('loadConnectors: fetching with rcAccountId=', data!.rcAccountId ?? '(none)')
130
+ const list = await fetchConnectors(data!.rcAccountId)
131
+ setConnectors(list)
132
+ setStep('select')
133
+ } catch (err: any) {
134
+ dbg.error('loadConnectors failed:', err.message)
135
+ setStep('error')
136
+ setErrorMsg(err.message || 'Failed to load connectors')
137
+ }
138
+ }
139
+
140
+ loadConnectors()
141
+ }, [data])
142
+
143
+ const resetToSelect = useCallback(() => {
144
+ setStep('select')
145
+ setFlow(INITIAL_FLOW_STATE)
146
+ setErrorMsg(null)
147
+ }, [])
148
+
149
+ const handleConnectorSelect = useCallback(async (connector: Connector) => {
150
+ dbg.info('connector selected:', JSON.stringify(connector))
151
+ setStep('loading')
152
+ setErrorMsg(null)
153
+
154
+ try {
155
+ const manifest = await fetchManifest(
156
+ connector.id,
157
+ connector.status === 'private',
158
+ data?.rcAccountId,
159
+ )
160
+
161
+ if (!manifest) {
162
+ setStep('error')
163
+ setErrorMsg('Failed to load connector configuration')
164
+ return
165
+ }
166
+
167
+ const connectorName = connector.name
168
+ const platform = manifest?.platforms?.[connectorName]
169
+
170
+ if (!platform) {
171
+ setStep('error')
172
+ setErrorMsg('Invalid connector configuration')
173
+ return
174
+ }
175
+
176
+ setFlow((prev) => ({
177
+ ...prev,
178
+ connectorManifest: manifest,
179
+ connectorName,
180
+ connectorDisplayName: connector.displayName,
181
+ }))
182
+
183
+ const envType = platform.environment?.type
184
+ const authType = platform.auth?.type
185
+
186
+ // For oauth with dynamic/selectable environments, collect auth info first
187
+ if (authType === 'oauth' && (envType === 'dynamic' || envType === 'selectable')) {
188
+ setStep('authInfo')
189
+ return
190
+ }
191
+
192
+ // For fixed environments, extract hostname from the fixed URL
193
+ if (envType === 'fixed' && platform.environment?.url) {
194
+ try {
195
+ const url = new URL(platform.environment.url)
196
+ setFlow((prev) => ({ ...prev, hostname: url.hostname }))
197
+ } catch {
198
+ // hostname stays empty
199
+ }
200
+ }
201
+
202
+ setStep('oauth')
203
+ } catch (err: any) {
204
+ setStep('error')
205
+ setErrorMsg(err.message || 'Failed to load connector')
206
+ }
207
+ }, [data?.rcAccountId])
208
+
209
+ const handleAuthInfoSubmit = useCallback(
210
+ (value: { hostname?: string; selection?: string }) => {
211
+ try {
212
+ const platform = flow.connectorManifest?.platforms?.[flow.connectorName!]
213
+ const envType = platform?.environment?.type
214
+ let resolvedHostname = ''
215
+
216
+ if (envType === 'dynamic' && value.hostname) {
217
+ resolvedHostname = new URL(value.hostname).hostname
218
+ } else if (envType === 'selectable' && value.selection) {
219
+ const sel = platform.environment.selections?.find(
220
+ (s: { name: string; const: string }) => s.name === value.selection,
221
+ )
222
+ if (sel?.const) {
223
+ resolvedHostname = new URL(sel.const).hostname
224
+ }
225
+ }
226
+
227
+ setFlow((prev) => ({ ...prev, hostname: resolvedHostname }))
228
+ setStep('oauth')
229
+ } catch (err: any) {
230
+ setStep('error')
231
+ setErrorMsg(err.message || 'Failed to process environment selection')
232
+ }
233
+ },
234
+ [flow.connectorManifest, flow.connectorName],
235
+ )
236
+
237
+ const handleAuthSuccess = useCallback(
238
+ (authData: { jwtToken?: string; userInfo?: any }) => {
239
+ setFlow((prev) => ({ ...prev, userInfo: authData.userInfo ?? null }))
240
+ setStep('success')
241
+ },
242
+ [],
243
+ )
244
+
245
+ const handleAuthError = useCallback(
246
+ (error: string) => {
247
+ if (error === 'retry') {
248
+ setStep('oauth')
249
+ return
250
+ }
251
+ setStep('error')
252
+ setErrorMsg(error)
253
+ },
254
+ [],
255
+ )
256
+
257
+ // Waiting for initial tool output from ChatGPT
258
+ if (!data) {
259
+ return (
260
+ <div className="flex items-center justify-center min-h-[150px] p-4">
261
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
262
+ </div>
263
+ )
264
+ }
265
+
266
+ // Error from initial tool call
267
+ if (data.error) {
268
+ return (
269
+ <div className="p-4">
270
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4">
271
+ <p className="text-red-700">{data.errorMessage || 'An error occurred'}</p>
272
+ </div>
273
+ <DebugPanel />
274
+ </div>
275
+ )
276
+ }
277
+
278
+ return (
279
+ <div className="p-4">
280
+ {step === 'loadingConnectors' && (
281
+ <div className="flex items-center justify-center min-h-[150px]">
282
+ <div className="text-center space-y-3">
283
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
284
+ <p className="text-sm text-secondary">Loading connectors...</p>
285
+ </div>
286
+ </div>
287
+ )}
288
+
289
+ {step === 'select' && (
290
+ <ConnectorList connectors={connectors} onSelect={handleConnectorSelect} />
291
+ )}
292
+
293
+ {step === 'loading' && (
294
+ <div className="flex items-center justify-center min-h-[150px]">
295
+ <div className="text-center space-y-3">
296
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
297
+ <p className="text-sm text-secondary">Loading...</p>
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {step === 'authInfo' && flow.connectorManifest && flow.connectorName && (
303
+ <AuthInfoForm
304
+ environmentType={
305
+ flow.connectorManifest.platforms[flow.connectorName].environment.type
306
+ }
307
+ urlIdentifier={
308
+ flow.connectorManifest.platforms[flow.connectorName].environment.urlIdentifier
309
+ }
310
+ instructions={
311
+ flow.connectorManifest.platforms[flow.connectorName].environment.instructions
312
+ }
313
+ selections={
314
+ flow.connectorManifest.platforms[flow.connectorName].environment.selections
315
+ }
316
+ connectorDisplayName={flow.connectorDisplayName!}
317
+ onSubmit={handleAuthInfoSubmit}
318
+ onBack={resetToSelect}
319
+ />
320
+ )}
321
+
322
+ {step === 'oauth' && flow.connectorManifest && flow.connectorName && (
323
+ <OAuthConnect
324
+ connectorManifest={flow.connectorManifest}
325
+ connectorName={flow.connectorName}
326
+ connectorDisplayName={flow.connectorDisplayName!}
327
+ hostname={flow.hostname}
328
+ openaiSessionId={data?.openaiSessionId ?? null}
329
+ rcExtensionId={data?.rcExtensionId ?? null}
330
+ onSuccess={handleAuthSuccess}
331
+ onError={handleAuthError}
332
+ onBack={resetToSelect}
333
+ />
334
+ )}
335
+
336
+ {step === 'success' && (
337
+ <AuthSuccess
338
+ connectorDisplayName={flow.connectorDisplayName!}
339
+ userInfo={flow.userInfo ?? undefined}
340
+ />
341
+ )}
342
+
343
+ {step === 'error' && (
344
+ <div className="w-full max-w-md">
345
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 mb-4">
346
+ <p className="text-red-700 text-sm font-semibold">Error</p>
347
+ <p className="text-red-700 text-sm mt-1">{errorMsg || 'An error occurred'}</p>
348
+ </div>
349
+ <button
350
+ type="button"
351
+ onClick={resetToSelect}
352
+ className="text-sm text-primary hover:underline cursor-pointer"
353
+ >
354
+ &larr; Back to connector list
355
+ </button>
356
+ <DebugPanel />
357
+ </div>
358
+ )}
359
+ </div>
360
+ )
361
+ }
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button'
3
+ import { Input } from '@openai/apps-sdk-ui/components/Input'
4
+
5
+ interface Selection {
6
+ name: string
7
+ const: string
8
+ }
9
+
10
+ interface AuthInfoFormProps {
11
+ environmentType: 'dynamic' | 'selectable'
12
+ urlIdentifier?: string
13
+ instructions?: string
14
+ selections?: Selection[]
15
+ connectorDisplayName: string
16
+ onSubmit: (value: { hostname?: string; selection?: string }) => void
17
+ onBack: () => void
18
+ }
19
+
20
+ export function AuthInfoForm({
21
+ environmentType,
22
+ urlIdentifier,
23
+ instructions,
24
+ selections,
25
+ connectorDisplayName,
26
+ onSubmit,
27
+ onBack,
28
+ }: AuthInfoFormProps) {
29
+ const [hostname, setHostname] = useState('')
30
+ const [selectedName, setSelectedName] = useState<string | null>(null)
31
+
32
+ const handleSubmit = (e: React.FormEvent) => {
33
+ e.preventDefault()
34
+ if (environmentType === 'dynamic') {
35
+ onSubmit({ hostname })
36
+ } else if (selectedName) {
37
+ onSubmit({ selection: selectedName })
38
+ }
39
+ }
40
+
41
+ return (
42
+ <div className="w-full max-w-md">
43
+ <div className="mb-4">
44
+ <button
45
+ type="button"
46
+ onClick={onBack}
47
+ className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
48
+ >
49
+ &larr; Back
50
+ </button>
51
+ <h2 className="heading-lg">Connect to {connectorDisplayName}</h2>
52
+ </div>
53
+
54
+ <form onSubmit={handleSubmit} className="space-y-4">
55
+ {environmentType === 'dynamic' && (
56
+ <div className="space-y-2">
57
+ <p className="text-secondary text-sm">
58
+ {instructions || `Enter your ${connectorDisplayName} hostname`}
59
+ </p>
60
+ {urlIdentifier && (
61
+ <p className="text-xs text-secondary opacity-70">
62
+ Example: {urlIdentifier}
63
+ </p>
64
+ )}
65
+ <Input
66
+ type="url"
67
+ value={hostname}
68
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHostname(e.target.value)}
69
+ placeholder="https://your-instance.example.com"
70
+ size="md"
71
+ />
72
+ </div>
73
+ )}
74
+
75
+ {environmentType === 'selectable' && selections && (
76
+ <div className="space-y-2">
77
+ <p className="text-secondary text-sm">
78
+ Select your {connectorDisplayName} environment
79
+ </p>
80
+ <div className="space-y-2">
81
+ {selections.map((sel) => (
82
+ <button
83
+ key={sel.name}
84
+ type="button"
85
+ onClick={() => setSelectedName(sel.name)}
86
+ className={`w-full text-left rounded-xl border p-3 transition-colors cursor-pointer ${
87
+ selectedName === sel.name
88
+ ? 'border-primary bg-surface-hover'
89
+ : 'border-default bg-surface hover:border-primary'
90
+ }`}
91
+ >
92
+ <span className="font-medium text-sm">{sel.name}</span>
93
+ </button>
94
+ ))}
95
+ </div>
96
+ </div>
97
+ )}
98
+
99
+ <Button
100
+ type="submit"
101
+ color="primary"
102
+ size="md"
103
+ disabled={
104
+ (environmentType === 'dynamic' && !hostname.trim()) ||
105
+ (environmentType === 'selectable' && !selectedName)
106
+ }
107
+ >
108
+ Continue
109
+ </Button>
110
+ </form>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,22 @@
1
+ interface AuthSuccessProps {
2
+ connectorDisplayName: string
3
+ userInfo?: { id?: string; name?: string }
4
+ }
5
+
6
+ export function AuthSuccess({ connectorDisplayName, userInfo }: AuthSuccessProps) {
7
+ return (
8
+ <div className="w-full max-w-md">
9
+ <div className="rounded-xl border border-green-200 bg-green-50 p-5 text-center space-y-2">
10
+ <div className="text-2xl text-green-700">&#10003;</div>
11
+ <h2 className="heading-lg text-green-800">Connected</h2>
12
+ <p className="text-sm text-green-700">
13
+ Successfully connected to <strong>{connectorDisplayName}</strong>
14
+ {userInfo?.name ? ` as ${userInfo.name}` : ''}.
15
+ </p>
16
+ <p className="text-xs text-green-600 mt-2">
17
+ You can now use the AI assistant to interact with your CRM.
18
+ </p>
19
+ </div>
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,82 @@
1
+ import { useState } from 'react'
2
+ import { Button } from "@openai/apps-sdk-ui/components/Button"
3
+ import { Badge } from "@openai/apps-sdk-ui/components/Badge"
4
+
5
+ export interface Connector {
6
+ id: string
7
+ name: string
8
+ displayName: string
9
+ description?: string
10
+ status?: 'public' | 'private'
11
+ }
12
+
13
+ interface ConnectorListProps {
14
+ connectors: Connector[]
15
+ disabled?: boolean
16
+ onSelect: (connector: Connector) => void
17
+ }
18
+
19
+ export function ConnectorList({ connectors, disabled, onSelect }: ConnectorListProps) {
20
+ const [selectedName, setSelectedName] = useState<string | null>(null)
21
+
22
+ if (!connectors || connectors.length === 0) {
23
+ return (
24
+ <div className="p-4 text-center text-secondary">
25
+ <p>No connectors available</p>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ const handleSelect = (connector: Connector) => {
31
+ setSelectedName(connector.displayName)
32
+ onSelect(connector)
33
+ }
34
+
35
+ return (
36
+ <div className="w-full max-w-md">
37
+ <div className="mb-4">
38
+ <h2 className="heading-lg">Available Connectors</h2>
39
+ <p className="text-secondary text-sm mt-1">
40
+ Select a CRM to connect with RingCentral
41
+ </p>
42
+ </div>
43
+
44
+ <div className="space-y-3">
45
+ {connectors.map((connector) => {
46
+ const isSelected = selectedName === connector.displayName
47
+ return (
48
+ <div
49
+ key={connector.displayName}
50
+ className="rounded-xl border border-default bg-surface p-4 hover:border-primary transition-colors"
51
+ >
52
+ <div className="flex items-center justify-between gap-3">
53
+ <div className="flex-1">
54
+ <div className="flex items-center gap-2">
55
+ <h3 className="font-medium">{connector.displayName}</h3>
56
+ {connector.status === 'private' && (
57
+ <Badge color="secondary" size="sm">Private</Badge>
58
+ )}
59
+ </div>
60
+ {connector.description && (
61
+ <p className="text-secondary text-sm mt-1">
62
+ {connector.description}
63
+ </p>
64
+ )}
65
+ </div>
66
+ <Button
67
+ color={isSelected ? 'secondary' : 'primary'}
68
+ size="sm"
69
+ onClick={() => handleSelect(connector)}
70
+ disabled={disabled || isSelected}
71
+ >
72
+ {isSelected ? 'Connecting...' : 'Connect'}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ )
77
+ })}
78
+ </div>
79
+ </div>
80
+ )
81
+ }
82
+
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { getEntries, subscribe, type DebugEntry } from '../lib/debugLog'
3
+
4
+ const LEVEL_STYLE: Record<DebugEntry['level'], string> = {
5
+ info: 'text-blue-700',
6
+ warn: 'text-yellow-700',
7
+ error: 'text-red-700 font-semibold',
8
+ }
9
+
10
+ export function DebugPanel() {
11
+ const [entries, setEntries] = useState(getEntries)
12
+ const [open, setOpen] = useState(false)
13
+
14
+ useEffect(() => subscribe(() => setEntries(getEntries())), [])
15
+
16
+ return (
17
+ <div className="mt-4 border border-default rounded-lg overflow-hidden text-xs">
18
+ <button
19
+ type="button"
20
+ onClick={() => setOpen((v) => !v)}
21
+ className="w-full flex items-center justify-between px-3 py-2 bg-surface hover:bg-surface-hover cursor-pointer text-secondary"
22
+ >
23
+ <span>Debug log ({entries.length} entries)</span>
24
+ <span>{open ? '▲' : '▼'}</span>
25
+ </button>
26
+
27
+ {open && (
28
+ <div className="max-h-60 overflow-y-auto bg-black/5 p-2 space-y-0.5 font-mono">
29
+ {entries.length === 0 && (
30
+ <p className="text-secondary italic">No entries yet.</p>
31
+ )}
32
+ {entries.map((e, i) => (
33
+ <div key={i} className={`${LEVEL_STYLE[e.level]} leading-tight`}>
34
+ <span className="opacity-50 mr-1">{e.time}</span>
35
+ <span>[{e.level}]</span>{' '}
36
+ <span className="break-all">{e.msg}</span>
37
+ </div>
38
+ ))}
39
+ </div>
40
+ )}
41
+ </div>
42
+ )
43
+ }