@brainfish-ai/devdoc 0.1.43 → 0.1.44

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 (35) hide show
  1. package/dist/cli/commands/create.js +2 -2
  2. package/package.json +1 -1
  3. package/renderer/app/api/collections/route.js +35 -4
  4. package/renderer/app/api/suggestions/route.js +33 -13
  5. package/renderer/app/globals.css +69 -0
  6. package/renderer/app/layout.js +2 -2
  7. package/renderer/app/llms-full.txt/route.js +10 -1
  8. package/renderer/app/llms.txt/route.js +10 -1
  9. package/renderer/app/sitemap.xml/route.js +11 -1
  10. package/renderer/components/docs/mdx/cards.js +1 -1
  11. package/renderer/components/docs/mdx/landing.js +7 -5
  12. package/renderer/components/docs-viewer/agent/agent-chat.js +13 -112
  13. package/renderer/components/docs-viewer/agent/agent-popup-button.js +99 -0
  14. package/renderer/components/docs-viewer/agent/index.js +3 -0
  15. package/renderer/components/docs-viewer/content/content-router.js +182 -0
  16. package/renderer/components/docs-viewer/content/doc-page.js +31 -5
  17. package/renderer/components/docs-viewer/content/index.js +2 -0
  18. package/renderer/components/docs-viewer/index.js +381 -485
  19. package/renderer/components/docs-viewer/playground/graphql-playground.js +205 -3
  20. package/renderer/components/docs-viewer/sidebar/right-sidebar.js +35 -39
  21. package/renderer/components/theme-toggle.js +1 -21
  22. package/renderer/hooks/use-route-state.js +159 -0
  23. package/renderer/lib/api-docs/agent/use-suggestions.js +97 -0
  24. package/renderer/lib/api-docs/code-editor/mode-context.js +61 -89
  25. package/renderer/lib/api-docs/mobile-context.js +40 -3
  26. package/renderer/lib/docs/config/environment.js +38 -0
  27. package/renderer/lib/docs/config/index.js +1 -0
  28. package/renderer/lib/docs/config/schema.js +17 -5
  29. package/renderer/lib/docs/mdx/compiler.js +5 -2
  30. package/renderer/lib/docs/mdx/index.js +2 -0
  31. package/renderer/lib/docs/mdx/remark-mermaid.js +63 -0
  32. package/renderer/lib/docs/navigation/index.js +1 -2
  33. package/renderer/lib/docs/navigation/types.js +3 -1
  34. package/renderer/lib/docs-navigation.js +140 -0
  35. package/renderer/package.json +1 -0
@@ -6,12 +6,182 @@ import { Play, ArrowClockwise, Copy, Check, Spinner, X, FileText, BracketsCurly,
6
6
  import { Button } from '@/components/ui/button';
7
7
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
8
8
  import Editor, { loader } from '@monaco-editor/react';
9
- // Configure Monaco to use CDN
9
+ import { buildSchema } from 'graphql';
10
+ import { getAutocompleteSuggestions, CompletionItemKind } from 'graphql-language-service';
11
+ // Configure Monaco to load from CDN
10
12
  loader.config({
11
13
  paths: {
12
14
  vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'
13
15
  }
14
16
  });
17
+ // Store parsed schema and completion provider disposable
18
+ let parsedSchema = null;
19
+ let completionProviderDisposable = null;
20
+ // Map graphql-language-service completion kinds to Monaco kinds
21
+ function toMonacoCompletionKind(kind, monaco) {
22
+ const mKinds = monaco.languages.CompletionItemKind;
23
+ switch(kind){
24
+ case CompletionItemKind.Field:
25
+ return mKinds.Field;
26
+ case CompletionItemKind.Variable:
27
+ return mKinds.Variable;
28
+ case CompletionItemKind.Property:
29
+ return mKinds.Property;
30
+ case CompletionItemKind.EnumMember:
31
+ return mKinds.EnumMember;
32
+ case CompletionItemKind.Class:
33
+ return mKinds.Class;
34
+ case CompletionItemKind.Interface:
35
+ return mKinds.Interface;
36
+ case CompletionItemKind.Function:
37
+ return mKinds.Function;
38
+ case CompletionItemKind.Snippet:
39
+ return mKinds.Snippet;
40
+ case CompletionItemKind.Keyword:
41
+ return mKinds.Keyword;
42
+ case CompletionItemKind.Constant:
43
+ return mKinds.Constant;
44
+ case CompletionItemKind.Enum:
45
+ return mKinds.Enum;
46
+ default:
47
+ return mKinds.Text;
48
+ }
49
+ }
50
+ // Find fields already selected in the current selection set
51
+ function getExistingFieldsInSelectionSet(documentText, cursorOffset) {
52
+ const existingFields = new Set();
53
+ // Find the opening brace of the current selection set
54
+ let braceCount = 0;
55
+ let selectionSetStart = -1;
56
+ for(let i = cursorOffset - 1; i >= 0; i--){
57
+ const char = documentText[i];
58
+ if (char === '}') braceCount++;
59
+ if (char === '{') {
60
+ if (braceCount === 0) {
61
+ selectionSetStart = i;
62
+ break;
63
+ }
64
+ braceCount--;
65
+ }
66
+ }
67
+ if (selectionSetStart === -1) return existingFields;
68
+ // Find the closing brace
69
+ braceCount = 1;
70
+ let selectionSetEnd = documentText.length;
71
+ for(let i = selectionSetStart + 1; i < documentText.length; i++){
72
+ const char = documentText[i];
73
+ if (char === '{') braceCount++;
74
+ if (char === '}') {
75
+ braceCount--;
76
+ if (braceCount === 0) {
77
+ selectionSetEnd = i;
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ // Extract the selection set content
83
+ const selectionSetContent = documentText.slice(selectionSetStart + 1, selectionSetEnd);
84
+ // Parse field names - match field names that appear on their own or with arguments/aliases
85
+ // Matches: fieldName, fieldName(args), alias: fieldName, ...fieldName
86
+ const lines = selectionSetContent.split('\n');
87
+ for (const line of lines){
88
+ const trimmed = line.trim();
89
+ if (!trimmed || trimmed.startsWith('#')) continue; // Skip empty lines and comments
90
+ // Match field name at start of line (possibly with alias)
91
+ // Pattern: optionalAlias: fieldName or just fieldName
92
+ const fieldMatch = trimmed.match(/^(?:\.\.\.)?(?:[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*)?([a-zA-Z_][a-zA-Z0-9_]*)/);
93
+ if (fieldMatch) {
94
+ const fieldName = fieldMatch[1];
95
+ // Skip GraphQL keywords but include __typename
96
+ if (![
97
+ 'query',
98
+ 'mutation',
99
+ 'subscription',
100
+ 'fragment',
101
+ 'on'
102
+ ].includes(fieldName)) {
103
+ existingFields.add(fieldName);
104
+ }
105
+ }
106
+ }
107
+ return existingFields;
108
+ }
109
+ // Register GraphQL completion provider using graphql-language-service
110
+ function registerGraphQLCompletionProvider(monaco, schema) {
111
+ // Dispose previous provider if exists
112
+ if (completionProviderDisposable) {
113
+ completionProviderDisposable.dispose();
114
+ }
115
+ completionProviderDisposable = monaco.languages.registerCompletionItemProvider('graphql', {
116
+ triggerCharacters: [
117
+ '{',
118
+ '(',
119
+ ' ',
120
+ ':',
121
+ '$',
122
+ '@',
123
+ '.',
124
+ '\n'
125
+ ],
126
+ provideCompletionItems: (model, position)=>{
127
+ const documentText = model.getValue();
128
+ const cursorOffset = model.getOffsetAt(position);
129
+ // graphql-language-service Position interface
130
+ const gqlPosition = {
131
+ line: position.lineNumber - 1,
132
+ character: position.column - 1,
133
+ setLine: (line)=>{
134
+ gqlPosition.line = line;
135
+ },
136
+ setCharacter: (char)=>{
137
+ gqlPosition.character = char;
138
+ },
139
+ lessThanOrEqualTo: (other)=>{
140
+ return gqlPosition.line < other.line || gqlPosition.line === other.line && gqlPosition.character <= other.character;
141
+ }
142
+ };
143
+ try {
144
+ // Get completions from graphql-language-service
145
+ const completions = getAutocompleteSuggestions(schema, documentText, gqlPosition);
146
+ // Get existing fields in current selection set to filter them out
147
+ const existingFields = getExistingFieldsInSelectionSet(documentText, cursorOffset);
148
+ // Filter out already-selected fields (only for Field kind suggestions)
149
+ const filteredCompletions = completions.filter((item)=>{
150
+ // Only filter out Field suggestions (kind = 5), keep keywords, types, etc.
151
+ if (item.kind === CompletionItemKind.Field) {
152
+ return !existingFields.has(item.label);
153
+ }
154
+ return true;
155
+ });
156
+ const word = model.getWordUntilPosition(position);
157
+ const range = {
158
+ startLineNumber: position.lineNumber,
159
+ endLineNumber: position.lineNumber,
160
+ startColumn: word.startColumn,
161
+ endColumn: word.endColumn
162
+ };
163
+ const suggestions = filteredCompletions.map((item, index)=>({
164
+ label: item.label,
165
+ kind: toMonacoCompletionKind(item.kind, monaco),
166
+ insertText: item.insertText || item.label,
167
+ insertTextRules: item.insertText?.includes('$') ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined,
168
+ detail: item.detail || undefined,
169
+ documentation: item.documentation || undefined,
170
+ sortText: String(index).padStart(5, '0'),
171
+ range
172
+ }));
173
+ return {
174
+ suggestions
175
+ };
176
+ } catch (err) {
177
+ console.error('[GraphQL Playground] Completion error:', err);
178
+ return {
179
+ suggestions: []
180
+ };
181
+ }
182
+ }
183
+ });
184
+ }
15
185
  const STORAGE_KEY = 'brainfish-graphql-playground';
16
186
  // ============================================================================
17
187
  // Helpers
@@ -184,7 +354,7 @@ export function GraphQLPlayground({ endpoint, defaultQuery = `# Welcome to the G
184
354
  query {
185
355
  __typename
186
356
  }
187
- `, headers: defaultHeaders = {}, operations = [], selectedOperationId: externalSelectedId, hideExplorer = false, className, theme = 'dark' }) {
357
+ `, headers: defaultHeaders = {}, operations = [], selectedOperationId: externalSelectedId, hideExplorer = false, className, theme = 'dark', schemaSDL }) {
188
358
  const [query, setQuery] = useState(defaultQuery);
189
359
  const [variables, setVariables] = useState('{}');
190
360
  const [customHeaders, setCustomHeaders] = useState(Object.keys(defaultHeaders).length > 0 ? JSON.stringify(defaultHeaders, null, 2) : '{}');
@@ -199,6 +369,35 @@ query {
199
369
  const selectedOperationId = externalSelectedId ?? internalSelectedId;
200
370
  const abortControllerRef = useRef(null);
201
371
  const hasLoadedRef = useRef(false);
372
+ const monacoRef = useRef(null);
373
+ // Initialize Monaco GraphQL when editor mounts
374
+ const handleEditorDidMount = useCallback((editor, monaco)=>{
375
+ monacoRef.current = monaco;
376
+ // Parse schema and register completion provider
377
+ if (schemaSDL) {
378
+ try {
379
+ parsedSchema = buildSchema(schemaSDL);
380
+ registerGraphQLCompletionProvider(monaco, parsedSchema);
381
+ } catch (err) {
382
+ console.error('[GraphQL Playground] Failed to parse schema:', err);
383
+ }
384
+ }
385
+ }, [
386
+ schemaSDL
387
+ ]);
388
+ // Update completion provider when schema changes
389
+ useEffect(()=>{
390
+ if (schemaSDL && monacoRef.current) {
391
+ try {
392
+ parsedSchema = buildSchema(schemaSDL);
393
+ registerGraphQLCompletionProvider(monacoRef.current, parsedSchema);
394
+ } catch (err) {
395
+ console.error('[GraphQL Playground] Failed to update schema:', err);
396
+ }
397
+ }
398
+ }, [
399
+ schemaSDL
400
+ ]);
202
401
  // Load from localStorage on mount
203
402
  useEffect(()=>{
204
403
  const stored = loadStoredState();
@@ -478,6 +677,7 @@ query {
478
677
  height: "100%",
479
678
  language: "graphql",
480
679
  value: query,
680
+ onMount: handleEditorDidMount,
481
681
  onChange: (value)=>{
482
682
  setQuery(value || '');
483
683
  setInternalSelectedId(undefined); // Clear internal selection when manually editing
@@ -502,7 +702,9 @@ query {
502
702
  },
503
703
  wordWrap: 'on',
504
704
  automaticLayout: true,
505
- tabSize: 2
705
+ tabSize: 2,
706
+ quickSuggestions: true,
707
+ suggestOnTriggerCharacters: true
506
708
  },
507
709
  loading: /*#__PURE__*/ _jsxs("div", {
508
710
  className: "flex items-center justify-center h-full text-muted-foreground text-sm",
@@ -2,15 +2,14 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useState, useCallback } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import Image from 'next/image';
6
- import { X, ArrowClockwise } from '@phosphor-icons/react';
5
+ import { X, ArrowClockwise, Sparkle } from '@phosphor-icons/react';
7
6
  import { AgentChat } from '../agent';
8
7
  import { Button } from '@/components/ui/button';
9
8
  import { useMobile } from '@/lib/api-docs/mobile-context';
10
9
  import { cn } from '@/lib/utils';
11
10
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
12
11
  export function RightSidebar({ request, collection, apiSummary, onNavigateToEndpoint, onPrefillParameters, debugContext, onClearDebugContext, explainContext, onClearExplainContext, onOpenGlobalAuth, onNavigateToAuthTab, onNavigateToParamsTab, onNavigateToBodyTab, onNavigateToHeadersTab, onNavigateToDocSection, onNavigateToDocPage }) {
13
- // Mobile context
12
+ // Mobile context - now used for both mobile and desktop
14
13
  const { isMobile, isRightSidebarOpen, closeRightSidebar } = useMobile();
15
14
  const [mounted, setMounted] = useState(false);
16
15
  // Ensure we're mounted before using portal (for SSR)
@@ -46,7 +45,7 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
46
45
  setChatKey((prev)=>prev + 1);
47
46
  setHasMessages(false);
48
47
  }, []);
49
- // Sidebar content component (shared between mobile and desktop)
48
+ // Sidebar content component
50
49
  const sidebarContent = /*#__PURE__*/ _jsxs(_Fragment, {
51
50
  children: [
52
51
  /*#__PURE__*/ _jsx("div", {
@@ -57,15 +56,9 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
57
56
  /*#__PURE__*/ _jsxs("div", {
58
57
  className: "docs-agent-title flex items-center gap-2.5",
59
58
  children: [
60
- /*#__PURE__*/ _jsx("div", {
61
- className: "docs-agent-avatar size-7 rounded-full overflow-hidden bg-muted flex items-center justify-center shrink-0",
62
- children: /*#__PURE__*/ _jsx(Image, {
63
- src: "/icon.png",
64
- alt: "Assistant",
65
- width: 28,
66
- height: 28,
67
- className: "size-7 object-cover"
68
- })
59
+ /*#__PURE__*/ _jsx(Sparkle, {
60
+ className: "size-5 text-primary",
61
+ weight: "fill"
69
62
  }),
70
63
  /*#__PURE__*/ _jsx("span", {
71
64
  className: "text-sm font-medium",
@@ -97,15 +90,26 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
97
90
  })
98
91
  ]
99
92
  }),
100
- isMobile && /*#__PURE__*/ _jsx(Button, {
101
- variant: "ghost",
102
- size: "icon",
103
- onClick: closeRightSidebar,
104
- className: "h-7 w-7 lg:hidden",
105
- children: /*#__PURE__*/ _jsx(X, {
106
- className: "h-4 w-4",
107
- weight: "bold"
108
- })
93
+ /*#__PURE__*/ _jsxs(Tooltip, {
94
+ children: [
95
+ /*#__PURE__*/ _jsx(TooltipTrigger, {
96
+ asChild: true,
97
+ children: /*#__PURE__*/ _jsx(Button, {
98
+ variant: "ghost",
99
+ size: "icon",
100
+ onClick: closeRightSidebar,
101
+ className: "h-7 w-7 text-muted-foreground hover:text-foreground",
102
+ children: /*#__PURE__*/ _jsx(X, {
103
+ className: "h-4 w-4",
104
+ weight: "bold"
105
+ })
106
+ })
107
+ }),
108
+ /*#__PURE__*/ _jsx(TooltipContent, {
109
+ side: "bottom",
110
+ children: "Close"
111
+ })
112
+ ]
109
113
  })
110
114
  ]
111
115
  })
@@ -136,28 +140,20 @@ export function RightSidebar({ request, collection, apiSummary, onNavigateToEndp
136
140
  })
137
141
  ]
138
142
  });
139
- // Mobile sidebar rendered via portal to escape stacking context
140
- const mobileSidebar = mounted && isMobile ? /*#__PURE__*/ createPortal(/*#__PURE__*/ _jsxs(_Fragment, {
143
+ // Render via portal for proper z-index stacking (both mobile and desktop)
144
+ if (!mounted) return null;
145
+ return /*#__PURE__*/ createPortal(/*#__PURE__*/ _jsxs(_Fragment, {
141
146
  children: [
142
- isRightSidebarOpen && /*#__PURE__*/ _jsx("div", {
143
- className: "docs-agent-overlay fixed inset-0 bg-black/50 z-[55] lg:hidden",
147
+ isMobile && isRightSidebarOpen && /*#__PURE__*/ _jsx("div", {
148
+ className: "docs-agent-overlay fixed inset-0 bg-black/50 z-[55]",
144
149
  onClick: closeRightSidebar
145
150
  }),
146
151
  /*#__PURE__*/ _jsx("div", {
147
- className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "fixed inset-y-0 right-0 z-[60] w-[320px] sm:w-[360px] h-full", "transform transition-transform duration-300 ease-in-out", !isRightSidebarOpen && "translate-x-full", isRightSidebarOpen && "translate-x-0"),
152
+ className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "fixed inset-y-0 right-0 z-[60]", // Width: smaller on mobile, full 384px on desktop
153
+ "w-[320px] sm:w-[360px] lg:w-96", // Slide animation
154
+ "transform transition-transform duration-300 ease-in-out", isRightSidebarOpen ? "translate-x-0" : "translate-x-full"),
148
155
  children: sidebarContent
149
156
  })
150
157
  ]
151
- }), document.body) : null;
152
- // Desktop sidebar rendered in place
153
- const desktopSidebar = !isMobile ? /*#__PURE__*/ _jsx("div", {
154
- className: cn("docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden", "relative w-96 h-full"),
155
- children: sidebarContent
156
- }) : null;
157
- return /*#__PURE__*/ _jsxs(_Fragment, {
158
- children: [
159
- mobileSidebar,
160
- desktopSidebar
161
- ]
162
- });
158
+ }), document.body);
163
159
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { Moon, Sun, Desktop, Check } from "@phosphor-icons/react";
4
+ import { Moon, Sun, Check } from "@phosphor-icons/react";
5
5
  import { useTheme } from "next-themes";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -78,26 +78,6 @@ export function ThemeToggle() {
78
78
  className: "h-4 w-4"
79
79
  })
80
80
  ]
81
- }),
82
- /*#__PURE__*/ _jsxs(DropdownMenuItem, {
83
- onClick: ()=>setTheme("system"),
84
- className: cn("flex items-center justify-between gap-2", mounted && theme === "system" && "bg-accent"),
85
- children: [
86
- /*#__PURE__*/ _jsxs("div", {
87
- className: "flex items-center gap-2",
88
- children: [
89
- /*#__PURE__*/ _jsx(Desktop, {
90
- className: "h-4 w-4"
91
- }),
92
- /*#__PURE__*/ _jsx("span", {
93
- children: "System"
94
- })
95
- ]
96
- }),
97
- mounted && theme === "system" && /*#__PURE__*/ _jsx(Check, {
98
- className: "h-4 w-4"
99
- })
100
- ]
101
81
  })
102
82
  ]
103
83
  })
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+ import { useState, useEffect, useCallback, useMemo } from 'react';
3
+ /**
4
+ * Parse URL hash into route state
5
+ *
6
+ * URL Schema:
7
+ * - #tab → Tab only, show default content (docs view)
8
+ * - #tab/page/{slug} → Doc page (docs view)
9
+ * - #tab/endpoint/{id} → API endpoint (docs view)
10
+ * - #tab/endpoint/{id}/playground → API endpoint (playground view)
11
+ * - #tab/endpoint/{id}/notes → API endpoint (notes view)
12
+ * - #tab/page/{slug}/notes → Doc page (notes view)
13
+ */ function parseHash(hash) {
14
+ // Remove leading # if present
15
+ const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash;
16
+ // Default state
17
+ const defaultState = {
18
+ tab: null,
19
+ contentType: null,
20
+ contentId: null,
21
+ view: 'docs',
22
+ hash: cleanHash
23
+ };
24
+ if (!cleanHash) {
25
+ return defaultState;
26
+ }
27
+ // Split hash into parts: tab/type/id[/view]
28
+ const parts = cleanHash.split('/');
29
+ const tab = parts[0] || null;
30
+ const type = parts[1] || null;
31
+ // Check if last part is a view mode
32
+ const lastPart = parts[parts.length - 1];
33
+ const isViewSuffix = lastPart === 'playground' || lastPart === 'notes';
34
+ const view = isViewSuffix ? lastPart : 'docs';
35
+ // Get ID (everything between type and view, or type and end)
36
+ const idParts = isViewSuffix ? parts.slice(2, -1) : parts.slice(2);
37
+ const id = idParts.join('/') || null;
38
+ // Parse content type
39
+ let contentType = null;
40
+ if (type === 'endpoint') {
41
+ contentType = 'endpoint';
42
+ } else if (type === 'page') {
43
+ contentType = 'page';
44
+ } else if (type === 'section') {
45
+ contentType = 'section';
46
+ }
47
+ return {
48
+ tab,
49
+ contentType,
50
+ contentId: id,
51
+ view,
52
+ hash: cleanHash
53
+ };
54
+ }
55
+ /**
56
+ * Get the current hash from window.location
57
+ */ function getCurrentHash() {
58
+ if (typeof window === 'undefined') return '';
59
+ return window.location.hash;
60
+ }
61
+ /**
62
+ * Hook that provides URL-based route state
63
+ *
64
+ * This hook is the single source of truth for page/content selection.
65
+ * It automatically updates when the URL hash changes (via navigation or browser back/forward).
66
+ *
67
+ * @example
68
+ * const { tab, contentType, contentId } = useRouteState()
69
+ *
70
+ * // Render based on content type
71
+ * if (contentType === 'page') {
72
+ * return <DocPage slug={contentId} />
73
+ * }
74
+ */ export function useRouteState() {
75
+ const [hash, setHash] = useState(()=>getCurrentHash());
76
+ // Parse hash into route state
77
+ const state = useMemo(()=>parseHash(hash), [
78
+ hash
79
+ ]);
80
+ // Listen for hash changes
81
+ useEffect(()=>{
82
+ const handleHashChange = ()=>{
83
+ setHash(getCurrentHash());
84
+ };
85
+ // Listen for both hashchange and popstate (browser back/forward)
86
+ window.addEventListener('hashchange', handleHashChange);
87
+ window.addEventListener('popstate', handleHashChange);
88
+ // Set initial hash (in case it was set before this effect ran)
89
+ handleHashChange();
90
+ return ()=>{
91
+ window.removeEventListener('hashchange', handleHashChange);
92
+ window.removeEventListener('popstate', handleHashChange);
93
+ };
94
+ }, []);
95
+ return state;
96
+ }
97
+ /**
98
+ * Hook that provides navigation functions for updating the URL
99
+ *
100
+ * @param defaultTab - Default tab to use when navigating
101
+ * @returns Navigation functions
102
+ */ export function useRouteNavigation(defaultTab) {
103
+ const navigateToPage = useCallback((slug, tab, view)=>{
104
+ const targetTab = tab || defaultTab || '';
105
+ const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
106
+ window.location.hash = targetTab ? `${targetTab}/page/${slug}${viewSuffix}` : `page/${slug}${viewSuffix}`;
107
+ }, [
108
+ defaultTab
109
+ ]);
110
+ const navigateToEndpoint = useCallback((id, tab, view)=>{
111
+ const targetTab = tab || defaultTab || '';
112
+ const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
113
+ window.location.hash = targetTab ? `${targetTab}/endpoint/${id}${viewSuffix}` : `endpoint/${id}${viewSuffix}`;
114
+ }, [
115
+ defaultTab
116
+ ]);
117
+ const navigateToSection = useCallback((sectionId, tab)=>{
118
+ const targetTab = tab || defaultTab || '';
119
+ window.location.hash = targetTab ? `${targetTab}/section/${sectionId}` : `section/${sectionId}`;
120
+ }, [
121
+ defaultTab
122
+ ]);
123
+ const navigateToTab = useCallback((tab)=>{
124
+ window.location.hash = tab;
125
+ }, []);
126
+ const clearSelection = useCallback((tab)=>{
127
+ const targetTab = tab || defaultTab;
128
+ if (targetTab) {
129
+ window.location.hash = targetTab;
130
+ } else {
131
+ // Remove hash entirely
132
+ window.history.pushState(null, '', window.location.pathname + window.location.search);
133
+ }
134
+ }, [
135
+ defaultTab
136
+ ]);
137
+ return {
138
+ navigateToPage,
139
+ navigateToEndpoint,
140
+ navigateToSection,
141
+ navigateToTab,
142
+ clearSelection
143
+ };
144
+ }
145
+ /**
146
+ * Utility to build hash URLs (for href attributes)
147
+ */ export const buildHashUrl = {
148
+ page: (tab, slug, view)=>{
149
+ const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
150
+ return `#${tab}/page/${slug}${viewSuffix}`;
151
+ },
152
+ endpoint: (tab, id, view)=>{
153
+ const viewSuffix = view && view !== 'docs' ? `/${view}` : '';
154
+ return `#${tab}/endpoint/${id}${viewSuffix}`;
155
+ },
156
+ section: (tab, sectionId)=>`#${tab}/section/${sectionId}`,
157
+ tab: (tab)=>`#${tab}`
158
+ };
159
+ export { parseHash };
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ /**
4
+ * Shared hook for fetching contextual suggestions
5
+ * Used by both AgentChat and AgentPopupButton
6
+ *
7
+ * Server-side caching with KV (1 week TTL) - no client-side cache needed
8
+ */ export function useSuggestions({ currentEndpoint, endpointIndex = [], limit }) {
9
+ const [suggestions, setSuggestions] = useState([]);
10
+ const [isLoading, setIsLoading] = useState(false);
11
+ // Stable key for request deduplication
12
+ const requestKey = useMemo(()=>currentEndpoint?.id || `general:${endpointIndex.length}`, [
13
+ currentEndpoint?.id,
14
+ endpointIndex.length
15
+ ]);
16
+ // Fallback suggestions
17
+ const fallbackSuggestions = useMemo(()=>{
18
+ if (currentEndpoint) {
19
+ return [
20
+ {
21
+ title: 'What does this',
22
+ label: 'endpoint do?',
23
+ prompt: `What does ${currentEndpoint.name} do?`
24
+ },
25
+ {
26
+ title: 'What parameters',
27
+ label: 'are required?',
28
+ prompt: 'What parameters are required?'
29
+ },
30
+ {
31
+ title: 'Python example',
32
+ label: 'Show code',
33
+ prompt: 'Show me a Python example'
34
+ }
35
+ ];
36
+ }
37
+ return [
38
+ {
39
+ title: 'Find an endpoint',
40
+ label: 'to create resources',
41
+ prompt: 'Find endpoints for creating resources'
42
+ },
43
+ {
44
+ title: 'How do I',
45
+ label: 'authenticate?',
46
+ prompt: 'How do I authenticate?'
47
+ },
48
+ {
49
+ title: 'Overview',
50
+ label: 'What can I do?',
51
+ prompt: 'What can I do with this API?'
52
+ }
53
+ ];
54
+ }, [
55
+ currentEndpoint
56
+ ]);
57
+ // Fetch suggestions from API (server handles KV caching)
58
+ useEffect(()=>{
59
+ let cancelled = false;
60
+ setIsLoading(true);
61
+ fetch('/api/suggestions', {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json'
65
+ },
66
+ body: JSON.stringify({
67
+ endpointIndex,
68
+ currentEndpointId: currentEndpoint?.id
69
+ })
70
+ }).then((res)=>res.json()).then((data)=>{
71
+ if (cancelled) return;
72
+ if (data.suggestions) {
73
+ const allSuggestions = data.suggestions;
74
+ setSuggestions(limit ? allSuggestions.slice(0, limit) : allSuggestions);
75
+ }
76
+ }).catch((err)=>{
77
+ if (cancelled) return;
78
+ console.warn('[useSuggestions] Failed to fetch suggestions:', err);
79
+ setSuggestions(limit ? fallbackSuggestions.slice(0, limit) : fallbackSuggestions);
80
+ }).finally(()=>{
81
+ if (!cancelled) setIsLoading(false);
82
+ });
83
+ return ()=>{
84
+ cancelled = true;
85
+ };
86
+ }, [
87
+ requestKey,
88
+ currentEndpoint,
89
+ endpointIndex,
90
+ limit,
91
+ fallbackSuggestions
92
+ ]);
93
+ return {
94
+ suggestions,
95
+ isLoading
96
+ };
97
+ }