@brainfish-ai/devdoc 0.1.21

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 (268) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +415 -0
  3. package/bin/devdoc.js +13 -0
  4. package/dist/cli/commands/build.d.ts +5 -0
  5. package/dist/cli/commands/build.js +87 -0
  6. package/dist/cli/commands/check.d.ts +1 -0
  7. package/dist/cli/commands/check.js +143 -0
  8. package/dist/cli/commands/create.d.ts +24 -0
  9. package/dist/cli/commands/create.js +387 -0
  10. package/dist/cli/commands/deploy.d.ts +9 -0
  11. package/dist/cli/commands/deploy.js +433 -0
  12. package/dist/cli/commands/dev.d.ts +6 -0
  13. package/dist/cli/commands/dev.js +139 -0
  14. package/dist/cli/commands/init.d.ts +11 -0
  15. package/dist/cli/commands/init.js +238 -0
  16. package/dist/cli/commands/keys.d.ts +12 -0
  17. package/dist/cli/commands/keys.js +165 -0
  18. package/dist/cli/commands/start.d.ts +5 -0
  19. package/dist/cli/commands/start.js +56 -0
  20. package/dist/cli/commands/upload.d.ts +13 -0
  21. package/dist/cli/commands/upload.js +238 -0
  22. package/dist/cli/commands/whoami.d.ts +8 -0
  23. package/dist/cli/commands/whoami.js +91 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +106 -0
  26. package/dist/config/index.d.ts +80 -0
  27. package/dist/config/index.js +133 -0
  28. package/dist/constants.d.ts +9 -0
  29. package/dist/constants.js +13 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.js +12 -0
  32. package/dist/utils/logger.d.ts +16 -0
  33. package/dist/utils/logger.js +61 -0
  34. package/dist/utils/paths.d.ts +16 -0
  35. package/dist/utils/paths.js +50 -0
  36. package/package.json +51 -0
  37. package/renderer/app/api/assets/[...path]/route.ts +123 -0
  38. package/renderer/app/api/assets/route.ts +124 -0
  39. package/renderer/app/api/assets/upload/route.ts +177 -0
  40. package/renderer/app/api/auth-schemes/route.ts +77 -0
  41. package/renderer/app/api/chat/route.ts +858 -0
  42. package/renderer/app/api/codegen/route.ts +72 -0
  43. package/renderer/app/api/collections/route.ts +1016 -0
  44. package/renderer/app/api/debug/route.ts +53 -0
  45. package/renderer/app/api/deploy/route.ts +234 -0
  46. package/renderer/app/api/device/route.ts +42 -0
  47. package/renderer/app/api/docs/route.ts +187 -0
  48. package/renderer/app/api/keys/regenerate/route.ts +80 -0
  49. package/renderer/app/api/openapi-spec/route.ts +151 -0
  50. package/renderer/app/api/projects/[slug]/route.ts +153 -0
  51. package/renderer/app/api/projects/[slug]/stats/route.ts +96 -0
  52. package/renderer/app/api/projects/register/route.ts +152 -0
  53. package/renderer/app/api/proxy/route.ts +149 -0
  54. package/renderer/app/api/proxy-stream/route.ts +168 -0
  55. package/renderer/app/api/redirects/route.ts +47 -0
  56. package/renderer/app/api/schema/route.ts +65 -0
  57. package/renderer/app/api/subdomains/check/route.ts +172 -0
  58. package/renderer/app/api/suggestions/route.ts +144 -0
  59. package/renderer/app/favicon.ico +0 -0
  60. package/renderer/app/globals.css +1103 -0
  61. package/renderer/app/layout.tsx +47 -0
  62. package/renderer/app/llms-full.txt/route.ts +346 -0
  63. package/renderer/app/llms.txt/route.ts +279 -0
  64. package/renderer/app/page.tsx +14 -0
  65. package/renderer/app/robots.txt/route.ts +84 -0
  66. package/renderer/app/sitemap.xml/route.ts +199 -0
  67. package/renderer/components/docs/index.ts +12 -0
  68. package/renderer/components/docs/mdx/accordion.tsx +169 -0
  69. package/renderer/components/docs/mdx/badge.tsx +132 -0
  70. package/renderer/components/docs/mdx/callouts.tsx +154 -0
  71. package/renderer/components/docs/mdx/cards.tsx +213 -0
  72. package/renderer/components/docs/mdx/changelog.tsx +120 -0
  73. package/renderer/components/docs/mdx/code-block.tsx +186 -0
  74. package/renderer/components/docs/mdx/code-group.tsx +421 -0
  75. package/renderer/components/docs/mdx/file-embeds.tsx +105 -0
  76. package/renderer/components/docs/mdx/frame.tsx +112 -0
  77. package/renderer/components/docs/mdx/highlight.tsx +151 -0
  78. package/renderer/components/docs/mdx/iframe.tsx +134 -0
  79. package/renderer/components/docs/mdx/image.tsx +235 -0
  80. package/renderer/components/docs/mdx/index.ts +204 -0
  81. package/renderer/components/docs/mdx/mermaid.tsx +240 -0
  82. package/renderer/components/docs/mdx/param-field.tsx +200 -0
  83. package/renderer/components/docs/mdx/steps.tsx +113 -0
  84. package/renderer/components/docs/mdx/tabs.tsx +86 -0
  85. package/renderer/components/docs/mdx-renderer.tsx +100 -0
  86. package/renderer/components/docs/navigation/breadcrumbs.tsx +76 -0
  87. package/renderer/components/docs/navigation/index.ts +8 -0
  88. package/renderer/components/docs/navigation/page-nav.tsx +64 -0
  89. package/renderer/components/docs/navigation/sidebar.tsx +515 -0
  90. package/renderer/components/docs/navigation/toc.tsx +113 -0
  91. package/renderer/components/docs/notice.tsx +105 -0
  92. package/renderer/components/docs-header.tsx +274 -0
  93. package/renderer/components/docs-viewer/agent/agent-chat.tsx +2076 -0
  94. package/renderer/components/docs-viewer/agent/cards/debug-context-card.tsx +90 -0
  95. package/renderer/components/docs-viewer/agent/cards/endpoint-context-card.tsx +49 -0
  96. package/renderer/components/docs-viewer/agent/cards/index.tsx +50 -0
  97. package/renderer/components/docs-viewer/agent/cards/response-options-card.tsx +212 -0
  98. package/renderer/components/docs-viewer/agent/cards/types.ts +84 -0
  99. package/renderer/components/docs-viewer/agent/chat-message.tsx +17 -0
  100. package/renderer/components/docs-viewer/agent/index.tsx +6 -0
  101. package/renderer/components/docs-viewer/agent/messages/assistant-message.tsx +119 -0
  102. package/renderer/components/docs-viewer/agent/messages/chat-message.tsx +46 -0
  103. package/renderer/components/docs-viewer/agent/messages/index.ts +17 -0
  104. package/renderer/components/docs-viewer/agent/messages/tool-call-display.tsx +721 -0
  105. package/renderer/components/docs-viewer/agent/messages/types.ts +61 -0
  106. package/renderer/components/docs-viewer/agent/messages/typing-indicator.tsx +24 -0
  107. package/renderer/components/docs-viewer/agent/messages/user-message.tsx +51 -0
  108. package/renderer/components/docs-viewer/code-editor/index.tsx +2 -0
  109. package/renderer/components/docs-viewer/code-editor/notes-mode.tsx +1283 -0
  110. package/renderer/components/docs-viewer/content/changelog-page.tsx +331 -0
  111. package/renderer/components/docs-viewer/content/doc-page.tsx +285 -0
  112. package/renderer/components/docs-viewer/content/documentation-viewer.tsx +17 -0
  113. package/renderer/components/docs-viewer/content/index.tsx +29 -0
  114. package/renderer/components/docs-viewer/content/introduction.tsx +21 -0
  115. package/renderer/components/docs-viewer/content/request-details.tsx +330 -0
  116. package/renderer/components/docs-viewer/content/sections/auth.tsx +69 -0
  117. package/renderer/components/docs-viewer/content/sections/body.tsx +66 -0
  118. package/renderer/components/docs-viewer/content/sections/headers.tsx +43 -0
  119. package/renderer/components/docs-viewer/content/sections/overview.tsx +40 -0
  120. package/renderer/components/docs-viewer/content/sections/parameters.tsx +43 -0
  121. package/renderer/components/docs-viewer/content/sections/responses.tsx +87 -0
  122. package/renderer/components/docs-viewer/global-auth-modal.tsx +352 -0
  123. package/renderer/components/docs-viewer/index.tsx +1466 -0
  124. package/renderer/components/docs-viewer/playground/auth-editor.tsx +280 -0
  125. package/renderer/components/docs-viewer/playground/body-editor.tsx +221 -0
  126. package/renderer/components/docs-viewer/playground/code-editor.tsx +224 -0
  127. package/renderer/components/docs-viewer/playground/code-snippet.tsx +387 -0
  128. package/renderer/components/docs-viewer/playground/graphql-playground.tsx +745 -0
  129. package/renderer/components/docs-viewer/playground/index.tsx +671 -0
  130. package/renderer/components/docs-viewer/playground/key-value-editor.tsx +261 -0
  131. package/renderer/components/docs-viewer/playground/method-selector.tsx +60 -0
  132. package/renderer/components/docs-viewer/playground/request-builder.tsx +179 -0
  133. package/renderer/components/docs-viewer/playground/request-tabs.tsx +237 -0
  134. package/renderer/components/docs-viewer/playground/response-cards/idle-card.tsx +21 -0
  135. package/renderer/components/docs-viewer/playground/response-cards/index.tsx +93 -0
  136. package/renderer/components/docs-viewer/playground/response-cards/loading-card.tsx +16 -0
  137. package/renderer/components/docs-viewer/playground/response-cards/network-error-card.tsx +23 -0
  138. package/renderer/components/docs-viewer/playground/response-cards/response-body-card.tsx +268 -0
  139. package/renderer/components/docs-viewer/playground/response-cards/types.ts +82 -0
  140. package/renderer/components/docs-viewer/playground/response-viewer.tsx +43 -0
  141. package/renderer/components/docs-viewer/search/index.ts +2 -0
  142. package/renderer/components/docs-viewer/search/search-dialog.tsx +331 -0
  143. package/renderer/components/docs-viewer/search/use-search.ts +117 -0
  144. package/renderer/components/docs-viewer/shared/markdown-renderer.tsx +431 -0
  145. package/renderer/components/docs-viewer/shared/method-badge.tsx +41 -0
  146. package/renderer/components/docs-viewer/shared/schema-viewer.tsx +349 -0
  147. package/renderer/components/docs-viewer/sidebar/collection-tree.tsx +239 -0
  148. package/renderer/components/docs-viewer/sidebar/endpoint-options.tsx +316 -0
  149. package/renderer/components/docs-viewer/sidebar/index.tsx +343 -0
  150. package/renderer/components/docs-viewer/sidebar/right-sidebar.tsx +202 -0
  151. package/renderer/components/docs-viewer/sidebar/sidebar-group.tsx +118 -0
  152. package/renderer/components/docs-viewer/sidebar/sidebar-item.tsx +226 -0
  153. package/renderer/components/docs-viewer/sidebar/sidebar-section.tsx +52 -0
  154. package/renderer/components/theme-provider.tsx +11 -0
  155. package/renderer/components/theme-toggle.tsx +76 -0
  156. package/renderer/components/ui/badge.tsx +46 -0
  157. package/renderer/components/ui/button.tsx +59 -0
  158. package/renderer/components/ui/dialog.tsx +118 -0
  159. package/renderer/components/ui/dropdown-menu.tsx +257 -0
  160. package/renderer/components/ui/input.tsx +21 -0
  161. package/renderer/components/ui/label.tsx +24 -0
  162. package/renderer/components/ui/navigation-menu.tsx +168 -0
  163. package/renderer/components/ui/select.tsx +190 -0
  164. package/renderer/components/ui/spinner.tsx +114 -0
  165. package/renderer/components/ui/tabs.tsx +66 -0
  166. package/renderer/components/ui/tooltip.tsx +61 -0
  167. package/renderer/hooks/use-code-copy.ts +88 -0
  168. package/renderer/hooks/use-openapi-title.ts +44 -0
  169. package/renderer/lib/api-docs/agent/index.ts +6 -0
  170. package/renderer/lib/api-docs/agent/indexer.ts +323 -0
  171. package/renderer/lib/api-docs/agent/spec-summary.ts +335 -0
  172. package/renderer/lib/api-docs/agent/types.ts +116 -0
  173. package/renderer/lib/api-docs/auth/auth-context.tsx +225 -0
  174. package/renderer/lib/api-docs/auth/auth-storage.ts +87 -0
  175. package/renderer/lib/api-docs/auth/crypto.ts +89 -0
  176. package/renderer/lib/api-docs/auth/index.ts +4 -0
  177. package/renderer/lib/api-docs/code-editor/db.ts +164 -0
  178. package/renderer/lib/api-docs/code-editor/hooks.ts +266 -0
  179. package/renderer/lib/api-docs/code-editor/index.ts +6 -0
  180. package/renderer/lib/api-docs/code-editor/mode-context.tsx +207 -0
  181. package/renderer/lib/api-docs/code-editor/types.ts +105 -0
  182. package/renderer/lib/api-docs/codegen/definitions.ts +297 -0
  183. package/renderer/lib/api-docs/codegen/har.ts +251 -0
  184. package/renderer/lib/api-docs/codegen/index.ts +159 -0
  185. package/renderer/lib/api-docs/factories.ts +151 -0
  186. package/renderer/lib/api-docs/index.ts +17 -0
  187. package/renderer/lib/api-docs/mobile-context.tsx +112 -0
  188. package/renderer/lib/api-docs/navigation-context.tsx +88 -0
  189. package/renderer/lib/api-docs/parsers/graphql/README.md +129 -0
  190. package/renderer/lib/api-docs/parsers/graphql/index.ts +91 -0
  191. package/renderer/lib/api-docs/parsers/graphql/parser.ts +491 -0
  192. package/renderer/lib/api-docs/parsers/graphql/transformer.ts +246 -0
  193. package/renderer/lib/api-docs/parsers/graphql/types.ts +283 -0
  194. package/renderer/lib/api-docs/parsers/openapi/README.md +32 -0
  195. package/renderer/lib/api-docs/parsers/openapi/dereferencer.ts +60 -0
  196. package/renderer/lib/api-docs/parsers/openapi/extractors/auth.ts +574 -0
  197. package/renderer/lib/api-docs/parsers/openapi/extractors/body.ts +403 -0
  198. package/renderer/lib/api-docs/parsers/openapi/extractors/index.ts +232 -0
  199. package/renderer/lib/api-docs/parsers/openapi/index.ts +171 -0
  200. package/renderer/lib/api-docs/parsers/openapi/transformer.ts +277 -0
  201. package/renderer/lib/api-docs/parsers/openapi/validator.ts +31 -0
  202. package/renderer/lib/api-docs/playground/context.tsx +107 -0
  203. package/renderer/lib/api-docs/playground/navigation-context.tsx +124 -0
  204. package/renderer/lib/api-docs/playground/request-builder.ts +223 -0
  205. package/renderer/lib/api-docs/playground/request-runner.ts +282 -0
  206. package/renderer/lib/api-docs/playground/types.ts +35 -0
  207. package/renderer/lib/api-docs/types.ts +269 -0
  208. package/renderer/lib/api-docs/utils.ts +311 -0
  209. package/renderer/lib/cache.ts +193 -0
  210. package/renderer/lib/docs/config/index.ts +29 -0
  211. package/renderer/lib/docs/config/loader.ts +142 -0
  212. package/renderer/lib/docs/config/schema.ts +298 -0
  213. package/renderer/lib/docs/index.ts +12 -0
  214. package/renderer/lib/docs/mdx/compiler.ts +176 -0
  215. package/renderer/lib/docs/mdx/frontmatter.ts +80 -0
  216. package/renderer/lib/docs/mdx/index.ts +26 -0
  217. package/renderer/lib/docs/navigation/generator.ts +348 -0
  218. package/renderer/lib/docs/navigation/index.ts +12 -0
  219. package/renderer/lib/docs/navigation/types.ts +123 -0
  220. package/renderer/lib/docs-navigation-context.tsx +80 -0
  221. package/renderer/lib/multi-tenant/context.ts +105 -0
  222. package/renderer/lib/storage/blob.ts +845 -0
  223. package/renderer/lib/utils.ts +6 -0
  224. package/renderer/next.config.ts +76 -0
  225. package/renderer/package.json +66 -0
  226. package/renderer/postcss.config.mjs +5 -0
  227. package/renderer/public/assets/images/screenshot.png +0 -0
  228. package/renderer/public/assets/logo/dark.svg +9 -0
  229. package/renderer/public/assets/logo/light.svg +9 -0
  230. package/renderer/public/assets/logo.svg +9 -0
  231. package/renderer/public/file.svg +1 -0
  232. package/renderer/public/globe.svg +1 -0
  233. package/renderer/public/icon.png +0 -0
  234. package/renderer/public/logo.svg +9 -0
  235. package/renderer/public/window.svg +1 -0
  236. package/renderer/tsconfig.json +28 -0
  237. package/templates/basic/README.md +139 -0
  238. package/templates/basic/assets/favicon.svg +4 -0
  239. package/templates/basic/assets/logo.svg +9 -0
  240. package/templates/basic/docs.json +47 -0
  241. package/templates/basic/guides/configuration.mdx +149 -0
  242. package/templates/basic/guides/overview.mdx +96 -0
  243. package/templates/basic/index.mdx +39 -0
  244. package/templates/basic/package.json +14 -0
  245. package/templates/basic/quickstart.mdx +92 -0
  246. package/templates/basic/vercel.json +6 -0
  247. package/templates/graphql/README.md +139 -0
  248. package/templates/graphql/api-reference/schema.graphql +305 -0
  249. package/templates/graphql/assets/favicon.svg +4 -0
  250. package/templates/graphql/assets/logo.svg +9 -0
  251. package/templates/graphql/docs.json +54 -0
  252. package/templates/graphql/guides/configuration.mdx +149 -0
  253. package/templates/graphql/guides/overview.mdx +96 -0
  254. package/templates/graphql/index.mdx +39 -0
  255. package/templates/graphql/package.json +14 -0
  256. package/templates/graphql/quickstart.mdx +92 -0
  257. package/templates/graphql/vercel.json +6 -0
  258. package/templates/openapi/README.md +139 -0
  259. package/templates/openapi/api-reference/openapi.json +419 -0
  260. package/templates/openapi/assets/favicon.svg +4 -0
  261. package/templates/openapi/assets/logo.svg +9 -0
  262. package/templates/openapi/docs.json +61 -0
  263. package/templates/openapi/guides/configuration.mdx +149 -0
  264. package/templates/openapi/guides/overview.mdx +96 -0
  265. package/templates/openapi/index.mdx +39 -0
  266. package/templates/openapi/package.json +14 -0
  267. package/templates/openapi/quickstart.mdx +92 -0
  268. package/templates/openapi/vercel.json +6 -0
@@ -0,0 +1,2076 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useCallback, useState, FormEvent, useMemo } from 'react'
4
+ import { useChat } from '@ai-sdk/react'
5
+ import {
6
+ DefaultChatTransport,
7
+ lastAssistantMessageIsCompleteWithToolCalls,
8
+ UIMessage,
9
+ } from 'ai'
10
+ import {
11
+ ArrowUp,
12
+ Square,
13
+ Image as ImageIcon,
14
+ X
15
+ } from '@phosphor-icons/react'
16
+ import { Button } from '@/components/ui/button'
17
+ import type { BrainfishCollection, BrainfishRESTRequest } from '@/lib/api-docs/types'
18
+ import type { PrefillData, RequestValidationResult } from '@/lib/api-docs/agent/types'
19
+ import type { DebugContext } from '../playground/response-viewer'
20
+ import { encodeCardMessage, detectErrorType } from './cards'
21
+ import { buildEndpointIndex, searchEndpoints, findRequestById, formatRequestForAI } from '@/lib/api-docs/agent/indexer'
22
+ import { ChatMessage, TypingIndicator } from './chat-message'
23
+ import { cn } from '@/lib/utils'
24
+ import {
25
+ Tooltip,
26
+ TooltipContent,
27
+ TooltipTrigger,
28
+ } from '@/components/ui/tooltip'
29
+ import { useModeContext, useNotes, useWorkspace } from '@/lib/api-docs/code-editor'
30
+ import { useCurrentRequestPayload, useSendTrigger } from '@/lib/api-docs/playground/context'
31
+
32
+ /**
33
+ * Custom transport that intercepts streaming events for write_file tool
34
+ * Wraps DefaultChatTransport and intercepts the stream to extract write_file content
35
+ */
36
+ function createStreamingTransport(
37
+ config: ConstructorParameters<typeof DefaultChatTransport>[0],
38
+ callbacks: {
39
+ onWriteFileDelta: (toolCallId: string, path: string, content: string) => void
40
+ onWriteFileStart: (toolCallId: string, path: string) => void
41
+ onWriteFileEnd: (toolCallId: string) => void
42
+ }
43
+ ): DefaultChatTransport<UIMessage> {
44
+ const transport = new DefaultChatTransport<UIMessage>(config)
45
+
46
+ // Track active write_file tool calls
47
+ const activeWriteFiles = new Map<string, { path: string; lastContent: string }>()
48
+ const toolInputBuffers = new Map<string, string>()
49
+
50
+ // Override the fetch to intercept the stream
51
+ const originalFetch = transport['fetch'] || globalThis.fetch
52
+
53
+ transport['fetch'] = async (input: RequestInfo | URL, init?: RequestInit) => {
54
+ const response = await originalFetch(input, init)
55
+
56
+ if (!response.body) return response
57
+
58
+ // Create a transform stream to intercept and process the SSE events
59
+ const reader = response.body.getReader()
60
+ const decoder = new TextDecoder()
61
+ let buffer = ''
62
+
63
+ const processedStream = new ReadableStream({
64
+ async start(controller) {
65
+ try {
66
+ while (true) {
67
+ const { done, value } = await reader.read()
68
+
69
+ if (done) {
70
+ // Stream ended - close all active write_files
71
+ for (const [toolCallId] of activeWriteFiles) {
72
+ callbacks.onWriteFileEnd(toolCallId)
73
+ }
74
+ activeWriteFiles.clear()
75
+ toolInputBuffers.clear()
76
+ controller.close()
77
+ break
78
+ }
79
+
80
+ // Pass through the original data
81
+ controller.enqueue(value)
82
+
83
+ // Parse the SSE events for write_file content
84
+ buffer += decoder.decode(value, { stream: true })
85
+ const lines = buffer.split('\n')
86
+ buffer = lines.pop() || '' // Keep incomplete line in buffer
87
+
88
+ for (const line of lines) {
89
+ if (line.startsWith('data: ')) {
90
+ const data = line.slice(6)
91
+ if (data === '[DONE]') continue
92
+
93
+ try {
94
+ const event = JSON.parse(data)
95
+
96
+ // Handle tool-input-start for write_file
97
+ if (event.type === 'tool-input-start' && event.toolName === 'write_file') {
98
+ toolInputBuffers.set(event.toolCallId, '')
99
+ }
100
+
101
+ // Handle tool-input-delta for write_file
102
+ if (event.type === 'tool-input-delta' && toolInputBuffers.has(event.toolCallId)) {
103
+ const currentBuffer = toolInputBuffers.get(event.toolCallId) || ''
104
+ const newBuffer = currentBuffer + (event.inputTextDelta || '')
105
+ toolInputBuffers.set(event.toolCallId, newBuffer)
106
+
107
+ // Try to parse partial JSON to extract content
108
+ try {
109
+ // Look for "content": " and extract what comes after
110
+ const contentMatch = newBuffer.match(/"content":\s*"((?:[^"\\]|\\.)*)/)
111
+ if (contentMatch) {
112
+ const escapedContent = contentMatch[1]
113
+ // Unescape the content
114
+ const content = escapedContent
115
+ .replace(/\\n/g, '\n')
116
+ .replace(/\\t/g, '\t')
117
+ .replace(/\\r/g, '\r')
118
+ .replace(/\\"/g, '"')
119
+ .replace(/\\\\/g, '\\')
120
+
121
+ const activeWrite = activeWriteFiles.get(event.toolCallId)
122
+
123
+ // Also try to extract path
124
+ const pathMatch = newBuffer.match(/"path":\s*"([^"]+)"/)
125
+ const path = pathMatch?.[1] || ''
126
+
127
+ if (!activeWrite && path) {
128
+ // First time seeing content with path - start streaming
129
+ activeWriteFiles.set(event.toolCallId, { path, lastContent: content })
130
+ callbacks.onWriteFileStart(event.toolCallId, path)
131
+ callbacks.onWriteFileDelta(event.toolCallId, path, content)
132
+ } else if (activeWrite && content.length > activeWrite.lastContent.length) {
133
+ // Content grew - send new content
134
+ activeWrite.lastContent = content
135
+ callbacks.onWriteFileDelta(event.toolCallId, activeWrite.path, content)
136
+ }
137
+ }
138
+ } catch {
139
+ // JSON not complete yet, continue buffering
140
+ }
141
+ }
142
+
143
+ // Handle tool-input-available (complete)
144
+ if (event.type === 'tool-input-available' && event.toolName === 'write_file') {
145
+ const activeWrite = activeWriteFiles.get(event.toolCallId)
146
+ if (activeWrite) {
147
+ callbacks.onWriteFileEnd(event.toolCallId)
148
+ activeWriteFiles.delete(event.toolCallId)
149
+ }
150
+ toolInputBuffers.delete(event.toolCallId)
151
+ }
152
+ } catch {
153
+ // Invalid JSON, skip
154
+ }
155
+ }
156
+ }
157
+ }
158
+ } catch (err) {
159
+ controller.error(err)
160
+ }
161
+ },
162
+ })
163
+
164
+ // Return new response with the processed stream
165
+ return new Response(processedStream, {
166
+ headers: response.headers,
167
+ status: response.status,
168
+ statusText: response.statusText,
169
+ })
170
+ }
171
+
172
+ return transport
173
+ }
174
+
175
+ interface Suggestion {
176
+ title: string
177
+ label: string
178
+ prompt: string
179
+ }
180
+
181
+ // Client-side cache for suggestions
182
+ const SUGGESTIONS_CACHE_KEY = 'brainfish-suggestions-cache'
183
+
184
+ function getSuggestionsFromCache(key: string): Suggestion[] | null {
185
+ try {
186
+ const cached = localStorage.getItem(SUGGESTIONS_CACHE_KEY)
187
+ if (cached) {
188
+ const data = JSON.parse(cached)
189
+ if (data[key] && Date.now() - data[key].timestamp < 1000 * 60 * 60) { // 1 hour TTL
190
+ return data[key].suggestions
191
+ }
192
+ }
193
+ } catch {
194
+ // Ignore cache errors
195
+ }
196
+ return null
197
+ }
198
+
199
+ function saveSuggestionsToCache(key: string, suggestions: Suggestion[]) {
200
+ try {
201
+ const cached = localStorage.getItem(SUGGESTIONS_CACHE_KEY)
202
+ const data = cached ? JSON.parse(cached) : {}
203
+ data[key] = { suggestions, timestamp: Date.now() }
204
+ localStorage.setItem(SUGGESTIONS_CACHE_KEY, JSON.stringify(data))
205
+ } catch {
206
+ // Ignore cache errors
207
+ }
208
+ }
209
+
210
+ // Changelog release type for agent search
211
+ interface ChangelogRelease {
212
+ version: string
213
+ date: string
214
+ title: string
215
+ slug: string
216
+ }
217
+
218
+ // Extended collection type with optional changelog
219
+ interface ExtendedCollection extends BrainfishCollection {
220
+ changelogReleases?: ChangelogRelease[]
221
+ }
222
+
223
+ interface AgentChatProps {
224
+ collection: ExtendedCollection
225
+ currentEndpoint: BrainfishRESTRequest | null
226
+ onNavigate: (endpointId: string) => void
227
+ onPrefill: (data: PrefillData) => void
228
+ apiSummary?: string | null
229
+ debugContext?: DebugContext | null
230
+ onDebugContextConsumed?: () => void
231
+ /** Explain context from playground response - triggers agent explanation */
232
+ explainContext?: DebugContext | null
233
+ /** Callback to clear explain context after processing */
234
+ onExplainContextConsumed?: () => void
235
+ onOpenGlobalAuth?: () => void
236
+ onNavigateToAuthTab?: () => void
237
+ onNavigateToParamsTab?: () => void
238
+ onNavigateToBodyTab?: () => void
239
+ onNavigateToHeadersTab?: () => void
240
+ /** Callback to navigate to a documentation section */
241
+ onNavigateToDocSection?: (sectionId: string) => void
242
+ /** Callback to navigate to a documentation page */
243
+ onNavigateToDocPage?: (slug: string) => void
244
+ /** Callback when message count changes */
245
+ onHasMessagesChange?: (hasMessages: boolean) => void
246
+ }
247
+
248
+ // Storage key for chat history
249
+ const CHAT_STORAGE_KEY = 'brainfish-agent-chat'
250
+
251
+ export function AgentChat({
252
+ collection,
253
+ currentEndpoint,
254
+ onNavigate,
255
+ onPrefill,
256
+ apiSummary,
257
+ debugContext,
258
+ onDebugContextConsumed,
259
+ explainContext,
260
+ onExplainContextConsumed,
261
+ onOpenGlobalAuth,
262
+ onNavigateToAuthTab,
263
+ onNavigateToParamsTab,
264
+ onNavigateToBodyTab,
265
+ onNavigateToHeadersTab,
266
+ onNavigateToDocSection,
267
+ onNavigateToDocPage,
268
+ onHasMessagesChange,
269
+ }: AgentChatProps) {
270
+ const messagesEndRef = useRef<HTMLDivElement>(null)
271
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
272
+ const processedDebugRef = useRef<string | null>(null)
273
+
274
+ // Local input state (controlled by us, not useChat)
275
+ const [inputValue, setInputValue] = useState('')
276
+
277
+ // Image upload state
278
+ const [selectedImages, setSelectedImages] = useState<Array<{ file: File; preview: string; base64: string }>>([])
279
+ const fileInputRef = useRef<HTMLInputElement>(null)
280
+
281
+ // Mode context for notes, API client, and docs
282
+ const { switchToNotes, switchToApiClient, switchToDocs, setActiveFilePath, setStreamingContent, triggerNotesRefresh } = useModeContext()
283
+
284
+ // Get workspace for notes
285
+ const apiSpecUrl = process.env.NEXT_PUBLIC_OPENAPI_URL || collection.name || 'default'
286
+ const { workspace } = useWorkspace(apiSpecUrl, collection.name)
287
+ const { notes, createNote, updateNote, deleteNote, deleteAllNotes, refresh: refreshNotes } = useNotes(workspace?.id || null)
288
+
289
+ // Suggestions state
290
+ const [suggestions, setSuggestions] = useState<Suggestion[]>([])
291
+ const [loadingSuggestions, setLoadingSuggestions] = useState(false)
292
+ const [showSuggestions, setShowSuggestions] = useState(true)
293
+
294
+ // Current request payload from playground
295
+ const { currentRequestPayload } = useCurrentRequestPayload()
296
+
297
+ // Send trigger - to invoke playground's send button
298
+ const { requestSend } = useSendTrigger()
299
+
300
+ // Build endpoint index for search
301
+ const endpointIndex = useMemo(() => buildEndpointIndex(collection), [collection])
302
+
303
+ // Handler to open a file from chat (when user clicks on file name in tool display)
304
+ const handleOpenFile = useCallback((path: string) => {
305
+ switchToNotes(path) // Pass path directly to avoid URL reset
306
+ }, [switchToNotes])
307
+
308
+ // Track last streamed content to compute deltas
309
+ const lastStreamedContent = useRef<Map<string, string>>(new Map())
310
+
311
+ // Build documentation index for search (including nested pages)
312
+ const docIndex = useMemo(() => {
313
+ if (!collection.docGroups) return []
314
+
315
+ const pages: Array<{
316
+ id: string
317
+ slug: string
318
+ title: string
319
+ description: string
320
+ group: string
321
+ groupId: string
322
+ }> = []
323
+
324
+ // Helper to recursively add pages
325
+ const addPages = (pageList: typeof collection.docGroups[0]['pages'], groupTitle: string, groupId: string) => {
326
+ for (const page of pageList) {
327
+ pages.push({
328
+ id: page.id,
329
+ slug: page.slug,
330
+ title: page.title,
331
+ description: page.description || '',
332
+ group: groupTitle,
333
+ groupId: groupId,
334
+ })
335
+ // Also add children if present
336
+ if (page.children && page.children.length > 0) {
337
+ addPages(page.children, page.title || groupTitle, groupId)
338
+ }
339
+ }
340
+ }
341
+
342
+ for (const group of collection.docGroups) {
343
+ addPages(group.pages, group.title, group.id)
344
+ }
345
+
346
+ return pages
347
+ }, [collection])
348
+
349
+ // Build changelog index for search
350
+ const changelogIndex = useMemo(() => {
351
+ if (!collection.changelogReleases) return []
352
+ return collection.changelogReleases.map(release => ({
353
+ version: release.version,
354
+ date: release.date,
355
+ title: release.title,
356
+ slug: release.slug,
357
+ }))
358
+ }, [collection])
359
+
360
+ // Create streaming transport with callbacks
361
+ const transport = useMemo(() => {
362
+ return createStreamingTransport(
363
+ {
364
+ api: '/api/chat',
365
+ body: {
366
+ endpointIndex,
367
+ docIndex,
368
+ changelogIndex,
369
+ currentEndpointId: currentEndpoint?.id,
370
+ apiSummary: apiSummary || undefined,
371
+ },
372
+ },
373
+ {
374
+ onWriteFileStart: (toolCallId, path) => {
375
+ lastStreamedContent.current.set(toolCallId, '')
376
+ setStreamingContent({ path, content: '', isStreaming: true })
377
+ // Navigate to the file being written
378
+ setActiveFilePath(path)
379
+ },
380
+ onWriteFileDelta: (toolCallId, path, fullContent) => {
381
+ // Update streaming content with full content (already includes all previous)
382
+ setStreamingContent({ path, content: fullContent, isStreaming: true })
383
+ },
384
+ onWriteFileEnd: (toolCallId) => {
385
+ lastStreamedContent.current.delete(toolCallId)
386
+ // Don't clear streaming content immediately - let the final save do it
387
+ },
388
+ }
389
+ )
390
+ }, [endpointIndex, docIndex, changelogIndex, currentEndpoint?.id, apiSummary, setStreamingContent, setActiveFilePath])
391
+
392
+ // Chat hook with custom streaming transport
393
+ const {
394
+ messages,
395
+ status,
396
+ sendMessage,
397
+ addToolOutput,
398
+ setMessages,
399
+ stop,
400
+ } = useChat({
401
+ id: 'agent-chat',
402
+ transport,
403
+
404
+ // Automatically submit when all tool calls are complete
405
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
406
+
407
+ // Handle client-side tool execution
408
+ async onToolCall({ toolCall }) {
409
+
410
+ if ('dynamic' in toolCall && toolCall.dynamic) {
411
+ return
412
+ }
413
+
414
+ const { toolName, toolCallId, input: toolInput } = toolCall as {
415
+ toolName: string
416
+ toolCallId: string
417
+ input: Record<string, unknown>
418
+ }
419
+
420
+
421
+ try {
422
+ // === UNIFIED SEARCH TOOL ===
423
+ // Searches across docs, endpoints, changelog, and future content types
424
+ if (toolName === 'search') {
425
+ const { query, type = 'all' } = toolInput as { query: string; type?: 'all' | 'docs' | 'endpoints' | 'changelog' }
426
+
427
+ const results: Array<{
428
+ type: 'doc' | 'endpoint' | 'changelog'
429
+ id: string
430
+ title: string
431
+ description?: string
432
+ method?: string
433
+ path?: string
434
+ group?: string
435
+ date?: string
436
+ score: number
437
+ }> = []
438
+
439
+ // Tokenize query
440
+ const queryTokens = query.toLowerCase().split(/\s+/).filter(t => t.length > 1)
441
+ const queryLower = query.toLowerCase()
442
+
443
+ // Search changelog if type is 'all' or 'changelog'
444
+ const changelogReleases = collection.changelogReleases || []
445
+ if ((type === 'all' || type === 'changelog') && changelogReleases.length > 0) {
446
+ // Check for keywords that indicate changelog intent
447
+ const changelogKeywords = ['release', 'changelog', 'version', 'update', 'latest', 'new', 'what\'s new', 'history']
448
+ const isChangelogQuery = changelogKeywords.some(kw => queryLower.includes(kw))
449
+
450
+ for (const release of changelogReleases) {
451
+ let score = 0
452
+ const versionLower = release.version.toLowerCase()
453
+ const titleLower = release.title.toLowerCase()
454
+ const dateLower = release.date.toLowerCase()
455
+
456
+ // Boost score for changelog-related queries
457
+ if (isChangelogQuery) score += 50
458
+
459
+ if (versionLower.includes(queryLower)) score += 100
460
+ if (titleLower.includes(queryLower)) score += 80
461
+ if (queryLower.includes('latest') && release === changelogReleases[0]) score += 150
462
+
463
+ for (const token of queryTokens) {
464
+ if (versionLower.includes(token)) score += 30
465
+ if (titleLower.includes(token)) score += 25
466
+ if (dateLower.includes(token)) score += 20
467
+ }
468
+
469
+ if (score > 0) {
470
+ // Extract just the version from the slug (e.g., "changelog/v1.2.0" -> "v1.2.0")
471
+ const versionId = release.slug.replace(/^changelog\//, '')
472
+ results.push({
473
+ type: 'changelog',
474
+ id: versionId,
475
+ title: `${release.version} - ${release.title}`,
476
+ description: `Released ${release.date}`,
477
+ date: release.date,
478
+ group: 'Changelog',
479
+ score,
480
+ })
481
+ }
482
+ }
483
+ }
484
+
485
+ // Search docs if type is 'all' or 'docs'
486
+ if ((type === 'all' || type === 'docs') && docIndex && docIndex.length > 0) {
487
+ for (const doc of docIndex) {
488
+ let score = 0
489
+ const titleLower = doc.title.toLowerCase()
490
+ const descLower = doc.description.toLowerCase()
491
+ const slugLower = doc.slug.toLowerCase()
492
+
493
+ if (titleLower.includes(queryLower)) score += 100
494
+ if (slugLower.includes(queryLower)) score += 80
495
+ if (descLower.includes(queryLower)) score += 60
496
+
497
+ for (const token of queryTokens) {
498
+ if (titleLower.includes(token)) score += 20
499
+ if (slugLower.includes(token)) score += 25
500
+ if (descLower.includes(token)) score += 15
501
+ }
502
+
503
+ if (score > 0) {
504
+ results.push({
505
+ type: 'doc',
506
+ id: doc.slug,
507
+ title: doc.title,
508
+ description: doc.description,
509
+ group: doc.group,
510
+ score,
511
+ })
512
+ }
513
+ }
514
+ }
515
+
516
+ // Search endpoints if type is 'all' or 'endpoints'
517
+ if ((type === 'all' || type === 'endpoints') && endpointIndex && endpointIndex.length > 0) {
518
+ for (const ep of endpointIndex) {
519
+ let score = 0
520
+ const nameLower = ep.name.toLowerCase()
521
+ const pathLower = ep.path.toLowerCase()
522
+ const descLower = (ep.description || '').toLowerCase()
523
+
524
+ if (nameLower.includes(queryLower)) score += 100
525
+ if (pathLower.includes(queryLower)) score += 80
526
+ if (descLower.includes(queryLower)) score += 60
527
+
528
+ for (const token of queryTokens) {
529
+ if (nameLower.includes(token)) score += 20
530
+ if (pathLower.includes(token)) score += 25
531
+ if (descLower.includes(token)) score += 15
532
+ if (ep.method.toLowerCase() === token) score += 30
533
+ }
534
+
535
+ if (score > 0) {
536
+ results.push({
537
+ type: 'endpoint',
538
+ id: ep.id,
539
+ title: ep.name,
540
+ description: ep.description || undefined,
541
+ method: ep.method,
542
+ path: ep.path,
543
+ score,
544
+ })
545
+ }
546
+ }
547
+ }
548
+
549
+ // Sort by score and limit
550
+ const sortedResults = results
551
+ .sort((a, b) => b.score - a.score)
552
+ .slice(0, 10)
553
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
554
+ .map(({ score, ...rest }) => rest)
555
+
556
+ addToolOutput({
557
+ tool: 'search',
558
+ toolCallId,
559
+ output: {
560
+ results: sortedResults,
561
+ message: sortedResults.length > 0
562
+ ? `Found ${sortedResults.length} result(s)`
563
+ : 'No results found. Try different keywords.',
564
+ },
565
+ })
566
+ }
567
+
568
+ // === UNIFIED NAVIGATE TOOL ===
569
+ // Navigates to docs, endpoints, changelog, sections, and future content types
570
+ if (toolName === 'navigate') {
571
+ const { type, id, title } = toolInput as { type: 'doc' | 'endpoint' | 'changelog' | 'section'; id: string; title?: string }
572
+
573
+ if (type === 'doc') {
574
+ switchToDocs()
575
+ if (onNavigateToDocPage) {
576
+ onNavigateToDocPage(id)
577
+ }
578
+ addToolOutput({
579
+ tool: 'navigate',
580
+ toolCallId,
581
+ output: { success: true, type: 'doc', id, title, message: `Opened ${title || id}` },
582
+ })
583
+ } else if (type === 'changelog') {
584
+ // Navigate to changelog tab and scroll to specific release
585
+ // handleSelectDocPage handles the scrolling automatically
586
+ switchToDocs()
587
+ if (onNavigateToDocPage) {
588
+ onNavigateToDocPage(`changelog/${id}`)
589
+ }
590
+ addToolOutput({
591
+ tool: 'navigate',
592
+ toolCallId,
593
+ output: { success: true, type: 'changelog', id, title, message: `Opened changelog ${title || id}` },
594
+ })
595
+ } else if (type === 'endpoint') {
596
+ switchToDocs()
597
+ onNavigate(id)
598
+ const endpoint = endpointIndex.find(e => e.id === id)
599
+ addToolOutput({
600
+ tool: 'navigate',
601
+ toolCallId,
602
+ output: {
603
+ success: true,
604
+ type: 'endpoint',
605
+ id,
606
+ title: endpoint?.name || title,
607
+ method: endpoint?.method,
608
+ message: `Opened ${endpoint?.name || title || id}`
609
+ },
610
+ })
611
+ } else if (type === 'section') {
612
+ // Scroll to section on current page with retry for newly rendered content
613
+ const scrollToSection = (): boolean => {
614
+ const headings = document.querySelectorAll('.docs-content h1[id], .docs-content h2[id], .docs-content h3[id], .docs-content h4[id], .docs-prose h1[id], .docs-prose h2[id], .docs-prose h3[id], .docs-prose h4[id], .docs-page h1[id], .docs-page h2[id], .docs-page h3[id], .docs-page h4[id]')
615
+ for (const heading of headings) {
616
+ const text = heading.textContent?.toLowerCase() || ''
617
+ const headingId = heading.getAttribute('id') || ''
618
+ if (text.includes(id.toLowerCase()) || headingId.includes(id.toLowerCase())) {
619
+ (heading as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' })
620
+ return true
621
+ }
622
+ }
623
+ return false
624
+ }
625
+
626
+ // Try immediately, then retry with delays if not found
627
+ let found = scrollToSection()
628
+ if (!found) {
629
+ // Retry after a short delay (page might still be rendering)
630
+ setTimeout(() => {
631
+ found = scrollToSection()
632
+ if (!found) {
633
+ // Final retry after longer delay
634
+ setTimeout(() => {
635
+ scrollToSection()
636
+ }, 300)
637
+ }
638
+ }, 100)
639
+ }
640
+
641
+ addToolOutput({
642
+ tool: 'navigate',
643
+ toolCallId,
644
+ output: {
645
+ success: true, // Optimistically report success as we're retrying
646
+ type: 'section',
647
+ id,
648
+ message: `Scrolled to ${id}`
649
+ },
650
+ })
651
+ }
652
+ }
653
+
654
+ // === LEGACY TOOL HANDLERS (for backwards compatibility) ===
655
+ // These can be removed once the unified tools are fully adopted
656
+
657
+ if (toolName === 'search_docs') {
658
+ // Redirect to unified search
659
+ const { query } = toolInput as { query: string }
660
+ const results = docIndex
661
+ .filter(doc =>
662
+ doc.title.toLowerCase().includes(query.toLowerCase()) ||
663
+ doc.slug.toLowerCase().includes(query.toLowerCase())
664
+ )
665
+ .slice(0, 10)
666
+ addToolOutput({
667
+ tool: 'search_docs',
668
+ toolCallId,
669
+ output: { results, message: `Found ${results.length} result(s)` },
670
+ })
671
+ }
672
+
673
+ if (toolName === 'search_endpoints') {
674
+ const { query, method } = toolInput as { query: string; method?: string }
675
+ const results = searchEndpoints(endpointIndex, query, method)
676
+ addToolOutput({
677
+ tool: 'search_endpoints',
678
+ toolCallId,
679
+ output: results,
680
+ })
681
+ }
682
+
683
+ if (toolName === 'navigate_to_doc_page') {
684
+ const { slug, title } = toolInput as { slug: string; title: string }
685
+ switchToDocs()
686
+ if (onNavigateToDocPage) {
687
+ onNavigateToDocPage(slug)
688
+ }
689
+ addToolOutput({
690
+ tool: 'navigate_to_doc_page',
691
+ toolCallId,
692
+ output: { success: true, slug, title, message: `Navigated to "${title}"` },
693
+ })
694
+ }
695
+
696
+ if (toolName === 'navigate_to_endpoint') {
697
+ const { endpointId } = toolInput as { endpointId: string; reason?: string }
698
+ switchToDocs()
699
+ onNavigate(endpointId)
700
+ const endpoint = endpointIndex.find(e => e.id === endpointId)
701
+ addToolOutput({
702
+ tool: 'navigate_to_endpoint',
703
+ toolCallId,
704
+ output: { success: true, endpoint },
705
+ })
706
+ }
707
+
708
+ if (toolName === 'navigate_to_doc_section') {
709
+ const { sectionId, sectionName } = toolInput as { sectionId: string; sectionName: string }
710
+ switchToDocs()
711
+ if (onNavigateToDocSection) {
712
+ onNavigateToDocSection(sectionId)
713
+ }
714
+
715
+ addToolOutput({
716
+ tool: 'navigate_to_doc_section',
717
+ toolCallId,
718
+ output: { success: true, sectionId, sectionName },
719
+ })
720
+ }
721
+
722
+ // Handle scroll_to_section tool - scroll within current page
723
+ if (toolName === 'scroll_to_section') {
724
+ const { query } = toolInput as { query: string }
725
+
726
+ // Find matching heading in the current page
727
+ const findAndScrollToSection = (): { found: boolean; heading?: string; id?: string } => {
728
+ const queryLower = query.toLowerCase()
729
+ const headings = document.querySelectorAll('.docs-content h1[id], .docs-content h2[id], .docs-content h3[id], .docs-content h4[id], .docs-content h5[id], .docs-content h6[id], .docs-prose h1[id], .docs-prose h2[id], .docs-prose h3[id], .docs-prose h4[id], .docs-prose h5[id], .docs-prose h6[id], .docs-page h1[id], .docs-page h2[id], .docs-page h3[id], .docs-page h4[id], .docs-page h5[id], .docs-page h6[id]')
730
+
731
+ let bestMatch: { element: HTMLElement; score: number; text: string; id: string } | null = null
732
+
733
+ for (const heading of headings) {
734
+ const text = heading.textContent?.toLowerCase() || ''
735
+ const id = heading.getAttribute('id') || ''
736
+ let score = 0
737
+
738
+ // Exact match
739
+ if (text === queryLower || id === queryLower) {
740
+ score = 100
741
+ }
742
+ // Starts with query
743
+ else if (text.startsWith(queryLower) || id.startsWith(queryLower)) {
744
+ score = 80
745
+ }
746
+ // Contains query
747
+ else if (text.includes(queryLower) || id.includes(queryLower)) {
748
+ score = 60
749
+ }
750
+ // Word match
751
+ else {
752
+ const queryWords = queryLower.split(/\s+/)
753
+ const textWords = text.split(/\s+/)
754
+ const matchingWords = queryWords.filter(qw =>
755
+ textWords.some(tw => tw.includes(qw) || qw.includes(tw))
756
+ )
757
+ if (matchingWords.length > 0) {
758
+ score = 30 + (matchingWords.length / queryWords.length) * 30
759
+ }
760
+ }
761
+
762
+ if (score > 0 && (!bestMatch || score > bestMatch.score)) {
763
+ bestMatch = {
764
+ element: heading as HTMLElement,
765
+ score,
766
+ text: heading.textContent || '',
767
+ id
768
+ }
769
+ }
770
+ }
771
+
772
+ if (bestMatch) {
773
+ // Scroll to the section
774
+ bestMatch.element.scrollIntoView({ behavior: 'smooth', block: 'start' })
775
+ return { found: true, heading: bestMatch.text, id: bestMatch.id }
776
+ }
777
+
778
+ return { found: false }
779
+ }
780
+
781
+ // Try immediately
782
+ let result = findAndScrollToSection()
783
+
784
+ // If not found, retry with delays (page might still be rendering)
785
+ if (!result.found) {
786
+ setTimeout(() => {
787
+ result = findAndScrollToSection()
788
+ if (!result.found) {
789
+ setTimeout(() => {
790
+ findAndScrollToSection()
791
+ }, 300)
792
+ }
793
+ }, 100)
794
+ }
795
+
796
+ addToolOutput({
797
+ tool: 'scroll_to_section',
798
+ toolCallId,
799
+ output: result.found
800
+ ? { success: true, heading: result.heading, id: result.id, message: `Scrolled to "${result.heading}"` }
801
+ : { success: true, heading: query, id: query.toLowerCase().replace(/\s+/g, '-'), message: `Scrolling to "${query}"` }, // Optimistically report success
802
+ })
803
+ }
804
+
805
+ if (toolName === 'prefill_parameters') {
806
+ const prefillData = toolInput as PrefillData
807
+
808
+ onPrefill(prefillData)
809
+
810
+ addToolOutput({
811
+ tool: 'prefill_parameters',
812
+ toolCallId,
813
+ output: { success: true, ...prefillData },
814
+ })
815
+ }
816
+
817
+ if (toolName === 'get_endpoint_details') {
818
+ const { endpointId } = toolInput as { endpointId: string }
819
+ const fullRequest = findRequestById(collection, endpointId)
820
+
821
+ if (fullRequest) {
822
+ const formattedDetails = formatRequestForAI(fullRequest)
823
+ addToolOutput({
824
+ tool: 'get_endpoint_details',
825
+ toolCallId,
826
+ output: formattedDetails,
827
+ })
828
+ } else {
829
+ addToolOutput({
830
+ tool: 'get_endpoint_details',
831
+ toolCallId,
832
+ output: { error: 'Endpoint not found' },
833
+ })
834
+ }
835
+ }
836
+
837
+ // Handle get_current_request tool - returns current playground request payload
838
+ if (toolName === 'get_current_request') {
839
+
840
+ if (currentRequestPayload) {
841
+ addToolOutput({
842
+ tool: 'get_current_request',
843
+ toolCallId,
844
+ output: {
845
+ success: true,
846
+ request: {
847
+ method: currentRequestPayload.method,
848
+ url: currentRequestPayload.endpoint,
849
+ queryParams: currentRequestPayload.params.filter(p => p.active),
850
+ headers: currentRequestPayload.headers.filter(h => h.active),
851
+ body: currentRequestPayload.body,
852
+ contentType: currentRequestPayload.bodyContentType,
853
+ },
854
+ },
855
+ })
856
+ } else {
857
+ addToolOutput({
858
+ tool: 'get_current_request',
859
+ toolCallId,
860
+ output: {
861
+ success: false,
862
+ error: 'No request payload available. Please select an endpoint first.'
863
+ },
864
+ })
865
+ }
866
+ }
867
+
868
+ // Handle switch_to_notes tool
869
+ if (toolName === 'switch_to_notes') {
870
+
871
+ switchToNotes()
872
+
873
+ addToolOutput({
874
+ tool: 'switch_to_notes',
875
+ toolCallId,
876
+ output: { success: true, message: 'Switched to Notes workspace' },
877
+ })
878
+ }
879
+
880
+ // Handle switch_to_docs tool
881
+ if (toolName === 'switch_to_docs') {
882
+
883
+ switchToDocs()
884
+
885
+ addToolOutput({
886
+ tool: 'switch_to_docs',
887
+ toolCallId,
888
+ output: { success: true, message: 'Switched to Docs' },
889
+ })
890
+ }
891
+
892
+ // Handle switch_to_api_client tool
893
+ if (toolName === 'switch_to_api_client') {
894
+
895
+ switchToApiClient()
896
+
897
+ addToolOutput({
898
+ tool: 'switch_to_api_client',
899
+ toolCallId,
900
+ output: { success: true, message: 'Switched to API Client' },
901
+ })
902
+ }
903
+
904
+ // Handle list_notes tool (lists existing files to check for duplicates)
905
+ if (toolName === 'list_notes') {
906
+ const { filter } = toolInput as { filter?: string }
907
+
908
+ // Switch to notes workspace
909
+ switchToNotes()
910
+
911
+
912
+ // Filter notes if a filter is provided
913
+ let filteredNotes = notes.filter(n => !n.path.endsWith('.folder')) // Exclude folder placeholders
914
+
915
+ if (filter) {
916
+ const lowerFilter = filter.toLowerCase()
917
+ filteredNotes = filteredNotes.filter(n =>
918
+ n.path.toLowerCase().includes(lowerFilter)
919
+ )
920
+ }
921
+
922
+ // Return list of file paths with basic info
923
+ const notesList = filteredNotes.map(n => ({
924
+ path: n.path,
925
+ updatedAt: n.updatedAt,
926
+ }))
927
+
928
+ addToolOutput({
929
+ tool: 'list_notes',
930
+ toolCallId,
931
+ output: {
932
+ success: true,
933
+ count: notesList.length,
934
+ files: notesList,
935
+ message: notesList.length > 0
936
+ ? `Found ${notesList.length} file(s)${filter ? ` matching "${filter}"` : ''}`
937
+ : `No files found${filter ? ` matching "${filter}"` : ' in workspace'}`
938
+ },
939
+ })
940
+ }
941
+
942
+ // Handle open_file tool (opens existing file)
943
+ if (toolName === 'open_file') {
944
+ const { path } = toolInput as { path: string }
945
+
946
+
947
+ // Switch to notes mode with the file path
948
+ switchToNotes(path)
949
+
950
+ addToolOutput({
951
+ tool: 'open_file',
952
+ toolCallId,
953
+ output: { success: true, path, message: `Opened ${path}` },
954
+ })
955
+ }
956
+
957
+ // Handle create_folder tool (creates a folder for organizing files)
958
+ if (toolName === 'create_folder') {
959
+ const { path, description } = toolInput as { path: string; description: string }
960
+
961
+ // Switch to notes workspace
962
+ switchToNotes()
963
+
964
+
965
+ // Folders are virtual in our system - files with paths like "folder/file.ext"
966
+ // automatically create the folder structure when files are added.
967
+ // We just acknowledge the intent - the folder will appear when files are created inside it.
968
+
969
+ addToolOutput({
970
+ tool: 'create_folder',
971
+ toolCallId,
972
+ output: { success: true, path, description, message: `Folder "${path}" will be created when files are added` },
973
+ })
974
+ }
975
+
976
+ // Handle create_file tool (creates empty file)
977
+ if (toolName === 'create_file') {
978
+ const { path, description } = toolInput as { path: string; description: string }
979
+
980
+ // Switch to notes workspace
981
+ switchToNotes()
982
+
983
+
984
+ if (workspace) {
985
+ try {
986
+ // Create truly empty file - content will be written by write_file
987
+ await createNote(path, '')
988
+ await refreshNotes()
989
+
990
+ // Trigger refresh in notes-mode UI
991
+ triggerNotesRefresh()
992
+
993
+ setActiveFilePath(path)
994
+
995
+
996
+ addToolOutput({
997
+ tool: 'create_file',
998
+ toolCallId,
999
+ output: { success: true, path, description },
1000
+ })
1001
+ } catch (err) {
1002
+ console.error('[AgentChat] Failed to create file:', err)
1003
+ addToolOutput({
1004
+ tool: 'create_file',
1005
+ toolCallId,
1006
+ output: { success: false, error: err instanceof Error ? err.message : 'Failed to create file' },
1007
+ })
1008
+ }
1009
+ } else {
1010
+ addToolOutput({
1011
+ tool: 'create_file',
1012
+ toolCallId,
1013
+ output: { success: false, error: 'Workspace not available' },
1014
+ })
1015
+ }
1016
+ }
1017
+
1018
+ // Handle write_file tool (writes content to file)
1019
+ // Note: Streaming already shows content in real-time via onWriteFileDelta
1020
+ // This handler saves the final content to IndexedDB for persistence
1021
+ if (toolName === 'write_file') {
1022
+ const { path, content } = toolInput as { path: string; content: string }
1023
+
1024
+ // Switch to notes workspace
1025
+ switchToNotes()
1026
+
1027
+
1028
+ if (workspace) {
1029
+ try {
1030
+ // Don't clear streaming content here - notes-mode will clear it after loading
1031
+ // This prevents flash of empty content for mermaid previews
1032
+
1033
+ // Save the final content to IndexedDB for persistence
1034
+ await updateNote(path, content)
1035
+ await refreshNotes()
1036
+
1037
+ // Trigger refresh in notes-mode UI
1038
+ triggerNotesRefresh()
1039
+
1040
+ // Force reload the file content from DB
1041
+ setActiveFilePath(`${path}?t=${Date.now()}`)
1042
+
1043
+
1044
+ addToolOutput({
1045
+ tool: 'write_file',
1046
+ toolCallId,
1047
+ output: { success: true, path },
1048
+ })
1049
+ } catch (err) {
1050
+ console.error('[AgentChat] Failed to save file:', err)
1051
+ setStreamingContent(null) // Clear on error too
1052
+ addToolOutput({
1053
+ tool: 'write_file',
1054
+ toolCallId,
1055
+ output: { success: false, error: err instanceof Error ? err.message : 'Failed to save file' },
1056
+ })
1057
+ }
1058
+ } else {
1059
+ setStreamingContent(null)
1060
+ addToolOutput({
1061
+ tool: 'write_file',
1062
+ toolCallId,
1063
+ output: { success: false, error: 'Workspace not available' },
1064
+ })
1065
+ }
1066
+ }
1067
+
1068
+ // Handle delete_file tool (deletes a file from workspace)
1069
+ if (toolName === 'delete_file') {
1070
+ const { path, reason } = toolInput as { path: string; reason?: string }
1071
+
1072
+ // Switch to notes workspace
1073
+ switchToNotes()
1074
+
1075
+
1076
+ if (workspace) {
1077
+ try {
1078
+ // Check if file exists
1079
+ const fileExists = notes.some(n => n.path === path)
1080
+
1081
+ if (!fileExists) {
1082
+ addToolOutput({
1083
+ tool: 'delete_file',
1084
+ toolCallId,
1085
+ output: { success: false, error: `File not found: ${path}` },
1086
+ })
1087
+ return
1088
+ }
1089
+
1090
+ // Delete the file
1091
+ await deleteNote(path)
1092
+ await refreshNotes()
1093
+
1094
+ // Trigger refresh in notes-mode UI
1095
+ triggerNotesRefresh()
1096
+
1097
+ // Clear active file if it was the deleted one
1098
+ setActiveFilePath(null)
1099
+
1100
+
1101
+ addToolOutput({
1102
+ tool: 'delete_file',
1103
+ toolCallId,
1104
+ output: { success: true, path, reason, message: `Deleted ${path}` },
1105
+ })
1106
+ } catch (err) {
1107
+ console.error('[AgentChat] Failed to delete file:', err)
1108
+ addToolOutput({
1109
+ tool: 'delete_file',
1110
+ toolCallId,
1111
+ output: { success: false, error: err instanceof Error ? err.message : 'Failed to delete file' },
1112
+ })
1113
+ }
1114
+ } else {
1115
+ addToolOutput({
1116
+ tool: 'delete_file',
1117
+ toolCallId,
1118
+ output: { success: false, error: 'Workspace not available' },
1119
+ })
1120
+ }
1121
+ }
1122
+
1123
+ // Handle delete_all_notes tool (deletes all files from workspace)
1124
+ if (toolName === 'delete_all_notes') {
1125
+ const { confirmed } = toolInput as { confirmed: boolean }
1126
+
1127
+ // Switch to notes workspace
1128
+ switchToNotes()
1129
+
1130
+
1131
+ if (!confirmed) {
1132
+ addToolOutput({
1133
+ tool: 'delete_all_notes',
1134
+ toolCallId,
1135
+ output: { success: false, error: 'Deletion not confirmed. Please confirm with the user first.' },
1136
+ })
1137
+ return
1138
+ }
1139
+
1140
+ if (workspace) {
1141
+ try {
1142
+ // Refresh notes first to get accurate count (notes state might be stale)
1143
+ await refreshNotes()
1144
+
1145
+ // Get fresh count from database directly
1146
+ const { dbOperations } = await import('@/lib/api-docs/code-editor/db')
1147
+ const currentNotes = await dbOperations.listNotes(workspace.id)
1148
+ const visibleNotes = currentNotes.filter(n => !n.path.endsWith('.folder'))
1149
+ const noteCount = visibleNotes.length
1150
+
1151
+ // Get list of files for display
1152
+ const fileList = visibleNotes.map(n => n.path)
1153
+
1154
+
1155
+ if (noteCount === 0) {
1156
+ addToolOutput({
1157
+ tool: 'delete_all_notes',
1158
+ toolCallId,
1159
+ output: { success: true, deletedCount: 0, files: [], message: 'No notes to delete' },
1160
+ })
1161
+ return
1162
+ }
1163
+
1164
+ // Delete all notes
1165
+ await deleteAllNotes()
1166
+ await refreshNotes()
1167
+
1168
+ // Trigger refresh in notes-mode UI
1169
+ triggerNotesRefresh()
1170
+
1171
+ // Clear active file
1172
+ setActiveFilePath(null)
1173
+
1174
+
1175
+ addToolOutput({
1176
+ tool: 'delete_all_notes',
1177
+ toolCallId,
1178
+ output: {
1179
+ success: true,
1180
+ deletedCount: noteCount,
1181
+ files: fileList,
1182
+ message: `Deleted ${noteCount} note${noteCount !== 1 ? 's' : ''}`
1183
+ },
1184
+ })
1185
+ } catch (err) {
1186
+ console.error('[AgentChat] Failed to delete all notes:', err)
1187
+ addToolOutput({
1188
+ tool: 'delete_all_notes',
1189
+ toolCallId,
1190
+ output: { success: false, error: err instanceof Error ? err.message : 'Failed to delete notes' },
1191
+ })
1192
+ }
1193
+ } else {
1194
+ addToolOutput({
1195
+ tool: 'delete_all_notes',
1196
+ toolCallId,
1197
+ output: { success: false, error: 'Workspace not available' },
1198
+ })
1199
+ }
1200
+ }
1201
+
1202
+ // Handle check_request_validity tool - validates if request is ready to send
1203
+ if (toolName === 'check_request_validity') {
1204
+ const { endpointId } = toolInput as { endpointId: string }
1205
+
1206
+ const endpoint = findRequestById(collection, endpointId)
1207
+
1208
+ if (!endpoint) {
1209
+ addToolOutput({
1210
+ tool: 'check_request_validity',
1211
+ toolCallId,
1212
+ output: { success: false, error: 'Endpoint not found' },
1213
+ })
1214
+ return
1215
+ }
1216
+
1217
+ // Get current request payload from context
1218
+ const payload = currentRequestPayload
1219
+
1220
+ // Analyze path parameters from the endpoint URL
1221
+ const pathParamMatches = endpoint.endpoint.match(/\{([^}]+)\}/g) || []
1222
+ const requiredPathParams = pathParamMatches.map(p => p.slice(1, -1))
1223
+
1224
+ // Check which path params have values
1225
+ const filledPathParams = requiredPathParams.filter(param => {
1226
+ // Check if the URL has been filled with actual values (not still containing {param})
1227
+ return payload?.endpoint && !payload.endpoint.includes(`{${param}}`)
1228
+ })
1229
+ const missingPathParams = requiredPathParams.filter(p => !filledPathParams.includes(p))
1230
+
1231
+ // Analyze query parameters
1232
+ const requiredQueryParams = endpoint.params
1233
+ .filter(p => p.description?.toLowerCase().includes('required'))
1234
+ .map(p => p.key)
1235
+ const filledQueryParams = (payload?.params || [])
1236
+ .filter(p => p.active && p.value)
1237
+ .map(p => p.key)
1238
+ const missingQueryParams = requiredQueryParams.filter(p => !filledQueryParams.includes(p))
1239
+
1240
+ // Analyze body (for POST/PUT/PATCH)
1241
+ const needsBody = ['POST', 'PUT', 'PATCH'].includes(endpoint.method)
1242
+ const hasBody = !!(payload?.body && typeof payload.body === 'string' && payload.body.trim().length > 0)
1243
+
1244
+ // Try to extract required fields from the body schema
1245
+ const bodyRequiredFields: string[] = []
1246
+ if (endpoint.body.body && typeof endpoint.body.body === 'string') {
1247
+ try {
1248
+ const bodySchema = JSON.parse(endpoint.body.body)
1249
+ if (bodySchema.required && Array.isArray(bodySchema.required)) {
1250
+ bodyRequiredFields.push(...bodySchema.required)
1251
+ }
1252
+ } catch {
1253
+ // Not a schema, might be example body
1254
+ }
1255
+ }
1256
+
1257
+ // Check missing body fields
1258
+ let missingBodyFields: string[] = []
1259
+ if (hasBody && bodyRequiredFields.length > 0) {
1260
+ try {
1261
+ const currentBody = JSON.parse(payload?.body || '{}')
1262
+ missingBodyFields = bodyRequiredFields.filter(field =>
1263
+ currentBody[field] === undefined || currentBody[field] === ''
1264
+ )
1265
+ } catch {
1266
+ // Invalid JSON in body
1267
+ }
1268
+ }
1269
+
1270
+ // Check authentication
1271
+ const needsAuth = endpoint.auth.authType !== 'none'
1272
+ const hasAuth = payload?.headers?.some(h =>
1273
+ h.active && (
1274
+ h.key.toLowerCase() === 'authorization' ||
1275
+ h.key.toLowerCase() === 'x-api-key'
1276
+ )
1277
+ ) || false
1278
+
1279
+ // Build validation result
1280
+ const isValid: boolean =
1281
+ missingPathParams.length === 0 &&
1282
+ missingQueryParams.length === 0 &&
1283
+ (!needsBody || hasBody) &&
1284
+ (!needsAuth || hasAuth)
1285
+
1286
+ // Build summary message
1287
+ const issues: string[] = []
1288
+ if (missingPathParams.length > 0) {
1289
+ issues.push(`Missing path params: ${missingPathParams.join(', ')}`)
1290
+ }
1291
+ if (missingQueryParams.length > 0) {
1292
+ issues.push(`Missing required params: ${missingQueryParams.join(', ')}`)
1293
+ }
1294
+ if (needsBody && !hasBody) {
1295
+ issues.push('Request body is empty')
1296
+ }
1297
+ if (missingBodyFields.length > 0) {
1298
+ issues.push(`Missing body fields: ${missingBodyFields.join(', ')}`)
1299
+ }
1300
+ if (needsAuth && !hasAuth) {
1301
+ issues.push('Authentication not configured')
1302
+ }
1303
+
1304
+ const summary = isValid
1305
+ ? 'Request is ready to send!'
1306
+ : `Issues found: ${issues.join('; ')}`
1307
+
1308
+ const result: RequestValidationResult = {
1309
+ isValid,
1310
+ endpoint: {
1311
+ id: endpoint.id || endpointId,
1312
+ name: endpoint.name,
1313
+ method: endpoint.method,
1314
+ path: endpoint.endpoint,
1315
+ },
1316
+ validation: {
1317
+ pathParams: {
1318
+ required: requiredPathParams,
1319
+ filled: filledPathParams,
1320
+ missing: missingPathParams,
1321
+ },
1322
+ queryParams: {
1323
+ required: requiredQueryParams,
1324
+ filled: filledQueryParams,
1325
+ missing: missingQueryParams,
1326
+ },
1327
+ body: {
1328
+ required: needsBody,
1329
+ hasContent: hasBody,
1330
+ requiredFields: bodyRequiredFields,
1331
+ missingFields: missingBodyFields,
1332
+ },
1333
+ auth: {
1334
+ required: needsAuth,
1335
+ configured: hasAuth,
1336
+ type: needsAuth ? endpoint.auth.authType : null,
1337
+ },
1338
+ },
1339
+ summary,
1340
+ }
1341
+
1342
+ addToolOutput({
1343
+ tool: 'check_request_validity',
1344
+ toolCallId,
1345
+ output: result,
1346
+ })
1347
+ }
1348
+
1349
+ // Handle send_request tool - triggers the playground's send button
1350
+ if (toolName === 'send_request') {
1351
+ const { confirmSend } = toolInput as { confirmSend: boolean }
1352
+
1353
+ if (!confirmSend) {
1354
+ addToolOutput({
1355
+ tool: 'send_request',
1356
+ toolCallId,
1357
+ output: { success: false, error: 'Request not confirmed' },
1358
+ })
1359
+ return
1360
+ }
1361
+
1362
+ if (!currentEndpoint) {
1363
+ addToolOutput({
1364
+ tool: 'send_request',
1365
+ toolCallId,
1366
+ output: { success: false, error: 'No endpoint selected' },
1367
+ })
1368
+ return
1369
+ }
1370
+
1371
+ // Trigger the playground's send button
1372
+ requestSend()
1373
+
1374
+ addToolOutput({
1375
+ tool: 'send_request',
1376
+ toolCallId,
1377
+ output: {
1378
+ success: true,
1379
+ message: 'Request sent! Check the response viewer in the playground.',
1380
+ },
1381
+ })
1382
+ }
1383
+ } catch (err) {
1384
+ addToolOutput({
1385
+ tool: toolName,
1386
+ toolCallId,
1387
+ state: 'output-error',
1388
+ errorText: err instanceof Error ? err.message : 'Tool execution failed',
1389
+ })
1390
+ }
1391
+ },
1392
+ })
1393
+
1394
+ // Derived loading state
1395
+ const isLoading = status === 'streaming' || status === 'submitted'
1396
+
1397
+ // Load saved messages on mount
1398
+ useEffect(() => {
1399
+ try {
1400
+ const saved = localStorage.getItem(CHAT_STORAGE_KEY)
1401
+ if (saved) {
1402
+ const parsed = JSON.parse(saved)
1403
+ if (Array.isArray(parsed) && parsed.length > 0) {
1404
+ setMessages(parsed)
1405
+ setShowSuggestions(false)
1406
+ }
1407
+ }
1408
+ } catch {
1409
+ // Ignore parse errors
1410
+ }
1411
+ }, [setMessages])
1412
+
1413
+ // Save messages when they change
1414
+ useEffect(() => {
1415
+ if (messages.length > 0) {
1416
+ try {
1417
+ localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(messages))
1418
+ } catch {
1419
+ // Ignore storage errors
1420
+ }
1421
+ }
1422
+ }, [messages])
1423
+
1424
+ // Notify parent about message count changes
1425
+ useEffect(() => {
1426
+ onHasMessagesChange?.(messages.length > 0)
1427
+ }, [messages.length, onHasMessagesChange])
1428
+
1429
+ // Auto-scroll to bottom
1430
+ useEffect(() => {
1431
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
1432
+ }, [messages])
1433
+
1434
+ // Cache key for suggestions
1435
+ const suggestionsCacheKey = currentEndpoint?.id
1436
+ ? `endpoint:${currentEndpoint.id}`
1437
+ : `general:${endpointIndex.length}`
1438
+
1439
+ // Load dynamic suggestions from API
1440
+ useEffect(() => {
1441
+ // Check cache first
1442
+ const cached = getSuggestionsFromCache(suggestionsCacheKey)
1443
+ if (cached) {
1444
+ setSuggestions(cached)
1445
+ return
1446
+ }
1447
+
1448
+ // Fetch from API
1449
+ let cancelled = false
1450
+ setLoadingSuggestions(true)
1451
+
1452
+ fetch('/api/suggestions', {
1453
+ method: 'POST',
1454
+ headers: { 'Content-Type': 'application/json' },
1455
+ body: JSON.stringify({
1456
+ endpointIndex,
1457
+ currentEndpointId: currentEndpoint?.id,
1458
+ }),
1459
+ })
1460
+ .then(res => res.json())
1461
+ .then(data => {
1462
+ if (cancelled) return
1463
+ if (data.suggestions) {
1464
+ setSuggestions(data.suggestions)
1465
+ saveSuggestionsToCache(suggestionsCacheKey, data.suggestions)
1466
+ }
1467
+ })
1468
+ .catch(err => {
1469
+ if (cancelled) return
1470
+ console.warn('[AgentChat] Failed to fetch suggestions:', err)
1471
+ // Fallback suggestions
1472
+ const fallback: Suggestion[] = currentEndpoint
1473
+ ? [
1474
+ { title: 'What does this', label: 'endpoint do?', prompt: `What does ${currentEndpoint.name} do?` },
1475
+ { title: 'What parameters', label: 'are required?', prompt: 'What parameters are required?' },
1476
+ { title: 'Python example', label: 'Show code', prompt: 'Show me a Python example' },
1477
+ ]
1478
+ : [
1479
+ { title: 'Find an endpoint', label: 'to create resources', prompt: 'Find endpoints for creating resources' },
1480
+ { title: 'How do I', label: 'authenticate?', prompt: 'How do I authenticate?' },
1481
+ { title: 'Overview', label: 'What can I do?', prompt: 'What can I do with this API?' },
1482
+ ]
1483
+ setSuggestions(fallback)
1484
+ })
1485
+ .finally(() => {
1486
+ if (!cancelled) setLoadingSuggestions(false)
1487
+ })
1488
+
1489
+ return () => { cancelled = true }
1490
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1491
+ }, [suggestionsCacheKey])
1492
+
1493
+ // Track pending debug error type for showing options after AI response
1494
+ const [pendingErrorType, setPendingErrorType] = useState<ReturnType<typeof detectErrorType> | null>(null)
1495
+ const prevStatus = useRef(status)
1496
+
1497
+ // Handle debug context from response viewer - show context card and send prompt
1498
+ useEffect(() => {
1499
+ if (!debugContext || status === 'streaming' || status === 'submitted') {
1500
+ if (!debugContext) processedDebugRef.current = null
1501
+ return
1502
+ }
1503
+
1504
+ const contextKey = JSON.stringify(debugContext)
1505
+ if (processedDebugRef.current === contextKey) return
1506
+ processedDebugRef.current = contextKey
1507
+
1508
+ // Detect and store error type for later (to show options after AI responds)
1509
+ const errorType = detectErrorType(debugContext.status, debugContext.responseBody)
1510
+ setPendingErrorType(errorType)
1511
+
1512
+ // Create a concise debug prompt
1513
+ const prompt = `Debug this ${debugContext.status} error and suggest a fix.`
1514
+
1515
+ // Encode the debug context as a card message
1516
+ const contextCardMessage = encodeCardMessage({
1517
+ type: 'debug_context',
1518
+ context: debugContext,
1519
+ })
1520
+
1521
+ // Add the debug context card as a user message
1522
+ const debugContextMessage = {
1523
+ id: `debug-${Date.now()}`,
1524
+ role: 'user' as const,
1525
+ parts: [{
1526
+ type: 'text' as const,
1527
+ text: contextCardMessage,
1528
+ }],
1529
+ }
1530
+
1531
+ // Add context card, then send the actual prompt
1532
+ setMessages(prev => [...prev, debugContextMessage])
1533
+ setShowSuggestions(false)
1534
+
1535
+ // Small delay to let the card render, then send prompt
1536
+ setTimeout(() => {
1537
+ sendMessage({
1538
+ role: 'user',
1539
+ parts: [{ type: 'text', text: prompt }],
1540
+ })
1541
+ }, 100)
1542
+
1543
+ onDebugContextConsumed?.()
1544
+ }, [debugContext, status, sendMessage, setMessages, onDebugContextConsumed])
1545
+
1546
+ // Track processed explain context to avoid duplicates
1547
+ const processedExplainRef = useRef<string | null>(null)
1548
+
1549
+ // Handle explain context from response viewer - send explain prompt
1550
+ useEffect(() => {
1551
+ if (!explainContext || status === 'streaming' || status === 'submitted') {
1552
+ if (!explainContext) processedExplainRef.current = null
1553
+ return
1554
+ }
1555
+
1556
+ const contextKey = JSON.stringify(explainContext)
1557
+ if (processedExplainRef.current === contextKey) return
1558
+ processedExplainRef.current = contextKey
1559
+
1560
+ // Create a concise explain prompt
1561
+ const prompt = `Explain this ${explainContext.status} response. What does it mean and what are the key fields?`
1562
+
1563
+ // Encode the context as a card message (reuse debug_context type)
1564
+ const contextCardMessage = encodeCardMessage({
1565
+ type: 'debug_context',
1566
+ context: {
1567
+ ...explainContext,
1568
+ errorMessage: undefined, // Clear error message since this is a success
1569
+ },
1570
+ })
1571
+
1572
+ // Add the context card as a user message
1573
+ const explainContextMessage = {
1574
+ id: `explain-${Date.now()}`,
1575
+ role: 'user' as const,
1576
+ parts: [{
1577
+ type: 'text' as const,
1578
+ text: contextCardMessage,
1579
+ }],
1580
+ }
1581
+
1582
+ // Add context card, then send the actual prompt
1583
+ setMessages(prev => [...prev, explainContextMessage])
1584
+ setShowSuggestions(false)
1585
+
1586
+ // Small delay to let the card render, then send prompt
1587
+ setTimeout(() => {
1588
+ sendMessage({
1589
+ role: 'user',
1590
+ parts: [{ type: 'text', text: prompt }],
1591
+ })
1592
+ }, 100)
1593
+
1594
+ onExplainContextConsumed?.()
1595
+ }, [explainContext, status, sendMessage, setMessages, onExplainContextConsumed])
1596
+
1597
+ // Add response options card after AI finishes responding to debug request
1598
+ useEffect(() => {
1599
+ // Check if status changed from streaming/submitted to ready
1600
+ const wasProcessing = prevStatus.current === 'streaming' || prevStatus.current === 'submitted'
1601
+ const isNowReady = status === 'ready'
1602
+
1603
+ if (wasProcessing && isNowReady && pendingErrorType) {
1604
+ // Encode the response options as a card message
1605
+ const optionsCardMessage = encodeCardMessage({
1606
+ type: 'response_options',
1607
+ errorType: pendingErrorType,
1608
+ })
1609
+
1610
+ // Add the response options card after AI response
1611
+ const responseOptionsMessage = {
1612
+ id: `options-${Date.now()}`,
1613
+ role: 'assistant' as const,
1614
+ parts: [{
1615
+ type: 'text' as const,
1616
+ text: optionsCardMessage,
1617
+ }],
1618
+ }
1619
+
1620
+ setMessages(prev => [...prev, responseOptionsMessage])
1621
+
1622
+ // Clear pending error type
1623
+ setPendingErrorType(null)
1624
+ }
1625
+
1626
+ // Update previous status
1627
+ prevStatus.current = status
1628
+ }, [status, pendingErrorType, setMessages])
1629
+
1630
+ // Handle image selection
1631
+ const handleImageSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
1632
+ const files = Array.from(e.target.files || [])
1633
+ if (files.length === 0) return
1634
+
1635
+ // Limit to 4 images total
1636
+ const availableSlots = 4 - selectedImages.length
1637
+ const filesToProcess = files.slice(0, availableSlots)
1638
+
1639
+ const newImages = await Promise.all(
1640
+ filesToProcess.map(async (file) => {
1641
+ // Create preview URL
1642
+ const preview = URL.createObjectURL(file)
1643
+
1644
+ // Convert to base64
1645
+ const base64 = await new Promise<string>((resolve) => {
1646
+ const reader = new FileReader()
1647
+ reader.onloadend = () => {
1648
+ const result = reader.result as string
1649
+ // Extract base64 data without the data URL prefix
1650
+ resolve(result)
1651
+ }
1652
+ reader.readAsDataURL(file)
1653
+ })
1654
+
1655
+ return { file, preview, base64 }
1656
+ })
1657
+ )
1658
+
1659
+ setSelectedImages(prev => [...prev, ...newImages])
1660
+
1661
+ // Reset file input
1662
+ if (fileInputRef.current) {
1663
+ fileInputRef.current.value = ''
1664
+ }
1665
+ }, [selectedImages.length])
1666
+
1667
+ // Remove selected image
1668
+ const handleRemoveImage = useCallback((index: number) => {
1669
+ setSelectedImages(prev => {
1670
+ const newImages = [...prev]
1671
+ // Revoke the preview URL to free memory
1672
+ URL.revokeObjectURL(newImages[index].preview)
1673
+ newImages.splice(index, 1)
1674
+ return newImages
1675
+ })
1676
+ }, [])
1677
+
1678
+ // Auto-resize textarea
1679
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
1680
+ setInputValue(e.target.value)
1681
+ const textarea = e.target
1682
+ textarea.style.height = 'auto'
1683
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 128)}px`
1684
+ }, [])
1685
+
1686
+ // Handle form submit
1687
+ const handleSubmit = useCallback((e: FormEvent) => {
1688
+ e.preventDefault()
1689
+ if ((!inputValue.trim() && selectedImages.length === 0) || isLoading) return
1690
+
1691
+ const trimmedInput = inputValue.trim()
1692
+ setInputValue('')
1693
+ if (textareaRef.current) {
1694
+ textareaRef.current.style.height = 'auto'
1695
+ }
1696
+
1697
+ // Build message parts with images and text
1698
+ const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string }> = []
1699
+
1700
+ // Add images first
1701
+ selectedImages.forEach(img => {
1702
+ parts.push({ type: 'image', image: img.base64 })
1703
+ })
1704
+
1705
+ // Add text if present
1706
+ if (trimmedInput) {
1707
+ parts.push({ type: 'text', text: trimmedInput })
1708
+ } else if (selectedImages.length > 0) {
1709
+ // If only images, add a default prompt
1710
+ parts.push({ type: 'text', text: 'What can you tell me about this image?' })
1711
+ }
1712
+
1713
+ // Clear selected images
1714
+ selectedImages.forEach(img => URL.revokeObjectURL(img.preview))
1715
+ setSelectedImages([])
1716
+
1717
+ setShowSuggestions(false)
1718
+ sendMessage({
1719
+ role: 'user',
1720
+ // Cast to allow image parts which our API handles correctly
1721
+ parts: parts as Array<{ type: 'text'; text: string }>,
1722
+ })
1723
+ }, [inputValue, isLoading, sendMessage, selectedImages])
1724
+
1725
+ // Handle suggestion click
1726
+ const handleSuggestionClick = useCallback((prompt: string) => {
1727
+ setShowSuggestions(false)
1728
+ sendMessage({
1729
+ role: 'user',
1730
+ parts: [{ type: 'text', text: prompt }],
1731
+ })
1732
+ }, [sendMessage])
1733
+
1734
+ // Handle delete single message
1735
+ const handleDeleteMessage = useCallback((messageId: string) => {
1736
+ setMessages(prevMessages => {
1737
+ const newMessages = prevMessages.filter(msg => msg.id !== messageId)
1738
+ // Update localStorage with the new messages
1739
+ if (newMessages.length === 0) {
1740
+ localStorage.removeItem(CHAT_STORAGE_KEY)
1741
+ setShowSuggestions(true)
1742
+ } else {
1743
+ localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(newMessages))
1744
+ }
1745
+ return newMessages
1746
+ })
1747
+ }, [setMessages])
1748
+
1749
+ // Handle keyboard shortcuts
1750
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1751
+ if (e.key === 'Enter' && !e.shiftKey) {
1752
+ e.preventDefault()
1753
+ handleSubmit(e as unknown as FormEvent)
1754
+ }
1755
+ }, [handleSubmit])
1756
+
1757
+ // Card actions for error handling
1758
+ const cardActions = useMemo(() => ({
1759
+ onOpenGlobalAuth,
1760
+ onNavigateToAuthTab,
1761
+ onNavigateToParamsTab,
1762
+ onNavigateToBodyTab,
1763
+ onNavigateToHeadersTab,
1764
+ }), [onOpenGlobalAuth, onNavigateToAuthTab, onNavigateToParamsTab, onNavigateToBodyTab, onNavigateToHeadersTab])
1765
+
1766
+ // Convert AI SDK messages to our format for rendering
1767
+ const renderableMessages = messages.map(msg => ({
1768
+ id: msg.id,
1769
+ role: msg.role as 'user' | 'assistant' | 'system',
1770
+ content: getMessageContent(msg),
1771
+ images: getMessageImages(msg),
1772
+ toolInvocations: getToolInvocations(msg),
1773
+ }))
1774
+
1775
+ return (
1776
+ <div className="flex flex-col h-full">
1777
+ {/* Messages */}
1778
+ <div className="flex-1 overflow-y-auto flex flex-col">
1779
+ <div className={cn(
1780
+ "max-w-2xl mx-auto px-4 py-4 w-full",
1781
+ messages.length === 0 && showSuggestions ? "flex-1 flex flex-col" : ""
1782
+ )}>
1783
+ {messages.length === 0 && showSuggestions && (
1784
+ <div className="flex w-full grow flex-col">
1785
+ {/* Center - Title and subtitle */}
1786
+ <div className="flex w-full grow flex-col items-center justify-center">
1787
+ <div className="flex flex-col items-center text-center px-4">
1788
+ <h1 className="font-semibold text-2xl animate-in fade-in slide-in-from-bottom-1 duration-200">
1789
+ {currentEndpoint ? currentEndpoint.name : 'Hello there!'}
1790
+ </h1>
1791
+ <p className="text-muted-foreground text-lg animate-in fade-in slide-in-from-bottom-1 duration-200 delay-75">
1792
+ {currentEndpoint
1793
+ ? 'Ask me anything about this endpoint'
1794
+ : 'How can I help you today?'
1795
+ }
1796
+ </p>
1797
+ </div>
1798
+ </div>
1799
+
1800
+ {/* Suggestions - at the bottom */}
1801
+ <div className="grid w-full grid-cols-1 gap-2 pb-4 mt-8">
1802
+ {loadingSuggestions ? (
1803
+ // Loading skeletons
1804
+ <>
1805
+ {[1, 2, 3, 4].map((i) => (
1806
+ <div
1807
+ key={i}
1808
+ className="h-[52px] w-full rounded-2xl border bg-muted/30 animate-pulse"
1809
+ style={{ animationDelay: `${i * 100}ms` }}
1810
+ />
1811
+ ))}
1812
+ </>
1813
+ ) : (
1814
+ suggestions.map((suggestion, index) => (
1815
+ <div
1816
+ key={suggestion.prompt}
1817
+ className="animate-in fade-in slide-in-from-bottom-2 duration-200 fill-mode-both"
1818
+ style={{ animationDelay: `${100 + index * 50}ms` }}
1819
+ >
1820
+ <button
1821
+ onClick={() => handleSuggestionClick(suggestion.prompt)}
1822
+ className="h-auto w-full flex flex-wrap items-start justify-start gap-1 rounded-2xl border px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
1823
+ >
1824
+ <span className="font-medium">{suggestion.title}</span>
1825
+ <span className="text-muted-foreground">{suggestion.label}</span>
1826
+ </button>
1827
+ </div>
1828
+ ))
1829
+ )}
1830
+ </div>
1831
+ </div>
1832
+ )}
1833
+
1834
+ {renderableMessages.map((message, i) => (
1835
+ <ChatMessage
1836
+ key={message.id}
1837
+ role={message.role}
1838
+ content={message.content}
1839
+ messageId={message.id}
1840
+ images={message.images}
1841
+ toolInvocations={message.toolInvocations}
1842
+ isStreaming={isLoading && i === renderableMessages.length - 1}
1843
+ onDeleteMessage={handleDeleteMessage}
1844
+ onNavigate={onNavigate}
1845
+ onNavigateToDocPage={onNavigateToDocPage}
1846
+ onOpenFile={handleOpenFile}
1847
+ cardActions={cardActions}
1848
+ />
1849
+ ))}
1850
+
1851
+ {/* Show typing indicator when waiting for stream to start */}
1852
+ {status === 'submitted' && <TypingIndicator />}
1853
+
1854
+ <div ref={messagesEndRef} />
1855
+ </div>
1856
+ </div>
1857
+
1858
+ {/* Input */}
1859
+ <div className="border-t border-border p-4">
1860
+ <form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
1861
+ {/* Hidden file input */}
1862
+ <input
1863
+ ref={fileInputRef}
1864
+ type="file"
1865
+ accept="image/*"
1866
+ multiple
1867
+ onChange={handleImageSelect}
1868
+ className="hidden"
1869
+ aria-hidden="true"
1870
+ />
1871
+
1872
+ <div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring">
1873
+ {/* Image previews */}
1874
+ {selectedImages.length > 0 && (
1875
+ <div className="flex flex-wrap gap-2 p-3 pb-0">
1876
+ {selectedImages.map((img, index) => (
1877
+ <div key={index} className="relative group">
1878
+ <img
1879
+ src={img.preview}
1880
+ alt={`Selected image ${index + 1}`}
1881
+ className="h-16 w-16 object-cover rounded-lg border border-border"
1882
+ />
1883
+ <Button
1884
+ type="button"
1885
+ size="icon"
1886
+ variant="secondary"
1887
+ onClick={() => handleRemoveImage(index)}
1888
+ className="absolute -top-1.5 -right-1.5 h-4 w-4 min-w-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-sm p-2"
1889
+ aria-label={`Remove image ${index + 1}`}
1890
+ >
1891
+ <X className="h-2.5 w-2.5 p-1" weight="bold" />
1892
+ </Button>
1893
+ </div>
1894
+ ))}
1895
+ </div>
1896
+ )}
1897
+
1898
+ <textarea
1899
+ ref={textareaRef}
1900
+ value={inputValue}
1901
+ onChange={handleInputChange}
1902
+ onKeyDown={handleKeyDown}
1903
+ placeholder="Send a message..."
1904
+ className={cn(
1905
+ "w-full resize-none bg-transparent px-4 pt-3 pb-12 text-sm",
1906
+ "placeholder:text-muted-foreground/70 outline-none",
1907
+ "min-h-[56px] max-h-32"
1908
+ )}
1909
+ rows={1}
1910
+ disabled={isLoading}
1911
+ aria-label="Message input"
1912
+ />
1913
+
1914
+ <div className="absolute bottom-2 left-2 right-2 flex items-center justify-between">
1915
+ {/* Image upload button */}
1916
+ <div>
1917
+ <Tooltip>
1918
+ <TooltipTrigger asChild>
1919
+ <Button
1920
+ type="button"
1921
+ size="icon"
1922
+ variant="ghost"
1923
+ className={cn(
1924
+ "h-8 w-8 rounded-full text-muted-foreground hover:text-foreground",
1925
+ selectedImages.length >= 4 && "opacity-50 cursor-not-allowed"
1926
+ )}
1927
+ onClick={() => fileInputRef.current?.click()}
1928
+ disabled={isLoading || selectedImages.length >= 4}
1929
+ aria-label="Upload image"
1930
+ >
1931
+ <ImageIcon className="h-4 w-4" weight="bold" />
1932
+ </Button>
1933
+ </TooltipTrigger>
1934
+ <TooltipContent side="top">
1935
+ {selectedImages.length >= 4 ? 'Maximum 4 images' : 'Upload image'}
1936
+ </TooltipContent>
1937
+ </Tooltip>
1938
+ </div>
1939
+
1940
+ {isLoading ? (
1941
+ <Tooltip>
1942
+ <TooltipTrigger asChild>
1943
+ <Button
1944
+ type="button"
1945
+ onClick={() => stop()}
1946
+ size="icon"
1947
+ variant="default"
1948
+ className="h-8 w-8 rounded-full"
1949
+ aria-label="Stop generating"
1950
+ >
1951
+ <Square className="h-3 w-3" weight="fill" />
1952
+ </Button>
1953
+ </TooltipTrigger>
1954
+ <TooltipContent side="top">Stop generating</TooltipContent>
1955
+ </Tooltip>
1956
+ ) : (
1957
+ <Tooltip>
1958
+ <TooltipTrigger asChild>
1959
+ <Button
1960
+ type="submit"
1961
+ disabled={!inputValue.trim()}
1962
+ size="icon"
1963
+ variant="default"
1964
+ className="h-8 w-8 rounded-full"
1965
+ aria-label="Send message"
1966
+ >
1967
+ <ArrowUp className="h-4 w-4" weight="bold" />
1968
+ </Button>
1969
+ </TooltipTrigger>
1970
+ <TooltipContent side="top">Send message</TooltipContent>
1971
+ </Tooltip>
1972
+ )}
1973
+ </div>
1974
+ </div>
1975
+ </form>
1976
+ </div>
1977
+ </div>
1978
+ )
1979
+ }
1980
+
1981
+ // Helper to extract text content from a message
1982
+ function getMessageContent(msg: { parts?: Array<{ type: string; text?: string }> }): string {
1983
+ if (!msg.parts) return ''
1984
+
1985
+ const textParts = msg.parts.filter(
1986
+ (part): part is { type: 'text'; text: string } =>
1987
+ part.type === 'text' && typeof part.text === 'string'
1988
+ )
1989
+
1990
+ let content = textParts.map(p => p.text).join('')
1991
+
1992
+ // Remove any trailing JSON blocks that the model might have included
1993
+ content = content.replace(/\n*```json[\s\S]*?```\n*/g, '')
1994
+ content = content.replace(/\n*\{[\s\S]*?"id":\s*"[^"]+",[\s\S]*?"name":\s*"[^"]+"[\s\S]*?\}\s*$/g, '')
1995
+
1996
+ return content.trim()
1997
+ }
1998
+
1999
+ // Helper to extract images from a message
2000
+ function getMessageImages(msg: { parts?: Array<{ type: string; image?: string }> }): Array<{ image: string }> {
2001
+ if (!msg.parts) return []
2002
+
2003
+ return msg.parts
2004
+ .filter((part): part is { type: 'image'; image: string } =>
2005
+ part.type === 'image' && typeof part.image === 'string'
2006
+ )
2007
+ .map(part => ({ image: part.image }))
2008
+ }
2009
+
2010
+ // Helper to extract tool invocations from a message
2011
+ function getToolInvocations(msg: { parts?: Array<Record<string, unknown>> }): Array<{
2012
+ toolCallId: string
2013
+ toolName: string
2014
+ args: Record<string, unknown>
2015
+ result?: unknown
2016
+ state: 'pending' | 'result' | 'error'
2017
+ }> {
2018
+ if (!msg.parts) return []
2019
+
2020
+ // Use a Map to deduplicate by toolCallId (keep latest version)
2021
+ const toolCallMap = new Map<string, {
2022
+ toolCallId: string
2023
+ toolName: string
2024
+ args: Record<string, unknown>
2025
+ result?: unknown
2026
+ state: 'pending' | 'result' | 'error'
2027
+ }>()
2028
+
2029
+ msg.parts
2030
+ .filter((part): part is {
2031
+ type: string
2032
+ toolCallId: string
2033
+ toolName?: string
2034
+ input?: Record<string, unknown>
2035
+ args?: Record<string, unknown>
2036
+ output?: unknown
2037
+ result?: unknown
2038
+ state?: string
2039
+ } => {
2040
+ return typeof part === 'object' &&
2041
+ part !== null &&
2042
+ 'type' in part &&
2043
+ typeof part.type === 'string' &&
2044
+ part.type.startsWith('tool-') &&
2045
+ part.type !== 'tool-call' &&
2046
+ 'toolCallId' in part
2047
+ })
2048
+ .forEach(part => {
2049
+ const toolName = part.toolName || part.type.replace('tool-', '')
2050
+
2051
+ // Only update if we don't have this tool call yet, or if the new one has more info
2052
+ const existing = toolCallMap.get(part.toolCallId)
2053
+ const newState = getToolState(part)
2054
+
2055
+ // Prefer 'result' state over 'pending'
2056
+ if (!existing || newState === 'result' || (newState === 'error' && existing.state === 'pending')) {
2057
+ toolCallMap.set(part.toolCallId, {
2058
+ toolCallId: part.toolCallId,
2059
+ toolName,
2060
+ args: (part.input as Record<string, unknown>) || (part.args as Record<string, unknown>) || {},
2061
+ result: part.output ?? part.result,
2062
+ state: newState,
2063
+ })
2064
+ }
2065
+ })
2066
+
2067
+ return Array.from(toolCallMap.values())
2068
+ }
2069
+
2070
+ // Helper to determine tool state from part
2071
+ function getToolState(part: { state?: string }): 'pending' | 'result' | 'error' {
2072
+ if (part.state === 'output-error') return 'error'
2073
+ if (part.state === 'output-available') return 'result'
2074
+ if (part.state === 'input-streaming' || part.state === 'input-available') return 'pending'
2075
+ return 'result'
2076
+ }