@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.
- package/dist/suite.d.mts +7 -4
- package/dist/suite.d.ts +7 -4
- package/dist/suite.js +377 -180
- package/dist/suite.mjs +389 -181
- package/package.json +1 -1
- package/src/styles/suite-skin.css +136 -32
- package/src/suite/components/ai-panel.tsx +306 -137
- package/src/suite/components/suite-layout.test.tsx +5 -0
- package/src/suite/components/suite-layout.tsx +22 -12
- package/src/suite/components/suite-sidebar.tsx +3 -1
- package/src/suite/components/suite-user-menu.tsx +43 -7
- package/src/suite/components/today-page-frame.tsx +4 -5
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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={() =>
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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={
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
</div>
|
|
635
773
|
</div>
|
|
636
|
-
)
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
{
|
|
640
|
-
<
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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 (
|
|
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={
|
|
660
|
-
disabled={loading}
|
|
811
|
+
rows={1}
|
|
661
812
|
data-test="ask-ai-input"
|
|
662
|
-
className="flex-1 resize-none
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
/**
|
|
123
|
-
|
|
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
|
-
|
|
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:
|
|
152
|
-
|
|
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-
|
|
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-
|
|
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.
|
|
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-
|
|
242
|
+
resolvedIcon && 'sm:pl-9',
|
|
233
243
|
)}
|
|
234
244
|
>
|
|
235
245
|
{description}
|