jamdesk 1.1.29 → 1.1.30
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.
- package/dist/lib/deps.js +3 -3
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +36 -109
- package/vendored/app/api/assets/[...path]/route.ts +2 -2
- package/vendored/app/api/chat/[project]/route.ts +76 -35
- package/vendored/app/api/isr-health/route.ts +2 -3
- package/vendored/app/layout.tsx +2 -0
- package/vendored/components/JdReadySentinel.tsx +25 -0
- package/vendored/components/navigation/Breadcrumb.tsx +2 -2
- package/vendored/components/navigation/Header.tsx +23 -17
- package/vendored/components/navigation/LanguageSelector.tsx +7 -4
- package/vendored/components/navigation/Sidebar.tsx +28 -37
- package/vendored/components/navigation/TabsNav.tsx +1 -1
- package/vendored/hooks/useChat.ts +113 -60
- package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
- package/vendored/hooks/useTextStreamPacer.ts +152 -0
- package/vendored/lib/chat-prompt.ts +5 -3
- package/vendored/lib/docs-types.ts +4 -0
- package/vendored/lib/find-first-nav-page.ts +40 -0
- package/vendored/lib/hedge-strip.ts +29 -0
- package/vendored/lib/middleware-helpers.ts +2 -1
- package/vendored/lib/openapi/lang-spec-path.ts +16 -0
- package/vendored/lib/page-isr-helpers.ts +4 -1
- package/vendored/lib/public-paths-resolver.ts +3 -42
- package/vendored/lib/ui-strings.ts +52 -0
- package/vendored/schema/docs-schema.json +15 -0
- package/vendored/workspace-package-lock.json +18 -18
|
@@ -17,6 +17,8 @@ import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
|
17
17
|
import { getIconClass } from '@/lib/icon-utils';
|
|
18
18
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
19
19
|
import { useChatOpenHandler } from '@/hooks/useChatPanel';
|
|
20
|
+
import { extractLanguageFromPath } from '@/lib/language-utils';
|
|
21
|
+
import { getUiStrings } from '@/lib/ui-strings';
|
|
20
22
|
|
|
21
23
|
interface HeaderProps {
|
|
22
24
|
config: DocsConfig;
|
|
@@ -44,6 +46,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
44
46
|
const tabsDropdownRef = useRef<HTMLDivElement>(null);
|
|
45
47
|
const pathname = usePathname();
|
|
46
48
|
const linkPrefix = useLinkPrefix();
|
|
49
|
+
const currentLang = extractLanguageFromPath(pathname || '/');
|
|
50
|
+
const ui = getUiStrings(currentLang);
|
|
51
|
+
const resolveLabel = (label: string | undefined, labels?: Record<string, string>, fallback?: string) =>
|
|
52
|
+
(currentLang && labels?.[currentLang]) || label || fallback || '';
|
|
47
53
|
|
|
48
54
|
// In sidebar-logo layout, logo is in sidebar, not header
|
|
49
55
|
const showLogoInHeader = layout === 'header-logo';
|
|
@@ -274,7 +280,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
274
280
|
<button
|
|
275
281
|
onClick={onToggleSidebar}
|
|
276
282
|
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"
|
|
277
|
-
aria-label=
|
|
283
|
+
aria-label={ui.toggleMenu}
|
|
278
284
|
>
|
|
279
285
|
{isSidebarOpen ? (
|
|
280
286
|
<i className="fa-solid fa-xmark text-[18px]" aria-hidden="true" />
|
|
@@ -302,10 +308,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
302
308
|
<button
|
|
303
309
|
onClick={() => setIsSearchOpen(true)}
|
|
304
310
|
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"
|
|
305
|
-
aria-label=
|
|
311
|
+
aria-label={ui.search}
|
|
306
312
|
>
|
|
307
313
|
<i className="fa-solid fa-magnifying-glass text-[14px] flex-shrink-0" aria-hidden="true" />
|
|
308
|
-
<span>
|
|
314
|
+
<span>{ui.search}</span>
|
|
309
315
|
<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
316
|
<span className="text-sm leading-none">⌘</span>
|
|
311
317
|
<span>K</span>
|
|
@@ -315,11 +321,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
315
321
|
<button
|
|
316
322
|
onClick={onChatOpen}
|
|
317
323
|
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)]"
|
|
318
|
-
aria-label=
|
|
319
|
-
title=
|
|
324
|
+
aria-label={ui.askAi}
|
|
325
|
+
title={`${ui.askAi} (⌘I)`}
|
|
320
326
|
>
|
|
321
327
|
<i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
|
|
322
|
-
<span>
|
|
328
|
+
<span>{ui.askAi}</span>
|
|
323
329
|
<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">
|
|
324
330
|
<span className="text-sm leading-none">⌘</span>
|
|
325
331
|
<span>I</span>
|
|
@@ -359,7 +365,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
359
365
|
<Link
|
|
360
366
|
key={tab.name}
|
|
361
367
|
href={tab.path}
|
|
362
|
-
prefetch={
|
|
368
|
+
prefetch={true}
|
|
363
369
|
className={`relative flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors
|
|
364
370
|
${tab.isActive
|
|
365
371
|
? 'text-[var(--color-text-primary)]'
|
|
@@ -386,7 +392,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
386
392
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
|
387
393
|
}`}
|
|
388
394
|
>
|
|
389
|
-
<span>
|
|
395
|
+
<span>{ui.more}</span>
|
|
390
396
|
<i className={`fa-solid fa-chevron-down text-[14px] transition-transform ${isTabsDropdownOpen ? 'rotate-180' : ''}`} aria-hidden="true" />
|
|
391
397
|
{isOverflowActive && (
|
|
392
398
|
<span className="absolute bottom-0 left-3 right-3 h-0.5 bg-[var(--color-accent)] rounded-full" />
|
|
@@ -418,7 +424,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
418
424
|
<Link
|
|
419
425
|
key={tab.name}
|
|
420
426
|
href={tab.path}
|
|
421
|
-
prefetch={
|
|
427
|
+
prefetch={true}
|
|
422
428
|
onClick={() => setIsTabsDropdownOpen(false)}
|
|
423
429
|
className={`flex items-center gap-2 px-3 py-2 text-sm transition-colors
|
|
424
430
|
${tab.isActive
|
|
@@ -442,7 +448,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
442
448
|
<button
|
|
443
449
|
onClick={() => setIsSearchOpen(true)}
|
|
444
450
|
className={MOBILE_ICON_BUTTON}
|
|
445
|
-
aria-label=
|
|
451
|
+
aria-label={ui.search}
|
|
446
452
|
>
|
|
447
453
|
<i className="fa-solid fa-magnifying-glass text-[16px]" aria-hidden="true" />
|
|
448
454
|
</button>
|
|
@@ -450,7 +456,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
450
456
|
<button
|
|
451
457
|
onClick={onChatOpen}
|
|
452
458
|
className={MOBILE_ICON_BUTTON}
|
|
453
|
-
aria-label=
|
|
459
|
+
aria-label={ui.askAi}
|
|
454
460
|
>
|
|
455
461
|
<i className="fa-solid fa-sparkles text-[16px] text-[var(--color-accent)]" aria-hidden="true" />
|
|
456
462
|
</button>
|
|
@@ -470,7 +476,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
470
476
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
|
471
477
|
>
|
|
472
478
|
{link.icon && <i className={`${getIconClass(getIconName(link.icon))} text-[14px]`} aria-hidden="true" />}
|
|
473
|
-
<span>{link.label}</span>
|
|
479
|
+
<span>{resolveLabel(link.label, link.labels)}</span>
|
|
474
480
|
{isExternal && <i className="fa-solid fa-arrow-up-right-from-square text-[10px] opacity-50" aria-hidden="true" />}
|
|
475
481
|
</a>
|
|
476
482
|
);
|
|
@@ -489,7 +495,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
489
495
|
{config.navbar.primary.type === 'github' && (
|
|
490
496
|
<i className="fa-brands fa-github text-[14px]" aria-hidden="true" />
|
|
491
497
|
)}
|
|
492
|
-
<span>{config.navbar.primary.label
|
|
498
|
+
<span>{resolveLabel(config.navbar.primary.label, config.navbar.primary.labels, config.navbar.primary.type === 'github' ? 'GitHub' : 'Get Started')}</span>
|
|
493
499
|
</a>
|
|
494
500
|
)}
|
|
495
501
|
|
|
@@ -509,8 +515,8 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
509
515
|
<button
|
|
510
516
|
onClick={() => setIsSearchOpen(true)}
|
|
511
517
|
className={`${COMPACT_ICON_BUTTON} text-[var(--color-text-secondary)]`}
|
|
512
|
-
aria-label=
|
|
513
|
-
title=
|
|
518
|
+
aria-label={ui.search}
|
|
519
|
+
title={`${ui.search} (⌘K)`}
|
|
514
520
|
>
|
|
515
521
|
<i className="fa-solid fa-magnifying-glass text-[16px]" aria-hidden="true" />
|
|
516
522
|
</button>
|
|
@@ -518,8 +524,8 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
518
524
|
<button
|
|
519
525
|
onClick={onChatOpen}
|
|
520
526
|
className={`${COMPACT_ICON_BUTTON} text-[var(--color-accent)]`}
|
|
521
|
-
aria-label=
|
|
522
|
-
title=
|
|
527
|
+
aria-label={ui.askAi}
|
|
528
|
+
title={`${ui.askAi} (⌘I)`}
|
|
523
529
|
>
|
|
524
530
|
<i className="fa-solid fa-sparkles text-[16px]" aria-hidden="true" />
|
|
525
531
|
</button>
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
transformLanguagePath,
|
|
10
10
|
saveLanguagePreference,
|
|
11
11
|
getLanguagePreference,
|
|
12
|
+
extractLanguageFromPath,
|
|
12
13
|
} from '@/lib/language-utils';
|
|
13
14
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
15
|
+
import { getUiStrings } from '@/lib/ui-strings';
|
|
14
16
|
|
|
15
17
|
interface LanguageSelectorProps {
|
|
16
18
|
/** Available languages from resolved navigation */
|
|
@@ -32,6 +34,7 @@ export function LanguageSelector({
|
|
|
32
34
|
const router = useRouter();
|
|
33
35
|
const linkPrefix = useLinkPrefix();
|
|
34
36
|
const pathname = usePathname();
|
|
37
|
+
const ui = getUiStrings(extractLanguageFromPath(pathname || '/'));
|
|
35
38
|
const [isOpen, setIsOpen] = useState(false);
|
|
36
39
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
37
40
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -153,9 +156,9 @@ export function LanguageSelector({
|
|
|
153
156
|
onClick={() => setIsOpen(!isOpen)}
|
|
154
157
|
aria-expanded={isOpen}
|
|
155
158
|
aria-haspopup="listbox"
|
|
156
|
-
aria-label={
|
|
159
|
+
aria-label={`${ui.selectLanguage}: ${currentLanguage?.displayName ?? ''}`}
|
|
157
160
|
className={`
|
|
158
|
-
flex items-center gap-2 rounded-lg transition-colors
|
|
161
|
+
flex items-center gap-2 rounded-lg transition-colors cursor-pointer
|
|
159
162
|
text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]
|
|
160
163
|
hover:bg-[var(--color-bg-tertiary)]
|
|
161
164
|
${compact ? 'px-2 py-1.5' : 'px-3 py-2 w-full'}
|
|
@@ -190,7 +193,7 @@ export function LanguageSelector({
|
|
|
190
193
|
<ul
|
|
191
194
|
ref={listRef}
|
|
192
195
|
role="listbox"
|
|
193
|
-
aria-label=
|
|
196
|
+
aria-label={ui.selectLanguage}
|
|
194
197
|
className={`
|
|
195
198
|
absolute z-50 min-w-[180px] py-1
|
|
196
199
|
bg-[var(--color-bg-primary)] border border-[var(--color-border)]
|
|
@@ -210,7 +213,7 @@ export function LanguageSelector({
|
|
|
210
213
|
onClick={() => handleSelectLanguage(lang)}
|
|
211
214
|
onMouseEnter={() => setFocusedIndex(index)}
|
|
212
215
|
className={`
|
|
213
|
-
w-full flex items-center gap-3 px-3 py-2 text-sm
|
|
216
|
+
w-full flex items-center gap-3 px-3 py-2 text-sm cursor-pointer
|
|
214
217
|
transition-colors outline-none
|
|
215
218
|
${
|
|
216
219
|
lang.isActive
|
|
@@ -6,21 +6,8 @@ import Image from 'next/image';
|
|
|
6
6
|
import { usePathname, useRouter } from 'next/navigation';
|
|
7
7
|
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
8
8
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
9
|
-
import type
|
|
10
|
-
|
|
11
|
-
NavigationPage,
|
|
12
|
-
NavigationConfig,
|
|
13
|
-
AnchorConfig,
|
|
14
|
-
TabConfig,
|
|
15
|
-
GroupConfig,
|
|
16
|
-
} from '@/lib/docs-types';
|
|
17
|
-
import {
|
|
18
|
-
normalizeNavPage,
|
|
19
|
-
normalizeLogo,
|
|
20
|
-
getIconName,
|
|
21
|
-
} from '@/lib/docs-types';
|
|
22
|
-
import type { TabsPosition } from '@/lib/docs-types';
|
|
23
|
-
import { resolveNavigation, type ResolvedGroup, type ResolvedPage, type ResolvedAnchor, type ResolvedTab, type ResolvedExternalAnchor, type ResolvedNavItem } from '@/lib/navigation-resolver';
|
|
9
|
+
import { normalizeLogo, type DocsConfig, type TabsPosition } from '@/lib/docs-types';
|
|
10
|
+
import { resolveNavigation, type ResolvedGroup, type ResolvedPage, type ResolvedTab } from '@/lib/navigation-resolver';
|
|
24
11
|
import type { LayoutVariant } from '@/themes';
|
|
25
12
|
import { getTheme } from '@/themes';
|
|
26
13
|
import { DefaultLogo, DefaultLogoCompact } from './DefaultLogo';
|
|
@@ -31,6 +18,7 @@ import { getIconClass } from '@/lib/icon-utils';
|
|
|
31
18
|
import { getTabsFromConfig } from '@/lib/navigation-utils';
|
|
32
19
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
33
20
|
import { useChatOpenHandler } from '@/hooks/useChatPanel';
|
|
21
|
+
import { useDelayedNavigationSpinner } from '@/hooks/useDelayedNavigationSpinner';
|
|
34
22
|
|
|
35
23
|
interface SidebarProps {
|
|
36
24
|
config: DocsConfig;
|
|
@@ -54,12 +42,13 @@ interface NavPageProps {
|
|
|
54
42
|
page: ResolvedPage;
|
|
55
43
|
pathname: string | null;
|
|
56
44
|
layout: LayoutVariant;
|
|
57
|
-
onPrefetch: (path: string) => void;
|
|
58
45
|
onNavigate: (href: string) => void;
|
|
59
46
|
linkPrefix?: string; // e.g., '/docs' when hostAtDocs is true
|
|
47
|
+
prefetch?: boolean;
|
|
48
|
+
showSpinner?: boolean;
|
|
60
49
|
}
|
|
61
50
|
|
|
62
|
-
export const NavPage = React.memo(function NavPage({ page, pathname, layout,
|
|
51
|
+
export const NavPage = React.memo(function NavPage({ page, pathname, layout, onNavigate, linkPrefix = '', prefetch = true, showSpinner = false }: NavPageProps) {
|
|
63
52
|
const href = `${linkPrefix}/${page.path}`;
|
|
64
53
|
const isActive = pathname === href;
|
|
65
54
|
const colors = page.method ? methodColors[page.method] : null;
|
|
@@ -68,8 +57,7 @@ export const NavPage = React.memo(function NavPage({ page, pathname, layout, onP
|
|
|
68
57
|
<li>
|
|
69
58
|
<Link
|
|
70
59
|
href={href}
|
|
71
|
-
prefetch={
|
|
72
|
-
onMouseEnter={() => onPrefetch(page.path)}
|
|
60
|
+
prefetch={prefetch}
|
|
73
61
|
onClick={() => onNavigate(href)}
|
|
74
62
|
className={`flex items-center gap-2 ${layout === 'header-logo' ? 'pr-3' : 'px-3'} py-1.5 rounded-lg text-sm transition-colors ${
|
|
75
63
|
isActive
|
|
@@ -86,11 +74,18 @@ export const NavPage = React.memo(function NavPage({ page, pathname, layout, onP
|
|
|
86
74
|
<i className={`${getIconClass(page.icon)} text-[14px] flex-shrink-0 opacity-60`} aria-hidden="true" />
|
|
87
75
|
)}
|
|
88
76
|
<span className="flex-1 min-w-0">{page.title}</span>
|
|
89
|
-
{page.tag && (
|
|
77
|
+
{page.tag && !showSpinner && (
|
|
90
78
|
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-[var(--color-accent)]/15 text-[var(--color-accent)]">
|
|
91
79
|
{page.tag}
|
|
92
80
|
</span>
|
|
93
81
|
)}
|
|
82
|
+
{showSpinner && (
|
|
83
|
+
<span
|
|
84
|
+
data-nav-spinner
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
className="inline-block h-3 w-3 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin motion-reduce:animate-pulse motion-reduce:[animation-duration:1.5s] flex-shrink-0"
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
94
89
|
</Link>
|
|
95
90
|
</li>
|
|
96
91
|
);
|
|
@@ -145,30 +140,20 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
145
140
|
const [logoError, setLogoError] = useState(false);
|
|
146
141
|
const [isDark, setIsDark] = useState(false);
|
|
147
142
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
148
|
-
const [pendingPathname, setPendingPathname] = useState<string | null>(null);
|
|
149
143
|
const sidebarRef = useRef<HTMLElement>(null);
|
|
150
144
|
const linkPrefix = useLinkPrefix();
|
|
151
145
|
|
|
152
146
|
// Chat panel state — used for Pulsar sidebar AI button
|
|
153
147
|
const { onChatOpen, devNotice } = useChatOpenHandler();
|
|
154
148
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
useEffect(() => {
|
|
162
|
-
setPendingPathname(null);
|
|
163
|
-
}, [pathname]);
|
|
149
|
+
// Two-stage feedback for nav clicks:
|
|
150
|
+
// pendingPathname → optimistic active highlight (instant)
|
|
151
|
+
// spinnerPathname → small spinner on the row (after 500ms, only if nav is slow)
|
|
152
|
+
const { pendingPathname, spinnerPathname, onNavigate: handleNavigate } =
|
|
153
|
+
useDelayedNavigationSpinner(pathname);
|
|
164
154
|
|
|
165
155
|
const effectivePathname = pendingPathname || pathname;
|
|
166
156
|
|
|
167
|
-
// Stable callback for prefetching pages on hover
|
|
168
|
-
const handlePrefetch = useCallback((path: string) => {
|
|
169
|
-
router.prefetch(`${linkPrefix}/${path}`);
|
|
170
|
-
}, [router, linkPrefix]);
|
|
171
|
-
|
|
172
157
|
// Determine effective tabsPosition:
|
|
173
158
|
// 1. Use config.tabsPosition if specified
|
|
174
159
|
// 2. Otherwise use prop if specified
|
|
@@ -311,6 +296,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
311
296
|
// Render a navigation group (supports nesting)
|
|
312
297
|
function renderGroup(group: ResolvedGroup, level: number = 0) {
|
|
313
298
|
const isExpanded = expandedGroups.has(group.name);
|
|
299
|
+
// expandedGroups starts empty and is populated in a useEffect, so the very
|
|
300
|
+
// first render has shouldPrefetch=false for every L0 section; Next.js
|
|
301
|
+
// re-schedules the prefetch when the prop flips to true on the next commit.
|
|
302
|
+
const shouldPrefetch = level !== 0 || isExpanded || !group.name;
|
|
314
303
|
|
|
315
304
|
// Skip rendering if group has no name and no content
|
|
316
305
|
if (!group.name && group.pages.length === 0 && !group.nested?.length && !group.items?.length) {
|
|
@@ -376,9 +365,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
376
365
|
page={p.page}
|
|
377
366
|
pathname={activePathname}
|
|
378
367
|
layout={layout}
|
|
379
|
-
onPrefetch={handlePrefetch}
|
|
380
368
|
onNavigate={handleNavigate}
|
|
381
369
|
linkPrefix={linkPrefix}
|
|
370
|
+
prefetch={shouldPrefetch}
|
|
371
|
+
showSpinner={spinnerPathname === `${linkPrefix}/${p.page.path}`}
|
|
382
372
|
/>
|
|
383
373
|
))}
|
|
384
374
|
</ul>
|
|
@@ -415,9 +405,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
415
405
|
page={page}
|
|
416
406
|
pathname={activePathname}
|
|
417
407
|
layout={layout}
|
|
418
|
-
onPrefetch={handlePrefetch}
|
|
419
408
|
onNavigate={handleNavigate}
|
|
420
409
|
linkPrefix={linkPrefix}
|
|
410
|
+
prefetch={shouldPrefetch}
|
|
411
|
+
showSpinner={spinnerPathname === `${linkPrefix}/${page.path}`}
|
|
421
412
|
/>
|
|
422
413
|
))}
|
|
423
414
|
</ul>
|
|
@@ -163,7 +163,7 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
|
|
|
163
163
|
<Link
|
|
164
164
|
key={tab.name}
|
|
165
165
|
href={tab.path}
|
|
166
|
-
prefetch={
|
|
166
|
+
prefetch={true}
|
|
167
167
|
className={`nav-tab-link flex items-center gap-2 px-3 py-1.5 text-sm transition-colors
|
|
168
168
|
${tab.isActive
|
|
169
169
|
? 'text-[var(--color-primary)] font-semibold bg-[var(--color-bg-tertiary)]'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
|
+
import { createTextStreamPacer, type TextStreamPacer } from './useTextStreamPacer';
|
|
5
|
+
import { useMediaQuery } from './useMediaQuery';
|
|
4
6
|
|
|
5
7
|
export interface Citation {
|
|
6
8
|
title: string;
|
|
@@ -80,6 +82,18 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
80
82
|
messagesRef.current = messages;
|
|
81
83
|
}, [messages]);
|
|
82
84
|
|
|
85
|
+
/** Currently-revealing pacer, if any. Used to abort on unmount so we never
|
|
86
|
+
* call setMessages after the component has torn down. */
|
|
87
|
+
const activePacerRef = useRef<TextStreamPacer | null>(null);
|
|
88
|
+
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
return () => {
|
|
92
|
+
activePacerRef.current?.abort();
|
|
93
|
+
activePacerRef.current = null;
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
83
97
|
// Persist messages to sessionStorage — debounced to avoid thrashing during streaming
|
|
84
98
|
useEffect(() => {
|
|
85
99
|
const timer = setTimeout(() => saveMessages(messages), 500);
|
|
@@ -93,45 +107,77 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
93
107
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
108
|
}, []);
|
|
95
109
|
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
const
|
|
99
|
-
setMessages((prev) => {
|
|
100
|
-
const msg = prev.find((m) => m.id === assistantId);
|
|
101
|
-
if (msg && !msg.content) {
|
|
102
|
-
return prev.filter((m) => m.id !== assistantId);
|
|
103
|
-
}
|
|
104
|
-
return prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m));
|
|
105
|
-
});
|
|
110
|
+
/** Unlock the input: the request is done (or errored out). The assistant
|
|
111
|
+
* message may still be revealing — that's handled by `markRevealComplete`. */
|
|
112
|
+
const unlockInput = useCallback((): void => {
|
|
106
113
|
setIsLoading(false);
|
|
107
114
|
isLoadingRef.current = false;
|
|
108
115
|
abortControllerRef.current = null;
|
|
109
116
|
}, []);
|
|
110
117
|
|
|
118
|
+
/** Reveal finished — remove empty bubbles, clear the streaming indicator,
|
|
119
|
+
* and apply any citations/clarifications that were buffered during pacing. */
|
|
120
|
+
const markRevealComplete = useCallback(
|
|
121
|
+
(
|
|
122
|
+
assistantId: string,
|
|
123
|
+
pending: { citations?: Citation[]; clarificationOptions?: string[] },
|
|
124
|
+
): void => {
|
|
125
|
+
setMessages((prev) => {
|
|
126
|
+
const idx = prev.findIndex((m) => m.id === assistantId);
|
|
127
|
+
if (idx === -1) return prev;
|
|
128
|
+
const msg = prev[idx];
|
|
129
|
+
if (!msg.content) {
|
|
130
|
+
// Empty bubble — drop it entirely.
|
|
131
|
+
return prev.filter((m) => m.id !== assistantId);
|
|
132
|
+
}
|
|
133
|
+
const next = prev.slice();
|
|
134
|
+
next[idx] = {
|
|
135
|
+
...msg,
|
|
136
|
+
isStreaming: false,
|
|
137
|
+
...(pending.citations ? { citations: pending.citations } : {}),
|
|
138
|
+
...(pending.clarificationOptions
|
|
139
|
+
? { clarificationOptions: pending.clarificationOptions }
|
|
140
|
+
: {}),
|
|
141
|
+
};
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
|
|
111
148
|
const processStream = useCallback(
|
|
112
149
|
async (response: Response, assistantId: string) => {
|
|
113
150
|
const reader = response.body!.getReader();
|
|
114
151
|
const decoder = new TextDecoder();
|
|
115
152
|
let buffer = '';
|
|
116
153
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
154
|
+
// Citations + clarifications arrive instantly from the server but we
|
|
155
|
+
// want them to appear only after the prose is finished revealing —
|
|
156
|
+
// otherwise the badges/buttons render above text that hasn't been typed.
|
|
157
|
+
const pending: { citations?: Citation[]; clarificationOptions?: string[] } = {};
|
|
158
|
+
|
|
159
|
+
const pacer = createTextStreamPacer({
|
|
160
|
+
reducedMotion,
|
|
161
|
+
// Haiku delivers the whole answer in ~50ms so the full buffer lands
|
|
162
|
+
// before the first RAF. The default 800-char flush would dump typical
|
|
163
|
+
// responses in one frame. Cap at 4000 instead — pace under that, dump
|
|
164
|
+
// above so very long answers don't take 10s+ to reveal.
|
|
165
|
+
instantFlushThreshold: 4000,
|
|
166
|
+
onReveal: (chunk) => {
|
|
167
|
+
setMessages((prev) => {
|
|
168
|
+
const idx = prev.findIndex((m) => m.id === assistantId);
|
|
169
|
+
if (idx === -1) return prev;
|
|
170
|
+
const next = prev.slice();
|
|
171
|
+
next[idx] = { ...next[idx], content: next[idx].content + chunk };
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
onDrain: () => {
|
|
176
|
+
markRevealComplete(assistantId, pending);
|
|
177
|
+
if (activePacerRef.current === pacer) activePacerRef.current = null;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
activePacerRef.current = pacer;
|
|
135
181
|
|
|
136
182
|
try {
|
|
137
183
|
while (true) {
|
|
@@ -146,7 +192,13 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
146
192
|
const trimmed = line.trim();
|
|
147
193
|
if (!trimmed.startsWith('data: ')) continue;
|
|
148
194
|
|
|
149
|
-
let event: {
|
|
195
|
+
let event: {
|
|
196
|
+
type: string;
|
|
197
|
+
content?: string;
|
|
198
|
+
message?: string;
|
|
199
|
+
sources?: Citation[];
|
|
200
|
+
options?: string[];
|
|
201
|
+
};
|
|
150
202
|
try {
|
|
151
203
|
event = JSON.parse(trimmed.slice(6));
|
|
152
204
|
} catch {
|
|
@@ -155,53 +207,49 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
155
207
|
|
|
156
208
|
switch (event.type) {
|
|
157
209
|
case 'text':
|
|
158
|
-
|
|
159
|
-
if (rafId === null) {
|
|
160
|
-
rafId = requestAnimationFrame(flushText);
|
|
161
|
-
}
|
|
210
|
+
pacer.enqueue(event.content || '');
|
|
162
211
|
break;
|
|
163
212
|
|
|
164
213
|
case 'citations':
|
|
165
|
-
|
|
166
|
-
prev.map((msg) =>
|
|
167
|
-
msg.id === assistantId ? { ...msg, citations: event.sources } : msg,
|
|
168
|
-
),
|
|
169
|
-
);
|
|
214
|
+
pending.citations = event.sources;
|
|
170
215
|
break;
|
|
171
216
|
|
|
172
217
|
case 'clarification':
|
|
173
|
-
|
|
174
|
-
prev.map((msg) =>
|
|
175
|
-
msg.id === assistantId
|
|
176
|
-
? { ...msg, clarificationOptions: event.options as string[] }
|
|
177
|
-
: msg,
|
|
178
|
-
),
|
|
179
|
-
);
|
|
218
|
+
pending.clarificationOptions = event.options as string[];
|
|
180
219
|
break;
|
|
181
220
|
|
|
182
221
|
case 'done':
|
|
183
|
-
//
|
|
222
|
+
// Server is done — unlock the input NOW so the user isn't
|
|
223
|
+
// blocked while text finishes revealing.
|
|
224
|
+
unlockInput();
|
|
184
225
|
break;
|
|
185
226
|
|
|
186
227
|
case 'error':
|
|
187
228
|
setError(event.message || 'An error occurred');
|
|
229
|
+
// SSE error means the server is done — unlock the input so
|
|
230
|
+
// the user can retry. (No 'done' event follows on error paths.)
|
|
231
|
+
unlockInput();
|
|
188
232
|
break;
|
|
189
233
|
}
|
|
190
234
|
}
|
|
191
235
|
}
|
|
236
|
+
pacer.finish();
|
|
237
|
+
// NOTE: We do NOT await drain here. `unlockInput()` already ran on
|
|
238
|
+
// SSE 'done'; `markRevealComplete` runs from the pacer's onDrain.
|
|
239
|
+
// sendMessage resolves as soon as this function returns.
|
|
192
240
|
} catch (err) {
|
|
241
|
+
pacer.abort();
|
|
242
|
+
if (activePacerRef.current === pacer) activePacerRef.current = null;
|
|
243
|
+
// Error path: input is unlocked here (may not have hit 'done' SSE event).
|
|
244
|
+
unlockInput();
|
|
245
|
+
// Drop the empty assistant bubble and clear the streaming flag.
|
|
246
|
+
markRevealComplete(assistantId, pending);
|
|
193
247
|
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
|
194
248
|
setError('The response was interrupted. Please try again.');
|
|
195
249
|
}
|
|
196
|
-
} finally {
|
|
197
|
-
// Flush any remaining batched text before finishing
|
|
198
|
-
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
199
|
-
flushText();
|
|
200
|
-
// Unified cleanup: removes empty bubbles, marks streaming done, resets loading
|
|
201
|
-
finishLoading(assistantId);
|
|
202
250
|
}
|
|
203
251
|
},
|
|
204
|
-
[
|
|
252
|
+
[unlockInput, markRevealComplete, reducedMotion],
|
|
205
253
|
);
|
|
206
254
|
|
|
207
255
|
const sendMessage = useCallback(
|
|
@@ -238,9 +286,10 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
238
286
|
assistantMessage,
|
|
239
287
|
]);
|
|
240
288
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
289
|
+
// Prior turns only — the server appends the current `message` itself.
|
|
290
|
+
// Including userMessage here caused Claude to see the same user turn
|
|
291
|
+
// twice and hedge with "I don't have information…".
|
|
292
|
+
const history = messagesRef.current.slice(-10).map((msg) => ({
|
|
244
293
|
role: msg.role,
|
|
245
294
|
content: msg.content,
|
|
246
295
|
}));
|
|
@@ -271,7 +320,8 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
271
320
|
errorMessage = 'AI chat is temporarily unavailable. Please try again later.';
|
|
272
321
|
}
|
|
273
322
|
setError(errorMessage);
|
|
274
|
-
|
|
323
|
+
unlockInput();
|
|
324
|
+
markRevealComplete(assistantMessage.id, {});
|
|
275
325
|
return;
|
|
276
326
|
}
|
|
277
327
|
|
|
@@ -280,15 +330,18 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
280
330
|
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
|
281
331
|
setError('Unable to connect. Check your internet connection and try again.');
|
|
282
332
|
}
|
|
283
|
-
|
|
333
|
+
unlockInput();
|
|
334
|
+
markRevealComplete(assistantMessage.id, {});
|
|
284
335
|
}
|
|
285
336
|
},
|
|
286
|
-
[endpoint, processStream,
|
|
337
|
+
[endpoint, processStream, unlockInput, markRevealComplete],
|
|
287
338
|
);
|
|
288
339
|
|
|
289
340
|
const abort = useCallback(() => {
|
|
290
|
-
if (!isLoadingRef.current) return;
|
|
341
|
+
if (!isLoadingRef.current && !activePacerRef.current) return;
|
|
291
342
|
abortControllerRef.current?.abort();
|
|
343
|
+
activePacerRef.current?.abort();
|
|
344
|
+
activePacerRef.current = null;
|
|
292
345
|
}, []);
|
|
293
346
|
|
|
294
347
|
const retry = useCallback(() => {
|