jamdesk 1.0.13 → 1.0.14

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 (132) hide show
  1. package/README.md +89 -4
  2. package/dist/__tests__/unit/auth.test.d.ts +2 -0
  3. package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/unit/auth.test.js +169 -0
  5. package/dist/__tests__/unit/auth.test.js.map +1 -0
  6. package/dist/__tests__/unit/config.test.d.ts +2 -0
  7. package/dist/__tests__/unit/config.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/config.test.js +76 -0
  9. package/dist/__tests__/unit/config.test.js.map +1 -0
  10. package/dist/__tests__/unit/deploy.test.d.ts +2 -0
  11. package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
  12. package/dist/__tests__/unit/deploy.test.js +273 -0
  13. package/dist/__tests__/unit/deploy.test.js.map +1 -0
  14. package/dist/__tests__/unit/deps-sync.test.js +3 -1
  15. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
  17. package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
  19. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
  20. package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
  21. package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
  23. package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
  24. package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
  25. package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/loading-page.test.js +73 -0
  27. package/dist/__tests__/unit/loading-page.test.js.map +1 -0
  28. package/dist/__tests__/unit/login.test.d.ts +2 -0
  29. package/dist/__tests__/unit/login.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/login.test.js +100 -0
  31. package/dist/__tests__/unit/login.test.js.map +1 -0
  32. package/dist/__tests__/unit/logout.test.d.ts +2 -0
  33. package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/logout.test.js +39 -0
  35. package/dist/__tests__/unit/logout.test.js.map +1 -0
  36. package/dist/__tests__/unit/tarball.test.d.ts +2 -0
  37. package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/tarball.test.js +126 -0
  39. package/dist/__tests__/unit/tarball.test.js.map +1 -0
  40. package/dist/__tests__/unit/whoami.test.d.ts +2 -0
  41. package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
  42. package/dist/__tests__/unit/whoami.test.js +47 -0
  43. package/dist/__tests__/unit/whoami.test.js.map +1 -0
  44. package/dist/commands/deploy.d.ts +13 -0
  45. package/dist/commands/deploy.d.ts.map +1 -0
  46. package/dist/commands/deploy.js +265 -0
  47. package/dist/commands/deploy.js.map +1 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +48 -25
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/login.d.ts +8 -0
  52. package/dist/commands/login.d.ts.map +1 -0
  53. package/dist/commands/login.js +135 -0
  54. package/dist/commands/login.js.map +1 -0
  55. package/dist/commands/logout.d.ts +5 -0
  56. package/dist/commands/logout.d.ts.map +1 -0
  57. package/dist/commands/logout.js +17 -0
  58. package/dist/commands/logout.js.map +1 -0
  59. package/dist/commands/whoami.d.ts +5 -0
  60. package/dist/commands/whoami.d.ts.map +1 -0
  61. package/dist/commands/whoami.js +24 -0
  62. package/dist/commands/whoami.js.map +1 -0
  63. package/dist/index.js +50 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/lib/auth.d.ts +34 -0
  66. package/dist/lib/auth.d.ts.map +1 -0
  67. package/dist/lib/auth.js +105 -0
  68. package/dist/lib/auth.js.map +1 -0
  69. package/dist/lib/config.d.ts +9 -0
  70. package/dist/lib/config.d.ts.map +1 -1
  71. package/dist/lib/config.js +7 -1
  72. package/dist/lib/config.js.map +1 -1
  73. package/dist/lib/dev-loading-server.d.ts +22 -0
  74. package/dist/lib/dev-loading-server.d.ts.map +1 -0
  75. package/dist/lib/dev-loading-server.js +117 -0
  76. package/dist/lib/dev-loading-server.js.map +1 -0
  77. package/dist/lib/docs-config.d.ts +1 -0
  78. package/dist/lib/docs-config.d.ts.map +1 -1
  79. package/dist/lib/docs-config.js.map +1 -1
  80. package/dist/lib/docs-json-writer.d.ts +2 -0
  81. package/dist/lib/docs-json-writer.d.ts.map +1 -0
  82. package/dist/lib/docs-json-writer.js +35 -0
  83. package/dist/lib/docs-json-writer.js.map +1 -0
  84. package/dist/lib/loading-page.d.ts +11 -0
  85. package/dist/lib/loading-page.d.ts.map +1 -0
  86. package/dist/lib/loading-page.js +222 -0
  87. package/dist/lib/loading-page.js.map +1 -0
  88. package/dist/lib/output.d.ts +13 -5
  89. package/dist/lib/output.d.ts.map +1 -1
  90. package/dist/lib/output.js +22 -5
  91. package/dist/lib/output.js.map +1 -1
  92. package/dist/lib/tarball.d.ts +28 -0
  93. package/dist/lib/tarball.d.ts.map +1 -0
  94. package/dist/lib/tarball.js +117 -0
  95. package/dist/lib/tarball.js.map +1 -0
  96. package/package.json +5 -2
  97. package/vendored/app/[[...slug]]/page.tsx +6 -20
  98. package/vendored/app/api/chat/[project]/route.ts +323 -0
  99. package/vendored/app/api/mcp/[project]/route.ts +2 -63
  100. package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
  101. package/vendored/components/chat/ChatEmptyState.tsx +79 -0
  102. package/vendored/components/chat/ChatFAB.tsx +36 -0
  103. package/vendored/components/chat/ChatInput.tsx +106 -0
  104. package/vendored/components/chat/ChatMessage.tsx +176 -0
  105. package/vendored/components/chat/ChatPanel.tsx +206 -0
  106. package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
  107. package/vendored/components/chat/LazyChatPanel.tsx +19 -0
  108. package/vendored/components/layout/LayoutWrapper.tsx +134 -44
  109. package/vendored/components/layout/PageColumns.tsx +40 -0
  110. package/vendored/components/navigation/Header.tsx +74 -29
  111. package/vendored/components/navigation/Sidebar.tsx +17 -2
  112. package/vendored/hooks/useChat.ts +335 -0
  113. package/vendored/hooks/useChatPanel.tsx +101 -0
  114. package/vendored/lib/anthropic-client.ts +19 -0
  115. package/vendored/lib/build/extract-tarball.ts +150 -0
  116. package/vendored/lib/chat-prompt.ts +56 -0
  117. package/vendored/lib/docs-types.ts +14 -0
  118. package/vendored/lib/docs.ts +22 -4
  119. package/vendored/lib/embedding-chunker.ts +173 -0
  120. package/vendored/lib/generate-starter-questions.ts +98 -0
  121. package/vendored/lib/isr-build-executor.ts +2 -1
  122. package/vendored/lib/middleware-helpers.ts +21 -0
  123. package/vendored/lib/route-helpers.ts +96 -0
  124. package/vendored/lib/snippet-loader-isr.ts +107 -1
  125. package/vendored/lib/static-artifacts.ts +3 -2
  126. package/vendored/lib/validate-config.ts +1 -0
  127. package/vendored/lib/vector-store.ts +213 -0
  128. package/vendored/schema/docs-schema.json +33 -0
  129. package/vendored/scripts/dev-project.cjs +6 -0
  130. package/vendored/shared/types.ts +6 -5
  131. package/vendored/tailwind.config.ts +9 -0
  132. package/vendored/themes/jam/variables.css +2 -2
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef, type KeyboardEvent } from 'react';
4
+ import { MIN_WIDTH, MAX_WIDTH } from '@/hooks/useChatPanel';
5
+
6
+ interface ChatResizeHandleProps {
7
+ chatWidth: number;
8
+ onResize: (width: number) => void;
9
+ }
10
+
11
+ /**
12
+ * Vertical drag handle between content and chat column.
13
+ * Supports mouse, touch, and keyboard (ArrowLeft/ArrowRight) interaction.
14
+ */
15
+ export function ChatResizeHandle({ chatWidth, onResize }: ChatResizeHandleProps) {
16
+ const rafRef = useRef<number | null>(null);
17
+ const isDraggingRef = useRef(false);
18
+
19
+ const startDrag = useCallback(
20
+ (startX: number) => {
21
+ const startWidth = chatWidth;
22
+ isDraggingRef.current = true;
23
+ document.body.style.userSelect = 'none';
24
+ document.body.style.cursor = 'col-resize';
25
+
26
+ const onMove = (clientX: number) => {
27
+ if (rafRef.current !== null) return;
28
+ rafRef.current = requestAnimationFrame(() => {
29
+ rafRef.current = null;
30
+ // Dragging left increases width (chat is on the right)
31
+ const delta = startX - clientX;
32
+ const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
33
+ onResize(newWidth);
34
+ });
35
+ };
36
+
37
+ const onEnd = () => {
38
+ isDraggingRef.current = false;
39
+ document.body.style.userSelect = '';
40
+ document.body.style.cursor = '';
41
+ if (rafRef.current !== null) {
42
+ cancelAnimationFrame(rafRef.current);
43
+ rafRef.current = null;
44
+ }
45
+ document.removeEventListener('mousemove', handleMouseMove);
46
+ document.removeEventListener('mouseup', onEnd);
47
+ document.removeEventListener('touchmove', handleTouchMove);
48
+ document.removeEventListener('touchend', onEnd);
49
+ };
50
+
51
+ const handleMouseMove = (e: MouseEvent) => onMove(e.clientX);
52
+ const handleTouchMove = (e: TouchEvent) => onMove(e.touches[0].clientX);
53
+
54
+ document.addEventListener('mousemove', handleMouseMove);
55
+ document.addEventListener('mouseup', onEnd);
56
+ document.addEventListener('touchmove', handleTouchMove, { passive: true });
57
+ document.addEventListener('touchend', onEnd);
58
+ },
59
+ [chatWidth, onResize],
60
+ );
61
+
62
+ const handleMouseDown = useCallback(
63
+ (e: React.MouseEvent) => {
64
+ e.preventDefault();
65
+ startDrag(e.clientX);
66
+ },
67
+ [startDrag],
68
+ );
69
+
70
+ const handleTouchStart = useCallback(
71
+ (e: React.TouchEvent) => {
72
+ startDrag(e.touches[0].clientX);
73
+ },
74
+ [startDrag],
75
+ );
76
+
77
+ const handleKeyDown = useCallback(
78
+ (e: KeyboardEvent<HTMLDivElement>) => {
79
+ const step = 10;
80
+ if (e.key === 'ArrowLeft') {
81
+ e.preventDefault();
82
+ onResize(Math.max(MIN_WIDTH, chatWidth - step));
83
+ } else if (e.key === 'ArrowRight') {
84
+ e.preventDefault();
85
+ onResize(Math.min(MAX_WIDTH, chatWidth + step));
86
+ }
87
+ },
88
+ [chatWidth, onResize],
89
+ );
90
+
91
+ return (
92
+ <div
93
+ role="separator"
94
+ aria-orientation="vertical"
95
+ aria-valuenow={chatWidth}
96
+ aria-valuemin={MIN_WIDTH}
97
+ aria-valuemax={MAX_WIDTH}
98
+ aria-label="Resize chat panel"
99
+ tabIndex={0}
100
+ onMouseDown={handleMouseDown}
101
+ onTouchStart={handleTouchStart}
102
+ onKeyDown={handleKeyDown}
103
+ className="hidden xl:flex items-center justify-center w-1 flex-shrink-0 cursor-col-resize group focus-visible:outline-2 focus-visible:outline-[var(--color-accent)]"
104
+ >
105
+ <div className="w-px h-full bg-[var(--color-border)] group-hover:bg-[var(--color-accent)]/50 transition-colors" />
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import dynamic from 'next/dynamic';
4
+ import type { ComponentProps } from 'react';
5
+ import type { ChatPanel as ChatPanelType } from './ChatPanel';
6
+
7
+ /**
8
+ * Code-split ChatPanel without visible loading state.
9
+ * The panel is rendered unconditionally (with isOpen=false initially),
10
+ * so a loading fallback would flash on every page load.
11
+ */
12
+ const ChatPanel = dynamic(
13
+ () => import('./ChatPanel').then((m) => m.ChatPanel),
14
+ { ssr: false },
15
+ );
16
+
17
+ export function LazyChatPanel(props: ComponentProps<typeof ChatPanelType>) {
18
+ return <ChatPanel {...props} />;
19
+ }
@@ -1,9 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo, useEffect } from 'react';
3
+ import { useState, useEffect } from 'react';
4
4
  import { Header } from '@/components/navigation/Header';
5
5
  import { Sidebar } from '@/components/navigation/Sidebar';
6
6
  import { TabsNav } from '@/components/navigation/TabsNav';
7
+ import { LazyChatPanel } from '@/components/chat/LazyChatPanel';
8
+ import { ChatResizeHandle } from '@/components/chat/ChatResizeHandle';
9
+ import { ChatPanel } from '@/components/chat/ChatPanel';
10
+ import { ChatPanelProvider, useChatPanel } from '@/hooks/useChatPanel';
7
11
  import { useHashNavigation } from '@/hooks/useHashNavigation';
8
12
  import type { DocsConfig } from '@/lib/docs-types';
9
13
  import { getTheme, type ThemeName } from '@/themes';
@@ -13,8 +17,33 @@ interface LayoutWrapperProps {
13
17
  children: React.ReactNode;
14
18
  }
15
19
 
20
+ /**
21
+ * Hook to track a CSS media query match.
22
+ * Returns false during SSR/initial render to avoid hydration mismatch.
23
+ */
24
+ function useMediaQuery(query: string): boolean {
25
+ const [matches, setMatches] = useState(false);
26
+
27
+ useEffect(() => {
28
+ const mql = window.matchMedia(query);
29
+ setMatches(mql.matches);
30
+ const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
31
+ mql.addEventListener('change', handler);
32
+ return () => mql.removeEventListener('change', handler);
33
+ }, [query]);
34
+
35
+ return matches;
36
+ }
37
+
16
38
  export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
17
39
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
40
+ // Chat is on by default in production; projects can opt out with chat.enabled: false.
41
+ // Hidden in local dev — the chat API route needs Upstash Vector + Anthropic API.
42
+ const chatEnabled = process.env.NODE_ENV === 'production'
43
+ && (config.chat?.enabled !== false);
44
+
45
+ // xl breakpoint (1280px) — chat renders inline on desktop, overlay on mobile
46
+ const isDesktop = useMediaQuery('(min-width: 1280px)');
18
47
 
19
48
  // Handle hash navigation for three-column independent scroll layout
20
49
  useHashNavigation();
@@ -42,22 +71,36 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
42
71
  }, []);
43
72
 
44
73
  // Get theme config to determine layout
45
- const themeConfig = useMemo(() => {
46
- return getTheme(config.theme as ThemeName | undefined);
47
- }, [config.theme]);
74
+ const themeConfig = getTheme(config.theme as ThemeName | undefined);
48
75
 
49
76
  const layout = themeConfig.layout;
50
77
 
51
78
  const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen);
52
79
  const closeSidebar = () => setIsSidebarOpen(false);
53
80
 
81
+ // In local dev (no ISR_MODE), middleware doesn't rewrite /_chat, so use the direct API path
82
+ const chatEndpoint = process.env.NEXT_PUBLIC_PROJECT_SLUG
83
+ ? `/api/chat/${process.env.NEXT_PUBLIC_PROJECT_SLUG}`
84
+ : '/_chat';
85
+
86
+ // Mobile (<xl) overlay — only when chat is NOT shown inline on desktop
87
+ const chatOverlay = chatEnabled && !isDesktop && <MobileChatOverlay />;
88
+
89
+ // Wrap in ChatPanelProvider so Header, PageColumns, and overlay can share state
90
+ const wrapWithProvider = (content: React.ReactNode) => (
91
+ <ChatPanelProvider
92
+ chatEnabled={chatEnabled}
93
+ chatEndpoint={chatEndpoint}
94
+ starterQuestions={config.chat?.starterQuestions}
95
+ >
96
+ {content}
97
+ </ChatPanelProvider>
98
+ );
99
+
54
100
  // Sidebar-logo layout: Sidebar is full height, header only above content
55
- // Tabs are rendered inline in the header for this layout
56
- // Uses fixed viewport height on desktop with independent scroll for each column
57
101
  if (layout === 'sidebar-logo') {
58
- return (
102
+ return wrapWithProvider(
59
103
  <div className="flex min-h-screen lg:h-screen lg:overflow-hidden">
60
- {/* Full-height sidebar - already has its own scroll */}
61
104
  <Sidebar
62
105
  config={config}
63
106
  layout={layout}
@@ -65,7 +108,6 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
65
108
  onClose={closeSidebar}
66
109
  />
67
110
 
68
- {/* Content area with header (tabs are inline in header) */}
69
111
  <div className="flex-1 lg:ml-[295px] flex flex-col lg:h-screen bg-[var(--color-bg-content,var(--color-bg-primary))]">
70
112
  <Header
71
113
  config={config}
@@ -73,7 +115,6 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
73
115
  isSidebarOpen={isSidebarOpen}
74
116
  onToggleSidebar={toggleSidebar}
75
117
  />
76
- {/* Main content area - contains content scroll container and TOC */}
77
118
  <main
78
119
  id="main-content"
79
120
  className="flex-1 flex flex-col lg:min-h-0 transition-colors overflow-x-hidden"
@@ -81,48 +122,97 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
81
122
  {children}
82
123
  </main>
83
124
  </div>
84
- </div>
125
+
126
+ {isDesktop && <DesktopChatColumn />}
127
+ {chatOverlay}
128
+ </div>,
85
129
  );
86
130
  }
87
131
 
88
- // Header-logo layout (Jam, Nebula): Uses sticky positioning for max-width support
89
- // Outer wrapper constrains entire layout to --layout-max-width
90
- // Uses fixed viewport height on desktop with independent scroll for each column
91
- return (
92
- <div
93
- className="min-h-screen lg:h-screen flex flex-col lg:overflow-hidden mx-auto px-4 lg:px-6"
94
- style={{ maxWidth: 'var(--layout-max-width, none)' }}
95
- >
96
- {/* Header - stays at top, does not scroll */}
97
- <Header
98
- config={config}
99
- layout={layout}
100
- isSidebarOpen={isSidebarOpen}
101
- onToggleSidebar={toggleSidebar}
102
- />
103
-
104
- {/* Tabs bar - below header, same height */}
105
- <TabsNav config={config} />
106
-
107
- {/* Main layout area with sidebar and content - fills remaining height */}
108
- <div className="flex flex-1 lg:min-h-0">
109
- {/* Sidebar - has its own scroll via sidebar-scroll class */}
110
- <Sidebar
132
+ // Header-logo layout (Jam, Nebula) outer flex row for layout + chat column
133
+ return wrapWithProvider(
134
+ <div className="flex min-h-screen lg:h-screen lg:overflow-hidden">
135
+ <div
136
+ className="flex-1 min-w-0 flex flex-col lg:overflow-hidden mx-auto px-4 lg:px-6"
137
+ style={{ maxWidth: 'var(--layout-max-width, none)' }}
138
+ >
139
+ <Header
111
140
  config={config}
112
141
  layout={layout}
113
- isOpen={isSidebarOpen}
114
- onClose={closeSidebar}
142
+ isSidebarOpen={isSidebarOpen}
143
+ onToggleSidebar={toggleSidebar}
115
144
  />
116
145
 
117
- {/* Main content area - contains content scroll container and TOC */}
118
- <main
119
- id="main-content"
120
- className="flex-1 flex flex-col lg:min-h-0 bg-[var(--color-bg-primary)] transition-colors overflow-x-hidden"
121
- >
122
- {children}
123
- </main>
146
+ <TabsNav config={config} />
147
+
148
+ <div className="flex flex-1 lg:min-h-0">
149
+ <Sidebar
150
+ config={config}
151
+ layout={layout}
152
+ isOpen={isSidebarOpen}
153
+ onClose={closeSidebar}
154
+ />
155
+
156
+ <main
157
+ id="main-content"
158
+ className="flex-1 flex flex-col lg:min-h-0 bg-[var(--color-bg-primary)] transition-colors overflow-x-hidden"
159
+ >
160
+ {children}
161
+ </main>
162
+ </div>
163
+ </div>
164
+
165
+ {isDesktop && <DesktopChatColumn />}
166
+ {chatOverlay}
167
+ </div>,
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Desktop chat column — renders at the outermost layout level for full page height.
173
+ * Uses ChatPanelProvider context for open/close state and width.
174
+ */
175
+ function DesktopChatColumn() {
176
+ const { isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEndpoint, starterQuestions } = useChatPanel();
177
+
178
+ if (!isChatOpen) return null;
179
+
180
+ return (
181
+ <>
182
+ <ChatResizeHandle chatWidth={chatWidth} onResize={setChatWidth} />
183
+ <div
184
+ className="flex flex-col flex-shrink-0"
185
+ style={{
186
+ width: chatWidth,
187
+ backgroundColor: 'var(--color-bg-primary)',
188
+ }}
189
+ data-chat-panel
190
+ >
191
+ <ChatPanel
192
+ mode="inline"
193
+ isOpen={true}
194
+ onClose={() => setIsChatOpen(false)}
195
+ starterQuestions={starterQuestions}
196
+ chatEndpoint={chatEndpoint}
197
+ />
124
198
  </div>
125
- </div>
199
+ </>
126
200
  );
127
201
  }
128
202
 
203
+ /**
204
+ * Mobile chat overlay — uses ChatPanelProvider context for open/close state.
205
+ * Separated so it can access the provider context.
206
+ */
207
+ function MobileChatOverlay() {
208
+ const { isChatOpen, setIsChatOpen, chatEndpoint, starterQuestions } = useChatPanel();
209
+
210
+ return (
211
+ <LazyChatPanel
212
+ isOpen={isChatOpen}
213
+ onClose={() => setIsChatOpen(false)}
214
+ starterQuestions={starterQuestions}
215
+ chatEndpoint={chatEndpoint}
216
+ />
217
+ );
218
+ }
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode } from 'react';
4
+ import { useChatPanel } from '@/hooks/useChatPanel';
5
+
6
+ interface PageColumnsProps {
7
+ children: ReactNode;
8
+ toc: ReactNode;
9
+ isWideMode?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Two-column page layout: content | TOC.
14
+ * On xl+ screens, the TOC is hidden when chat is open (chat renders at layout level).
15
+ * On <xl screens, only content is rendered (TOC hidden, chat is a mobile overlay).
16
+ */
17
+ export function PageColumns({ children, toc, isWideMode }: PageColumnsProps) {
18
+ const { isChatOpen } = useChatPanel();
19
+
20
+ return (
21
+ <div className="flex h-full">
22
+ {/* Content column - scrolls independently */}
23
+ <div
24
+ id="content-scroll-container"
25
+ className="flex-1 min-w-0 lg:overflow-y-auto lg:h-full content-scroll"
26
+ >
27
+ {children}
28
+ </div>
29
+
30
+ {/* TOC — shown when chat is closed and not in wide mode */}
31
+ {!isChatOpen && !isWideMode && (
32
+ <aside className="hidden xl:block w-72 flex-shrink-0 xl:h-full xl:overflow-y-auto toc-scroll xl:ml-0.5 pr-2">
33
+ <div className="py-6 sm:py-10 pr-4">
34
+ {toc}
35
+ </div>
36
+ </aside>
37
+ )}
38
+ </div>
39
+ );
40
+ }
@@ -15,6 +15,7 @@ import { LanguageSelector } from './LanguageSelector';
15
15
  import { resolveNavigation } from '@/lib/navigation-resolver';
16
16
  import { getIconClass } from '@/lib/icon-utils';
17
17
  import { useLinkPrefix } from '@/lib/link-prefix-context';
18
+ import { useChatPanel } from '@/hooks/useChatPanel';
18
19
 
19
20
  interface HeaderProps {
20
21
  config: DocsConfig;
@@ -27,7 +28,15 @@ interface HeaderProps {
27
28
  // Max tabs to show before overflow dropdown
28
29
  const MAX_VISIBLE_TABS = 4;
29
30
 
31
+ // Shared button classes for mobile search/AI icons
32
+ const MOBILE_ICON_BUTTON = 'lg:hidden p-2 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)]/50 border border-[var(--color-border)]/50 hover:bg-[var(--color-bg-secondary)] rounded-md transition-colors cursor-pointer';
33
+
34
+ // Shared button classes for compact (Nebula) header icon buttons
35
+ const COMPACT_ICON_BUTTON = 'hidden md:flex p-2 hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors cursor-pointer';
36
+
30
37
  export function Header({ config, layout = 'header-logo', tabsPosition: tabsPositionProp, isSidebarOpen, onToggleSidebar }: HeaderProps) {
38
+ const { chatEnabled: chatContextEnabled, setIsChatOpen } = useChatPanel();
39
+ const onChatOpen = chatContextEnabled ? () => setIsChatOpen(true) : undefined;
31
40
  const [isSearchOpen, setIsSearchOpen] = useState(false);
32
41
  const [logoError, setLogoError] = useState(false);
33
42
  const [isDark, setIsDark] = useState(false);
@@ -275,7 +284,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
275
284
  {/* Hamburger menu - mobile only */}
276
285
  <button
277
286
  onClick={onToggleSidebar}
278
- className="lg:hidden p-1.5 -ml-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors"
287
+ className="lg:hidden p-1.5 -ml-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors cursor-pointer"
279
288
  aria-label="Toggle menu"
280
289
  >
281
290
  {isSidebarOpen ? (
@@ -297,28 +306,43 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
297
306
  )}
298
307
  </div>
299
308
 
300
- {/* Center: Search Bar - only in header-logo layout, not for compact search themes */}
309
+ {/* Center: Search Bar + Ask AI Button - only in header-logo layout, not for compact search themes */}
301
310
  {showLogoInHeader && !useCompactSearch && (
302
311
  <div className="flex-1 flex justify-center px-4">
303
- <button
304
- onClick={() => setIsSearchOpen(true)}
305
- className="hidden md:flex items-center gap-3 px-3 py-2 w-full max-w-sm bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors"
306
- >
307
- <i className="fa-solid fa-magnifying-glass text-[14px] flex-shrink-0" aria-hidden="true" />
308
- <span className="flex-1 text-left">Search...</span>
309
- <kbd className="hidden sm:inline-flex items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded">
310
- <span className="text-sm leading-none">⌘</span>
311
- <span>K</span>
312
- </kbd>
313
- </button>
312
+ <div className="hidden md:flex items-center gap-2">
313
+ <button
314
+ onClick={() => setIsSearchOpen(true)}
315
+ className="flex items-center gap-3 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
316
+ aria-label="Search documentation"
317
+ >
318
+ <i className="fa-solid fa-magnifying-glass text-[14px] flex-shrink-0" aria-hidden="true" />
319
+ <span>Search</span>
320
+ <kbd className="hidden sm:inline-flex items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded">
321
+ <span className="text-sm leading-none">⌘</span>
322
+ <span>K</span>
323
+ </kbd>
324
+ </button>
325
+ {onChatOpen && (
326
+ <button
327
+ onClick={onChatOpen}
328
+ className="flex items-center gap-3 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] cursor-pointer transition-colors text-sm text-[var(--color-text-muted)]"
329
+ aria-label="Ask AI"
330
+ title="Ask AI (⌘I)"
331
+ >
332
+ <i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
333
+ <span>Ask AI</span>
334
+ <kbd className="hidden sm:inline-flex items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded">
335
+ <span className="text-sm leading-none">⌘</span>
336
+ <span>I</span>
337
+ </kbd>
338
+ </button>
339
+ )}
340
+ </div>
314
341
  </div>
315
342
  )}
316
343
 
317
- {/* Spacer for compact search themes (nebula) */}
318
- {showLogoInHeader && useCompactSearch && <div className="flex-1" />}
319
-
320
- {/* Spacer for sidebar-logo layout */}
321
- {!showLogoInHeader && <div className="flex-1" />}
344
+ {/* Spacer needed when the center search bar is not shown (compact themes or sidebar-logo layout) */}
345
+ {(!showLogoInHeader || useCompactSearch) && <div className="flex-1" />}
322
346
 
323
347
  {/* Right side: Search icon (mobile) + Theme toggle */}
324
348
  <div className="flex items-center gap-2 flex-shrink-0">
@@ -425,14 +449,23 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
425
449
  </nav>
426
450
  )}
427
451
 
428
- {/* Search icon - mobile only */}
452
+ {/* Search + AI icons - mobile only */}
429
453
  <button
430
454
  onClick={() => setIsSearchOpen(true)}
431
- className="lg:hidden p-2 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)]/50 border border-[var(--color-border)]/50 hover:bg-[var(--color-bg-secondary)] rounded-md transition-colors"
455
+ className={MOBILE_ICON_BUTTON}
432
456
  aria-label="Search"
433
457
  >
434
458
  <i className="fa-solid fa-magnifying-glass text-[16px]" aria-hidden="true" />
435
459
  </button>
460
+ {onChatOpen && (
461
+ <button
462
+ onClick={onChatOpen}
463
+ className={MOBILE_ICON_BUTTON}
464
+ aria-label="Ask AI"
465
+ >
466
+ <i className="fa-solid fa-sparkles text-[16px] text-[var(--color-accent)]" aria-hidden="true" />
467
+ </button>
468
+ )}
436
469
 
437
470
  {/* Navbar Links - shown to left of theme toggle in header-logo layout */}
438
471
  {showLogoInHeader && config.navbar?.links && config.navbar.links.length > 0 && (
@@ -481,16 +514,28 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
481
514
  />
482
515
  )}
483
516
 
484
- {/* Compact search icon - for themes like Nebula that don't show full search bar */}
517
+ {/* Compact search + AI icons - for themes like Nebula that use icon-only buttons */}
485
518
  {showLogoInHeader && useCompactSearch && (
486
- <button
487
- onClick={() => setIsSearchOpen(true)}
488
- className="hidden md:flex p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors"
489
- aria-label="Search"
490
- title="Search (⌘K)"
491
- >
492
- <i className="fa-solid fa-magnifying-glass text-[16px]" aria-hidden="true" />
493
- </button>
519
+ <>
520
+ <button
521
+ onClick={() => setIsSearchOpen(true)}
522
+ className={`${COMPACT_ICON_BUTTON} text-[var(--color-text-secondary)]`}
523
+ aria-label="Search"
524
+ title="Search (⌘K)"
525
+ >
526
+ <i className="fa-solid fa-magnifying-glass text-[16px]" aria-hidden="true" />
527
+ </button>
528
+ {onChatOpen && (
529
+ <button
530
+ onClick={onChatOpen}
531
+ className={`${COMPACT_ICON_BUTTON} text-[var(--color-accent)]`}
532
+ aria-label="Ask AI"
533
+ title="Ask AI (⌘I)"
534
+ >
535
+ <i className="fa-solid fa-sparkles text-[16px]" aria-hidden="true" />
536
+ </button>
537
+ )}
538
+ </>
494
539
  )}
495
540
 
496
541
  {/* Theme toggle - hidden on mobile since it's in the sidebar menu */}
@@ -29,6 +29,7 @@ import { ThemeToggle, ThemeToggleCycle } from '@/components/theme/ThemeToggle';
29
29
  import { getIconClass } from '@/lib/icon-utils';
30
30
  import { getTabsFromConfig } from '@/lib/navigation-utils';
31
31
  import { useLinkPrefix } from '@/lib/link-prefix-context';
32
+ import { useChatPanel } from '@/hooks/useChatPanel';
32
33
 
33
34
  interface SidebarProps {
34
35
  config: DocsConfig;
@@ -147,6 +148,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
147
148
  const sidebarRef = useRef<HTMLElement>(null);
148
149
  const linkPrefix = useLinkPrefix();
149
150
 
151
+ // Chat panel state — used for Pulsar sidebar AI button
152
+ const { chatEnabled, setIsChatOpen } = useChatPanel();
153
+ const onChatOpen = chatEnabled ? () => setIsChatOpen(true) : undefined;
154
+
150
155
  // Optimistic navigation: immediately highlight clicked link while page loads.
151
156
  // Strips hash fragments so anchor links (e.g. /page#section) match pathname.
152
157
  const handleNavigate = useCallback((url: string) => {
@@ -692,10 +697,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
692
697
 
693
698
  {/* Search button in sidebar - for sidebar-logo layout (desktop only, mobile uses header search) */}
694
699
  {showSearchInSidebar && (
695
- <div className="hidden lg:block px-4 pt-4 pb-4">
700
+ <div className="hidden lg:flex items-center gap-2 px-4 pt-4 pb-4">
696
701
  <button
697
702
  onClick={() => setIsSearchOpen(true)}
698
- className="w-full flex items-center gap-2.5 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors"
703
+ className="flex-1 flex items-center gap-2.5 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer"
699
704
  >
700
705
  <i className="fa-solid fa-magnifying-glass text-[14px] flex-shrink-0" aria-hidden="true" />
701
706
  <span className="flex-1 text-left">Search...</span>
@@ -704,6 +709,16 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
704
709
  <span>K</span>
705
710
  </kbd>
706
711
  </button>
712
+ {onChatOpen && (
713
+ <button
714
+ onClick={onChatOpen}
715
+ className="flex-shrink-0 flex items-center justify-center w-9 h-9 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer"
716
+ aria-label="Ask AI"
717
+ title="Ask AI (⌘I)"
718
+ >
719
+ <i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
720
+ </button>
721
+ )}
707
722
  </div>
708
723
  )}
709
724