@xenide-io/the-old-ui-theme 0.6.2 → 0.6.5

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.
@@ -5,6 +5,7 @@ import {
5
5
  Suspense,
6
6
  useCallback,
7
7
  useEffect,
8
+ useLayoutEffect,
8
9
  useRef,
9
10
  useState,
10
11
  type ComponentType,
@@ -12,7 +13,17 @@ import {
12
13
  type PointerEvent as ReactPointerEvent,
13
14
  type ReactNode,
14
15
  } from 'react';
15
- import { Check, Copy, Sparks, Xmark as X } from 'iconoir-react';
16
+ import {
17
+ ArrowDown,
18
+ ArrowUp,
19
+ Check,
20
+ Copy,
21
+ Erase,
22
+ Sparks,
23
+ Square,
24
+ WarningTriangle,
25
+ Xmark as X,
26
+ } from 'iconoir-react';
16
27
 
17
28
  const SuiteAiBlockNoteMessage = lazy(() => import('./ai-message-blocknote'));
18
29
 
@@ -140,6 +151,7 @@ export function SuiteAiPanel({
140
151
  }>;
141
152
  sendMessage: (params: {
142
153
  prompt: string;
154
+ signal?: AbortSignal;
143
155
  }) => Promise<{ messages: SuiteAiChatMessage[]; reply: string }>;
144
156
  clearChat: () => Promise<void>;
145
157
  brandIcon?: ComponentType<{ className?: string }>;
@@ -154,12 +166,15 @@ export function SuiteAiPanel({
154
166
  const [hydrating, setHydrating] = useState(false);
155
167
  const [error, setError] = useState('');
156
168
  const [copiedId, setCopiedId] = useState<number | null>(null);
169
+ const [atBottom, setAtBottom] = useState(true);
157
170
  const [launcherSide, setLauncherSide] = useState<LauncherEdge>('right');
158
171
  const [panelSide, setPanelSide] = useState<'left' | 'right'>('right');
159
172
  const [launcherPosition, setLauncherPosition] =
160
173
  useState<LauncherPosition | null>(null);
161
174
  const [draggingLauncher, setDraggingLauncher] = useState(false);
162
175
  const scrollerRef = useRef<HTMLDivElement>(null);
176
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
177
+ const abortRef = useRef<AbortController | null>(null);
163
178
  const launcherRef = useRef<HTMLButtonElement>(null);
164
179
  const launcherSideRef = useRef<LauncherEdge>('right');
165
180
  const launcherPositionRef = useRef<LauncherPosition | null>(null);
@@ -232,10 +247,20 @@ export function SuiteAiPanel({
232
247
  return () => window.removeEventListener('resize', placeLauncher);
233
248
  }, []);
234
249
 
235
- const scrollToBottom = useCallback(() => {
250
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
251
+ const el = scrollerRef.current;
252
+ if (!el) return;
253
+ el.scrollTo({ top: el.scrollHeight, behavior });
254
+ setAtBottom(true);
255
+ }, []);
256
+
257
+ const handleScroll = useCallback(() => {
236
258
  const el = scrollerRef.current;
237
259
  if (!el) return;
238
- el.scrollTop = el.scrollHeight;
260
+ // Within 64px of the bottom counts as "pinned" — matches the
261
+ // stick-to-bottom threshold most AI chats use for streaming reflow.
262
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
263
+ setAtBottom(distance < 64);
239
264
  }, []);
240
265
 
241
266
  const hydrate = useCallback(async () => {
@@ -282,21 +307,54 @@ export function SuiteAiPanel({
282
307
  }, [open, hydrate]);
283
308
 
284
309
  useEffect(() => {
285
- if (open) scrollToBottom();
286
- }, [messages, loading, open, scrollToBottom]);
310
+ if (open && atBottom) scrollToBottom();
311
+ }, [messages, loading, open, atBottom, scrollToBottom]);
312
+
313
+ // Focus the composer when the panel opens (but never steal focus mid-stream).
314
+ useEffect(() => {
315
+ if (open && !hydrating) textareaRef.current?.focus();
316
+ }, [open, hydrating]);
317
+
318
+ // Close on Escape while the panel is open.
319
+ useEffect(() => {
320
+ if (!open) return;
321
+ function onKey(event: KeyboardEvent) {
322
+ if (event.key === 'Escape') setOpen(false);
323
+ }
324
+ window.addEventListener('keydown', onKey);
325
+ return () => window.removeEventListener('keydown', onKey);
326
+ }, [open]);
327
+
328
+ // Auto-grow the composer with content, up to a cap, then scroll.
329
+ useLayoutEffect(() => {
330
+ const el = textareaRef.current;
331
+ if (!el) return;
332
+ el.style.height = 'auto';
333
+ el.style.height = `${Math.min(el.scrollHeight, 176)}px`;
334
+ }, [prompt, open]);
287
335
 
288
336
  async function ask(text: string) {
289
337
  const trimmed = text.trim();
290
338
  if (!trimmed || loading) return;
339
+ const controller = new AbortController();
340
+ abortRef.current = controller;
291
341
  setLoading(true);
292
342
  setError('');
293
343
  setPrompt('');
294
- // Optimistic user bubble — replaced by server messages on success.
344
+ setAtBottom(true);
345
+ // Optimistic user message — replaced by server messages on success.
295
346
  setMessages((prev) => [...prev, { role: 'user', content: trimmed }]);
296
347
  try {
297
- const result = await sendMessage({ prompt: trimmed });
348
+ const result = await sendMessage({
349
+ prompt: trimmed,
350
+ signal: controller.signal,
351
+ });
352
+ if (controller.signal.aborted) return;
298
353
  setMessages(result.messages ?? []);
299
354
  } catch (err) {
355
+ // A user-initiated stop is not an error — leave the optimistic
356
+ // message in place and let them retry.
357
+ if (controller.signal.aborted) return;
300
358
  setMessages((prev) =>
301
359
  prev[prev.length - 1]?.role === 'user' &&
302
360
  prev[prev.length - 1]?.content === trimmed
@@ -310,10 +368,26 @@ export function SuiteAiPanel({
310
368
  : 'The AI request failed. Try again.',
311
369
  );
312
370
  } finally {
371
+ if (abortRef.current === controller) abortRef.current = null;
313
372
  setLoading(false);
314
373
  }
315
374
  }
316
375
 
376
+ function stop() {
377
+ abortRef.current?.abort();
378
+ abortRef.current = null;
379
+ setLoading(false);
380
+ }
381
+
382
+ function retry() {
383
+ const lastUser = [...messages]
384
+ .reverse()
385
+ .find((m) => m.role === 'user')?.content;
386
+ setError('');
387
+ if (lastUser) void ask(lastUser);
388
+ else void hydrate();
389
+ }
390
+
317
391
  async function handleClear() {
318
392
  if (loading) return;
319
393
  try {
@@ -503,174 +577,269 @@ export function SuiteAiPanel({
503
577
  ) : null}
504
578
 
505
579
  {open ? (
506
- <div className="fixed inset-0 z-40" role="dialog" aria-modal="true">
580
+ <div
581
+ className="fixed inset-0 z-40"
582
+ role="dialog"
583
+ aria-modal="true"
584
+ aria-label={title}
585
+ >
507
586
  <button
508
587
  type="button"
509
- className="absolute inset-0 bg-black/25"
588
+ className="absolute inset-0 bg-ph-ink/25"
510
589
  onClick={() => setOpen(false)}
511
590
  aria-label="Close Ask AI"
591
+ tabIndex={-1}
512
592
  />
513
593
  <aside
514
- className={`absolute inset-y-0 flex w-full max-w-md flex-col bg-ph-surface shadow-2xl ${panelSide === 'left' ? 'left-0 border-r border-ph-border' : 'right-0 border-l border-ph-border'}`}
594
+ className={`absolute inset-y-0 flex w-full flex-col bg-ph-canvas shadow-2xl sm:max-w-xl ${panelSide === 'left' ? 'left-0 border-r border-ph-border' : 'right-0 border-l border-ph-border'}`}
515
595
  data-test="suite-ask-ai-panel"
516
596
  >
517
- <div className="flex items-center justify-between border-b border-ph-border px-4 py-3">
518
- <div className="min-w-0">
519
- <div className="flex items-center gap-2">
520
- <Icon className="h-4 w-4 shrink-0 text-ph-brand" />
521
- <span className="text-sm font-semibold text-ph-ink">
522
- {title}
523
- </span>
524
- </div>
525
- <p className="mt-0.5 text-[11px] text-ph-mutedtext">
526
- Shared across your suite · same chat in every app
527
- </p>
528
- </div>
529
- <div className="flex items-center gap-1">
530
- {messages.length > 0 ? (
531
- <button
532
- type="button"
533
- onClick={() => void handleClear()}
534
- disabled={loading}
535
- className="rounded-md px-2 py-1 text-xs text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink disabled:opacity-50"
536
- data-test="ask-ai-clear"
537
- >
538
- Clear
539
- </button>
540
- ) : null}
597
+ {/* ── Header ─────────────────────────────────────────────── */}
598
+ <header className="flex items-center gap-2 px-4 py-3">
599
+ <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-ph-ink">
600
+ {title}
601
+ </h2>
602
+ {messages.length > 0 ? (
541
603
  <button
542
604
  type="button"
543
- onClick={() => setOpen(false)}
544
- className="rounded p-1 hover:bg-ph-muted"
545
- aria-label="Close"
605
+ onClick={() => void handleClear()}
606
+ disabled={loading}
607
+ className="inline-flex h-10 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
608
+ data-test="ask-ai-clear"
546
609
  >
547
- <X className="h-4 w-4" />
610
+ <Erase className="h-3.5 w-3.5" />
611
+ Clear
548
612
  </button>
549
- </div>
550
- </div>
551
-
552
- {presets.length > 0 && messages.length === 0 ? (
553
- <div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
554
- {presets.map((preset) => (
555
- <button
556
- key={preset.label}
557
- type="button"
558
- onClick={() => void ask(preset.prompt)}
559
- disabled={loading || hydrating}
560
- className="rounded-full border border-ph-border px-2.5 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
561
- >
562
- {preset.label}
563
- </button>
564
- ))}
565
- </div>
566
- ) : null}
567
-
568
- <div
569
- ref={scrollerRef}
570
- className="flex-1 space-y-3 overflow-y-auto p-4"
571
- >
572
- {error ? (
573
- <p className="bg-ph-danger/10 rounded-lg px-3 py-2 text-sm text-ph-danger">
574
- {error}
575
- </p>
576
613
  ) : null}
614
+ <button
615
+ type="button"
616
+ onClick={() => setOpen(false)}
617
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus"
618
+ aria-label="Close Ask AI"
619
+ >
620
+ <X className="h-4 w-4" />
621
+ </button>
622
+ </header>
623
+
624
+ {/* ── Message stream ─────────────────────────────────────── */}
625
+ <div className="relative min-h-0 flex-1">
626
+ <div
627
+ ref={scrollerRef}
628
+ onScroll={handleScroll}
629
+ role="log"
630
+ aria-live="polite"
631
+ aria-label="Conversation"
632
+ className="suite-scroll-lock h-full space-y-5 overflow-y-auto px-4 py-5"
633
+ >
634
+ {hydrating && messages.length === 0 ? (
635
+ <div className="flex items-center gap-2 text-sm text-ph-subtle">
636
+ <Spinner className="h-4 w-4 animate-spin" />
637
+ Loading your chat…
638
+ </div>
639
+ ) : null}
577
640
 
578
- {hydrating && messages.length === 0 ? (
579
- <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
580
- <Spinner className="h-4 w-4 animate-spin" />
581
- Loading chat…
582
- </div>
583
- ) : null}
641
+ {!hydrating && messages.length === 0 && !error ? (
642
+ <div className="mx-auto mt-6 flex max-w-sm flex-col items-center px-2 text-center">
643
+ <span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-ph-brand/10 text-ph-brand">
644
+ <Icon className="h-6 w-6" />
645
+ </span>
646
+ <p className="mt-4 font-display text-lg font-semibold text-ph-ink">
647
+ How can I help?
648
+ </p>
649
+ <p className="mt-1.5 text-sm leading-relaxed text-ph-subtle">
650
+ {emptyState}
651
+ </p>
652
+ {presets.length > 0 ? (
653
+ <div className="mt-5 flex w-full flex-col gap-2">
654
+ {presets.map((preset) => (
655
+ <button
656
+ key={preset.label}
657
+ type="button"
658
+ onClick={() => void ask(preset.prompt)}
659
+ disabled={loading || hydrating}
660
+ className="group flex w-full items-center gap-2 rounded-xl border border-ph-border bg-ph-surface px-3.5 py-2.5 text-left text-sm text-ph-ink transition-colors hover:border-ph-brand/40 hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
661
+ >
662
+ <Sparks className="h-4 w-4 shrink-0 text-ph-brand" />
663
+ <span className="min-w-0 flex-1 truncate">
664
+ {preset.label}
665
+ </span>
666
+ <ArrowUp className="h-3.5 w-3.5 shrink-0 rotate-45 text-ph-mutedtext transition-transform group-hover:translate-x-0.5" />
667
+ </button>
668
+ ))}
669
+ </div>
670
+ ) : null}
671
+ </div>
672
+ ) : null}
584
673
 
585
- {!hydrating && messages.length === 0 && !error ? (
586
- <div className="mt-10 px-2 text-center">
587
- <p className="font-display text-base font-semibold text-ph-ink">
588
- How can I help?
589
- </p>
590
- <p className="mt-2 text-sm leading-relaxed text-ph-subtle">
591
- {emptyState}
592
- </p>
593
- </div>
594
- ) : null}
674
+ {messages.map((message, index) => {
675
+ const isUser = message.role === 'user';
676
+ if (isUser) {
677
+ return (
678
+ <div
679
+ key={`user-${index}`}
680
+ className="flex justify-end"
681
+ data-test="ask-ai-user"
682
+ >
683
+ <div className="max-w-[min(85%,42rem)] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-ph-brand/12 px-3.5 py-2.5 text-sm leading-relaxed text-ph-ink">
684
+ {message.content}
685
+ </div>
686
+ </div>
687
+ );
688
+ }
689
+ return (
690
+ <div
691
+ key={`assistant-${index}`}
692
+ className="flex gap-3"
693
+ data-test="ask-ai-assistant"
694
+ >
695
+ <span
696
+ className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand"
697
+ aria-hidden
698
+ >
699
+ <Icon className="h-4 w-4" />
700
+ </span>
701
+ <div className="min-w-0 flex-1">
702
+ <div className="text-sm leading-relaxed text-ph-ink">
703
+ <Suspense
704
+ fallback={
705
+ <p className="whitespace-pre-wrap break-words">
706
+ {message.content}
707
+ </p>
708
+ }
709
+ >
710
+ <SuiteAiBlockNoteMessage
711
+ markdown={message.content}
712
+ />
713
+ </Suspense>
714
+ </div>
715
+ <button
716
+ type="button"
717
+ onClick={() =>
718
+ void copyMessage(index, message.content)
719
+ }
720
+ className="mt-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus"
721
+ >
722
+ {copiedId === index ? (
723
+ <Check className="h-3 w-3" />
724
+ ) : (
725
+ <Copy className="h-3 w-3" />
726
+ )}
727
+ {copiedId === index ? 'Copied' : 'Copy'}
728
+ </button>
729
+ </div>
730
+ </div>
731
+ );
732
+ })}
733
+
734
+ {loading ? (
735
+ <div className="flex gap-3" aria-hidden>
736
+ <span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand">
737
+ <Icon className="h-4 w-4" />
738
+ </span>
739
+ <div className="flex items-center gap-1.5 py-1.5">
740
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce" />
741
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.15s]" />
742
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.3s]" />
743
+ </div>
744
+ </div>
745
+ ) : null}
595
746
 
596
- {messages.map((message, index) => {
597
- const isUser = message.role === 'user';
598
- return (
747
+ {loading ? (
748
+ <span className="sr-only" aria-live="polite">
749
+ Assistant is responding…
750
+ </span>
751
+ ) : null}
752
+
753
+ {error ? (
599
754
  <div
600
- key={`${message.role}-${index}-${message.content.slice(0, 24)}`}
601
- className={
602
- isUser
603
- ? 'ml-8 rounded-2xl bg-ph-brand/10 px-3.5 py-2.5 text-sm text-ph-ink'
604
- : 'mr-4 rounded-2xl bg-ph-muted px-3.5 py-2.5 text-sm text-ph-ink'
605
- }
606
- data-test={isUser ? 'ask-ai-user' : 'ask-ai-assistant'}
755
+ role="alert"
756
+ className="flex items-start gap-2.5 rounded-xl border border-ph-danger/30 bg-ph-danger/10 px-3.5 py-3 text-sm text-ph-ink"
607
757
  >
608
- {isUser ? (
609
- <p className="whitespace-pre-wrap">{message.content}</p>
610
- ) : (
611
- <Suspense
612
- fallback={
613
- <p className="whitespace-pre-wrap">
614
- {message.content}
615
- </p>
616
- }
617
- >
618
- <SuiteAiBlockNoteMessage markdown={message.content} />
619
- </Suspense>
620
- )}
621
- {!isUser ? (
758
+ <WarningTriangle className="mt-0.5 h-4 w-4 shrink-0 text-ph-danger" />
759
+ <div className="min-w-0 flex-1">
760
+ <p className="font-medium text-ph-danger">
761
+ Something went wrong
762
+ </p>
763
+ <p className="mt-0.5 text-ph-subtle">{error}</p>
622
764
  <button
623
765
  type="button"
624
- onClick={() => void copyMessage(index, message.content)}
625
- className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
766
+ onClick={retry}
767
+ disabled={loading}
768
+ className="mt-2 inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-ph-border bg-ph-surface px-2.5 text-xs font-medium text-ph-ink transition-colors hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
626
769
  >
627
- {copiedId === index ? (
628
- <Check className="h-3 w-3" />
629
- ) : (
630
- <Copy className="h-3 w-3" />
631
- )}
632
- {copiedId === index ? 'Copied' : 'Copy'}
770
+ Try again
633
771
  </button>
634
- ) : null}
772
+ </div>
635
773
  </div>
636
- );
637
- })}
638
-
639
- {loading ? (
640
- <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
641
- <Spinner className="h-4 w-4 animate-spin" />
642
- Thinking…
643
- </div>
774
+ ) : null}
775
+ </div>
776
+
777
+ {!atBottom ? (
778
+ <button
779
+ type="button"
780
+ onClick={() => scrollToBottom('smooth')}
781
+ className="absolute bottom-3 left-1/2 flex h-9 w-9 -translate-x-1/2 items-center justify-center rounded-full border border-ph-border bg-ph-surface text-ph-subtle shadow-ph-md transition-colors hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus motion-safe:animate-[suite-ai-fade_150ms_ease-out]"
782
+ aria-label="Scroll to latest"
783
+ >
784
+ <ArrowDown className="h-4 w-4" />
785
+ </button>
644
786
  ) : null}
645
787
  </div>
646
788
 
647
- <div className="border-t border-ph-border p-3">
648
- <div className="flex gap-2">
789
+ {/* ── Composer ───────────────────────────────────────────── */}
790
+ <div className="px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-1">
791
+ <div className="flex items-end gap-2 rounded-3xl border border-ph-border bg-ph-surface px-2 py-1 shadow-sm transition-colors focus-within:border-ph-brand/45 focus-within:ring-2 focus-within:ring-ph-focus/35">
792
+ <label htmlFor="suite-ask-ai-input" className="sr-only">
793
+ Ask a question
794
+ </label>
649
795
  <textarea
796
+ id="suite-ask-ai-input"
797
+ ref={textareaRef}
650
798
  value={prompt}
651
799
  onChange={(e) => setPrompt(e.target.value)}
652
800
  onKeyDown={(e) => {
653
- if (e.key === 'Enter' && !e.shiftKey) {
801
+ if (
802
+ e.key === 'Enter' &&
803
+ !e.shiftKey &&
804
+ !e.nativeEvent.isComposing
805
+ ) {
654
806
  e.preventDefault();
655
807
  void ask(prompt);
656
808
  }
657
809
  }}
658
810
  placeholder="Ask anything…"
659
- rows={2}
660
- disabled={loading}
811
+ rows={1}
661
812
  data-test="ask-ai-input"
662
- className="flex-1 resize-none rounded-xl border border-ph-border bg-ph-canvas p-2.5 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
813
+ className="max-h-44 min-h-[44px] flex-1 resize-none bg-transparent px-2 py-2.5 text-sm leading-relaxed text-ph-ink outline-none placeholder:text-ph-mutedtext"
663
814
  />
664
- <button
665
- type="button"
666
- onClick={() => void ask(prompt)}
667
- disabled={loading || !prompt.trim()}
668
- data-test="ask-ai-send"
669
- className="self-end rounded-xl bg-ph-brand px-3.5 py-2 text-sm font-medium text-white disabled:opacity-50"
670
- >
671
- Ask
672
- </button>
815
+ {loading ? (
816
+ <button
817
+ type="button"
818
+ onClick={stop}
819
+ data-test="ask-ai-stop"
820
+ className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-ph-muted text-ph-ink transition-colors hover:bg-ph-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus"
821
+ aria-label="Stop generating"
822
+ >
823
+ <Square className="h-3.5 w-3.5 fill-current" />
824
+ </button>
825
+ ) : (
826
+ <button
827
+ type="button"
828
+ onClick={() => void ask(prompt)}
829
+ disabled={!prompt.trim()}
830
+ data-test="ask-ai-send"
831
+ className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-ph-brand text-[color:var(--ph-on-accent)] transition-opacity hover:bg-ph-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
832
+ aria-label="Send message"
833
+ >
834
+ <ArrowUp className="h-[18px] w-[18px]" />
835
+ </button>
836
+ )}
673
837
  </div>
838
+ <p className="mt-1.5 px-1 text-center text-xs text-ph-mutedtext">
839
+ <kbd className="font-mono">Enter</kbd> to send ·{' '}
840
+ <kbd className="font-mono">Shift</kbd>+
841
+ <kbd className="font-mono">Enter</kbd> for a new line
842
+ </p>
674
843
  </div>
675
844
  </aside>
676
845
  </div>
@@ -40,5 +40,10 @@ describe("suite scroll ownership", () => {
40
40
  ).toHaveClass("break-words", "text-balance");
41
41
  expect(screen.getByRole("button", { name: "Export report" }).parentElement)
42
42
  .toHaveClass("max-w-full", "overflow-x-auto");
43
+ expect(
44
+ screen.getByRole("heading", {
45
+ name: "A very long responsive page title",
46
+ }).closest("header"),
47
+ ).toHaveClass("shrink-0");
43
48
  });
44
49
  });
@@ -92,7 +92,7 @@ export function SuitePage({
92
92
  role={role}
93
93
  aria-live={ariaLive}
94
94
  className={cn(
95
- 'suite-page-stage flex w-full min-w-0 flex-col',
95
+ 'suite-page-stage relative flex w-full min-w-0 flex-col',
96
96
  inset && 'suite-page-inset',
97
97
  className,
98
98
  )}
@@ -119,8 +119,11 @@ export function SuitePageHeader({
119
119
  badge,
120
120
  /** Breadcrumb line rendered above the title. */
121
121
  breadcrumb,
122
- /** Stick to the top of the scrollport (desktop page titles). */
123
- sticky = false,
122
+ /**
123
+ * Keep the title parked at the top of the scrollport while the page scrolls.
124
+ * On by default — opt out only for pages that are not the page's own title.
125
+ */
126
+ sticky = true,
124
127
  }: {
125
128
  title: ReactNode;
126
129
  description?: ReactNode;
@@ -141,15 +144,23 @@ export function SuitePageHeader({
141
144
 
142
145
  useEffect(() => {
143
146
  if (!sticky) return;
147
+ if (typeof IntersectionObserver === "undefined") return;
144
148
  const el = headerRef.current;
145
149
  if (!el) return;
146
150
 
147
- // Sentinel just above the header — when it leaves the scrollport, we're stuck.
151
+ const parent = el.parentElement;
152
+ if (!parent) return;
153
+
154
+ // Keep the sentinel out of flow so flex `gap` on the page doesn't add
155
+ // a blank strip above the title.
148
156
  const sentinel = document.createElement('div');
149
157
  sentinel.setAttribute('aria-hidden', 'true');
150
158
  sentinel.style.cssText =
151
- 'position:relative;height:1px;width:100%;margin:0;padding:0;pointer-events:none';
152
- el.parentElement?.insertBefore(sentinel, el);
159
+ 'position:absolute;top:0;left:0;height:1px;width:1px;margin:0;padding:0;pointer-events:none';
160
+ if (getComputedStyle(parent).position === 'static') {
161
+ parent.style.position = 'relative';
162
+ }
163
+ parent.insertBefore(sentinel, el);
153
164
 
154
165
  const observer = new IntersectionObserver(
155
166
  ([entry]) => {
@@ -167,7 +178,7 @@ export function SuitePageHeader({
167
178
  const resolvedIcon = todayIcon ? (
168
179
  <TodayTimeIcon
169
180
  className={cn(
170
- 'h-7 w-7 text-amber-500 sm:h-8 sm:w-8',
181
+ 'h-6 w-6 text-amber-500',
171
182
  todayIconClassName,
172
183
  )}
173
184
  />
@@ -181,9 +192,8 @@ export function SuitePageHeader({
181
192
  data-test={dataTest}
182
193
  data-stuck={sticky ? (stuck ? 'true' : 'false') : undefined}
183
194
  className={cn(
184
- 'flex min-w-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
195
+ 'flex min-w-0 shrink-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
185
196
  // No extra pt here — page inset sets the shared title baseline.
186
- // Stuck state adds pad via .suite-page-header-sticky[data-stuck].
187
197
  sticky && 'suite-page-header-sticky',
188
198
  className,
189
199
  )}
@@ -203,7 +213,7 @@ export function SuitePageHeader({
203
213
  <div className="flex min-w-0 items-center gap-3">
204
214
  {resolvedIcon ? (
205
215
  <div
206
- className="hidden shrink-0 sm:flex sm:items-center [&_svg]:h-7 [&_svg]:w-7 sm:[&_svg]:h-8 sm:[&_svg]:w-8"
216
+ className="hidden shrink-0 sm:flex sm:items-center [&_svg]:h-6 [&_svg]:w-6"
207
217
  aria-hidden="true"
208
218
  >
209
219
  {resolvedIcon}
@@ -212,7 +222,7 @@ export function SuitePageHeader({
212
222
  <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
213
223
  <h1
214
224
  className={cn(
215
- 'min-w-0 break-words font-display text-[1.625rem] font-bold leading-none tracking-tight text-ph-ink text-balance sm:text-[2.25rem]',
225
+ 'min-w-0 break-words font-display text-[1.375rem] font-semibold leading-tight tracking-tight text-ph-ink text-balance sm:text-2xl',
216
226
  titleClassName,
217
227
  )}
218
228
  >
@@ -229,7 +239,7 @@ export function SuitePageHeader({
229
239
  <div
230
240
  className={cn(
231
241
  'mt-1.5 max-w-2xl text-pretty text-sm leading-5 text-ph-subtle',
232
- resolvedIcon && 'sm:pl-11',
242
+ resolvedIcon && 'sm:pl-9',
233
243
  )}
234
244
  >
235
245
  {description}