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.
@@ -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="Toggle menu"
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="Search documentation"
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>Search</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="Ask AI"
319
- title="Ask AI (⌘I)"
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>Ask AI</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={false}
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>More</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={false}
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="Search"
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="Ask AI"
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 || (config.navbar.primary.type === 'github' ? 'GitHub' : 'Get Started')}</span>
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="Search"
513
- title="Search (⌘K)"
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="Ask AI"
522
- title="Ask AI (⌘I)"
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={`Select language, current: ${currentLanguage?.displayName}`}
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="Select language"
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
- DocsConfig,
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, onPrefetch, onNavigate, linkPrefix = '' }: NavPageProps) {
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={false}
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
- // Optimistic navigation: immediately highlight clicked link while page loads.
156
- // Strips hash fragments so anchor links (e.g. /page#section) match pathname.
157
- const handleNavigate = useCallback((url: string) => {
158
- setPendingPathname(url.split('#')[0]);
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={false}
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
- /** Mark an assistant message as done streaming and reset loading state.
97
- * If the assistant message has no content, remove it to avoid an empty bubble. */
98
- const finishLoading = useCallback((assistantId: string): void => {
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
- // Batch text chunks accumulate in ref, flush to state once per animation frame.
118
- // This reduces React re-renders from ~100/sec (per SSE chunk) to ~60/sec (per frame).
119
- let pendingText = '';
120
- let rafId: ReturnType<typeof requestAnimationFrame> | null = null;
121
-
122
- const flushText = () => {
123
- if (!pendingText) return;
124
- const text = pendingText;
125
- pendingText = '';
126
- rafId = null;
127
- setMessages((prev) =>
128
- prev.map((msg) =>
129
- msg.id === assistantId
130
- ? { ...msg, content: msg.content + text }
131
- : msg,
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: { type: string; content?: string; message?: string; sources?: Citation[]; options?: string[] };
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
- pendingText += event.content || '';
159
- if (rafId === null) {
160
- rafId = requestAnimationFrame(flushText);
161
- }
210
+ pacer.enqueue(event.content || '');
162
211
  break;
163
212
 
164
213
  case 'citations':
165
- setMessages((prev) =>
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
- setMessages((prev) =>
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
- // finishLoading in `finally` handles cleanup
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
- [finishLoading],
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
- // Use messagesRef to avoid stale closure ref always has current state
242
- const currentMessages = [...messagesRef.current, userMessage];
243
- const history = currentMessages.slice(-10).map((msg) => ({
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
- finishLoading(assistantMessage.id);
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
- finishLoading(assistantMessage.id);
333
+ unlockInput();
334
+ markRevealComplete(assistantMessage.id, {});
284
335
  }
285
336
  },
286
- [endpoint, processStream, finishLoading],
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(() => {