radiant-docs 0.1.41 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +42 -40
  3. package/template/package-lock.json +7 -0
  4. package/template/package.json +1 -0
  5. package/template/src/components/Header.astro +150 -16
  6. package/template/src/components/MdxPage.astro +76 -22
  7. package/template/src/components/PagePagination.astro +44 -8
  8. package/template/src/components/Sidebar.astro +10 -1
  9. package/template/src/components/TableOfContents.astro +159 -53
  10. package/template/src/components/chat/AssistantDocsWidget.tsx +221 -8
  11. package/template/src/components/chat/AssistantEmbedPanel.tsx +1090 -104
  12. package/template/src/components/user/Accordion.astro +2 -2
  13. package/template/src/components/user/AccordionGroup.astro +1 -1
  14. package/template/src/components/user/Callout.astro +2 -2
  15. package/template/src/components/user/Card.astro +488 -0
  16. package/template/src/components/user/CardGradient.astro +964 -0
  17. package/template/src/components/user/CodeBlock.astro +1 -1
  18. package/template/src/components/user/CodeGroup.astro +1 -1
  19. package/template/src/components/user/Column.astro +25 -0
  20. package/template/src/components/user/Columns.astro +200 -0
  21. package/template/src/components/user/ComponentPreviewBlock.astro +1 -1
  22. package/template/src/components/user/Image.astro +1 -1
  23. package/template/src/components/user/Step.astro +1 -1
  24. package/template/src/components/user/Steps.astro +1 -1
  25. package/template/src/components/user/Tab.astro +1 -3
  26. package/template/src/components/user/Tabs.astro +2 -2
  27. package/template/src/layouts/Layout.astro +2 -4
  28. package/template/src/lib/assistant-chrome-defaults.ts +12 -0
  29. package/template/src/lib/assistant-embed-script.ts +209 -18
  30. package/template/src/lib/validation.ts +325 -75
  31. package/template/src/styles/global.css +81 -4
  32. package/template/src/components/chat/AskAiWidget.tsx +0 -2011
@@ -28,8 +28,10 @@ import remarkParse from "remark-parse";
28
28
  import remarkGfm from "remark-gfm";
29
29
  import remarkRehype from "remark-rehype";
30
30
  import rehypeStringify from "rehype-stringify";
31
+ import { getDocsBasePath, withBasePath } from "../../lib/base-path";
31
32
 
32
33
  type AssistantLinkTarget = "current" | "blank";
34
+ export type AssistantPanelSize = "default" | "expanded";
33
35
 
34
36
  type HastNode = {
35
37
  type?: string;
@@ -56,7 +58,11 @@ type AssistantEmbedPanelProps = {
56
58
  linkTarget?: AssistantLinkTarget;
57
59
  allowApiPathQueryOverride?: boolean;
58
60
  openSignal?: number;
61
+ panelSize?: AssistantPanelSize;
62
+ onRequestOpen?: () => void;
59
63
  onRequestClose?: () => void;
64
+ onRequestPanelSizeToggle?: (size: AssistantPanelSize) => void;
65
+ onCurrentLinkNavigate?: (href: string, sourceElement?: Element) => void;
60
66
  };
61
67
 
62
68
  type AssistantColorByMode = {
@@ -77,8 +83,42 @@ type AssistantStreamEvent = {
77
83
 
78
84
  type ChatInputKeyEvent = JSX.TargetedKeyboardEvent<HTMLTextAreaElement>;
79
85
  type ChatViewportWheelEvent = JSX.TargetedWheelEvent<HTMLDivElement>;
86
+ type ChatViewportScrollEvent = JSX.TargetedEvent<HTMLDivElement, Event>;
87
+
88
+ type PersistedPanelState = {
89
+ messages: ChatMessage[];
90
+ scrollTop: number;
91
+ inFlight: boolean;
92
+ isAwaitingFirstToken: boolean;
93
+ inFlightUpdatedAt: number;
94
+ panelSize: AssistantPanelSize;
95
+ };
96
+
97
+ type PersistPanelStateOptions = {
98
+ allowBusyListenerWrite?: boolean;
99
+ refreshInFlightTimestamp?: boolean;
100
+ };
101
+
102
+ type CurrentLinkNavigationRequest = {
103
+ href: string;
104
+ sourceElement: HTMLAnchorElement;
105
+ };
106
+
107
+ type PendingAssistantHandoff = {
108
+ state: PersistedPanelState;
109
+ targetWindow: Window;
110
+ timeoutId: number;
111
+ };
80
112
 
81
- const STORAGE_KEY = "docs:assistant-embed-panel:v1";
113
+ export const ASSISTANT_PANEL_STORAGE_KEY = "docs:assistant-embed-panel:v1";
114
+ const HANDOFF_QUERY_PARAM = "assistantHandoff";
115
+ const OPEN_QUERY_PARAM = "assistant";
116
+ const OPEN_QUERY_VALUE = "open";
117
+ const HANDOFF_READY_TYPE = "assistant-handoff:ready";
118
+ const HANDOFF_STATE_TYPE = "assistant-handoff:state";
119
+ const HANDOFF_ACK_TYPE = "assistant-handoff:ack";
120
+ const IN_FLIGHT_STALE_MS = 10000;
121
+ const IN_FLIGHT_HEARTBEAT_MS = 3000;
82
122
  const MARKDOWN_HTML_CACHE_LIMIT = 300;
83
123
  const markdownHtmlCache = new Map<string, string>();
84
124
  const PRISM_LANGUAGE_ALIAS: Record<string, string> = {
@@ -113,6 +153,8 @@ const PRISM_LANGUAGE_LABEL: Record<string, string> = {
113
153
  mdx: "MDX",
114
154
  };
115
155
 
156
+ const EXTERNAL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
157
+
116
158
  function normalizeRelTokens(value: unknown): string[] {
117
159
  if (Array.isArray(value)) {
118
160
  return value
@@ -138,6 +180,154 @@ function visitHastNodes(
138
180
  }
139
181
  }
140
182
 
183
+ function isDocumentLocalHref(href: string): boolean {
184
+ return (
185
+ href.startsWith("#") ||
186
+ href.startsWith("?") ||
187
+ href.startsWith("//") ||
188
+ href.startsWith("./") ||
189
+ href.startsWith("../")
190
+ );
191
+ }
192
+
193
+ function rebaseAssistantLinkHref(href: string): string {
194
+ const value = href.trim();
195
+ if (!value || isDocumentLocalHref(value)) {
196
+ return href;
197
+ }
198
+
199
+ if (!EXTERNAL_PROTOCOL_REGEX.test(value)) {
200
+ return withBasePath(value);
201
+ }
202
+
203
+ if (
204
+ typeof window === "undefined" ||
205
+ !/^https?:/i.test(value) ||
206
+ !getDocsBasePath()
207
+ ) {
208
+ return href;
209
+ }
210
+
211
+ try {
212
+ const parsed = new URL(value);
213
+ if (parsed.origin !== window.location.origin) {
214
+ return href;
215
+ }
216
+
217
+ const rebasedHref = withBasePath(
218
+ `${parsed.pathname}${parsed.search}${parsed.hash}`,
219
+ );
220
+ return new URL(rebasedHref, parsed.origin).toString();
221
+ } catch {
222
+ return href;
223
+ }
224
+ }
225
+
226
+ function getSameOriginNavigationRequest(
227
+ event: JSX.TargetedMouseEvent<HTMLDivElement>,
228
+ ): CurrentLinkNavigationRequest | null {
229
+ if (
230
+ event.defaultPrevented ||
231
+ event.button !== 0 ||
232
+ event.metaKey ||
233
+ event.ctrlKey ||
234
+ event.shiftKey ||
235
+ event.altKey
236
+ ) {
237
+ return null;
238
+ }
239
+
240
+ const targetElement = event.target instanceof Element ? event.target : null;
241
+ const anchor = targetElement?.closest("a[href]") as HTMLAnchorElement | null;
242
+ if (!anchor || !event.currentTarget.contains(anchor)) {
243
+ return null;
244
+ }
245
+
246
+ const target = anchor.getAttribute("target")?.trim().toLowerCase();
247
+ if ((target && target !== "_self") || anchor.hasAttribute("download")) {
248
+ return null;
249
+ }
250
+
251
+ const rawHref = anchor.getAttribute("href")?.trim();
252
+ if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("?")) {
253
+ return null;
254
+ }
255
+
256
+ try {
257
+ const url = new URL(anchor.href, window.location.href);
258
+ if (url.origin !== window.location.origin) {
259
+ return null;
260
+ }
261
+
262
+ if (
263
+ url.pathname === window.location.pathname &&
264
+ url.search === window.location.search &&
265
+ url.hash
266
+ ) {
267
+ return null;
268
+ }
269
+
270
+ return { href: url.href, sourceElement: anchor };
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ function getSameOriginBlankNavigationRequest(
277
+ event: JSX.TargetedMouseEvent<HTMLDivElement>,
278
+ ): CurrentLinkNavigationRequest | null {
279
+ if (
280
+ event.defaultPrevented ||
281
+ event.button !== 0 ||
282
+ event.metaKey ||
283
+ event.ctrlKey ||
284
+ event.shiftKey ||
285
+ event.altKey
286
+ ) {
287
+ return null;
288
+ }
289
+
290
+ const targetElement = event.target instanceof Element ? event.target : null;
291
+ const anchor = targetElement?.closest("a[href]") as HTMLAnchorElement | null;
292
+ if (!anchor || !event.currentTarget.contains(anchor)) {
293
+ return null;
294
+ }
295
+
296
+ const target = anchor.getAttribute("target")?.trim().toLowerCase();
297
+ if ((target && target !== "_blank") || anchor.hasAttribute("download")) {
298
+ return null;
299
+ }
300
+
301
+ const rawHref = anchor.getAttribute("href")?.trim();
302
+ if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("?")) {
303
+ return null;
304
+ }
305
+
306
+ try {
307
+ const url = new URL(anchor.href, window.location.href);
308
+ if (url.origin !== window.location.origin) {
309
+ return null;
310
+ }
311
+
312
+ return { href: url.href, sourceElement: anchor };
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ const rehypeRebaseInternalLinks: Plugin<[], HastNode> = () => {
319
+ return (tree) => {
320
+ visitHastNodes(tree, (node) => {
321
+ if (node.type !== "element" || node.tagName !== "a") return;
322
+
323
+ const props = node.properties;
324
+ if (!props || typeof props.href !== "string") return;
325
+
326
+ props.href = rebaseAssistantLinkHref(props.href);
327
+ });
328
+ };
329
+ };
330
+
141
331
  const rehypeOpenLinksInNewTab: Plugin<[], HastNode> = () => {
142
332
  return (tree) => {
143
333
  visitHastNodes(tree, (node) => {
@@ -166,41 +356,212 @@ function createMessageId(): string {
166
356
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
167
357
  }
168
358
 
169
- function readPersistedMessages(): ChatMessage[] {
170
- if (typeof window === "undefined") {
359
+ function normalizePersistedMessages(rawMessages: unknown): ChatMessage[] {
360
+ if (!Array.isArray(rawMessages)) {
171
361
  return [];
172
362
  }
173
363
 
364
+ return rawMessages
365
+ .filter((message): message is ChatMessage => {
366
+ if (!message || typeof message !== "object") {
367
+ return false;
368
+ }
369
+ const candidate = message as Partial<ChatMessage>;
370
+ return (
371
+ (candidate.role === "user" || candidate.role === "assistant") &&
372
+ typeof candidate.id === "string" &&
373
+ typeof candidate.content === "string"
374
+ );
375
+ })
376
+ .map((message) => ({
377
+ id: message.id,
378
+ role: message.role,
379
+ content: message.content,
380
+ }));
381
+ }
382
+
383
+ function createEmptyPersistedPanelState(): PersistedPanelState {
384
+ return {
385
+ messages: [],
386
+ scrollTop: 0,
387
+ inFlight: false,
388
+ isAwaitingFirstToken: false,
389
+ inFlightUpdatedAt: 0,
390
+ panelSize: "default",
391
+ };
392
+ }
393
+
394
+ function normalizePanelSize(value: unknown): AssistantPanelSize {
395
+ return value === "expanded" ? "expanded" : "default";
396
+ }
397
+
398
+ function normalizePersistedPanelState(rawState: unknown): PersistedPanelState {
399
+ if (!rawState || typeof rawState !== "object") {
400
+ return createEmptyPersistedPanelState();
401
+ }
402
+
403
+ const state = rawState as {
404
+ messages?: unknown;
405
+ scrollTop?: unknown;
406
+ inFlight?: unknown;
407
+ isAwaitingFirstToken?: unknown;
408
+ inFlightUpdatedAt?: unknown;
409
+ panelSize?: unknown;
410
+ };
411
+ const inFlightUpdatedAt =
412
+ typeof state.inFlightUpdatedAt === "number" &&
413
+ Number.isFinite(state.inFlightUpdatedAt)
414
+ ? Math.max(0, state.inFlightUpdatedAt)
415
+ : 0;
416
+ const isInFlightFresh =
417
+ state.inFlight === true &&
418
+ inFlightUpdatedAt > 0 &&
419
+ Date.now() - inFlightUpdatedAt <= IN_FLIGHT_STALE_MS;
420
+ const scrollTop =
421
+ typeof state.scrollTop === "number" && Number.isFinite(state.scrollTop)
422
+ ? Math.max(0, state.scrollTop)
423
+ : 0;
424
+
425
+ return {
426
+ messages: normalizePersistedMessages(state.messages),
427
+ scrollTop,
428
+ inFlight: isInFlightFresh,
429
+ isAwaitingFirstToken:
430
+ isInFlightFresh && state.isAwaitingFirstToken === true,
431
+ inFlightUpdatedAt: isInFlightFresh ? inFlightUpdatedAt : 0,
432
+ panelSize: normalizePanelSize(state.panelSize),
433
+ };
434
+ }
435
+
436
+ function parsePersistedPanelState(raw: string | null): PersistedPanelState {
437
+ if (!raw) {
438
+ return createEmptyPersistedPanelState();
439
+ }
440
+
174
441
  try {
175
- const raw = window.localStorage.getItem(STORAGE_KEY);
176
- if (!raw) {
177
- return [];
442
+ const parsed = JSON.parse(raw) as unknown;
443
+ if (Array.isArray(parsed)) {
444
+ return {
445
+ ...createEmptyPersistedPanelState(),
446
+ messages: normalizePersistedMessages(parsed),
447
+ };
178
448
  }
179
449
 
180
- const parsed = JSON.parse(raw) as { messages?: unknown };
181
- if (!Array.isArray(parsed.messages)) {
182
- return [];
450
+ return normalizePersistedPanelState(parsed);
451
+ } catch {
452
+ return createEmptyPersistedPanelState();
453
+ }
454
+ }
455
+
456
+ function readPersistedPanelState(): PersistedPanelState {
457
+ if (typeof window === "undefined") {
458
+ return createEmptyPersistedPanelState();
459
+ }
460
+
461
+ try {
462
+ return parsePersistedPanelState(
463
+ window.localStorage.getItem(ASSISTANT_PANEL_STORAGE_KEY),
464
+ );
465
+ } catch {
466
+ return createEmptyPersistedPanelState();
467
+ }
468
+ }
469
+
470
+ function writePersistedPanelState(state: PersistedPanelState) {
471
+ if (typeof window === "undefined") {
472
+ return;
473
+ }
474
+
475
+ try {
476
+ window.localStorage.setItem(
477
+ ASSISTANT_PANEL_STORAGE_KEY,
478
+ JSON.stringify({
479
+ messages: state.messages,
480
+ scrollTop:
481
+ Number.isFinite(state.scrollTop) && state.scrollTop > 0
482
+ ? state.scrollTop
483
+ : 0,
484
+ inFlight: state.inFlight,
485
+ isAwaitingFirstToken: state.inFlight && state.isAwaitingFirstToken,
486
+ inFlightUpdatedAt: state.inFlight ? state.inFlightUpdatedAt : 0,
487
+ panelSize: normalizePanelSize(state.panelSize),
488
+ }),
489
+ );
490
+ } catch {
491
+ // Ignore storage failures in private mode or constrained embeds.
492
+ }
493
+ }
494
+
495
+ function buildHandoffUrl(href: string, handoffId: string): string {
496
+ const url = new URL(href, window.location.href);
497
+ url.searchParams.set(HANDOFF_QUERY_PARAM, handoffId);
498
+ url.searchParams.set(OPEN_QUERY_PARAM, OPEN_QUERY_VALUE);
499
+ return url.toString();
500
+ }
501
+
502
+ function readAssistantUrlSignal(): {
503
+ handoffId: string;
504
+ shouldOpen: boolean;
505
+ } {
506
+ if (typeof window === "undefined") {
507
+ return { handoffId: "", shouldOpen: false };
508
+ }
509
+
510
+ try {
511
+ const url = new URL(window.location.href);
512
+ const searchHandoffId = url.searchParams.get(HANDOFF_QUERY_PARAM) ?? "";
513
+ const searchShouldOpen =
514
+ url.searchParams.get(OPEN_QUERY_PARAM) === OPEN_QUERY_VALUE;
515
+ const hashParams = new URLSearchParams(
516
+ url.hash.startsWith("#") ? url.hash.slice(1) : url.hash,
517
+ );
518
+ const hashHandoffId = hashParams.get(HANDOFF_QUERY_PARAM) ?? "";
519
+ const hashShouldOpen =
520
+ hashParams.get(OPEN_QUERY_PARAM) === OPEN_QUERY_VALUE;
521
+
522
+ return {
523
+ handoffId: searchHandoffId || hashHandoffId,
524
+ shouldOpen:
525
+ Boolean(searchHandoffId || hashHandoffId) ||
526
+ searchShouldOpen ||
527
+ hashShouldOpen,
528
+ };
529
+ } catch {
530
+ return { handoffId: "", shouldOpen: false };
531
+ }
532
+ }
533
+
534
+ function clearAssistantUrlSignal() {
535
+ if (typeof window === "undefined") {
536
+ return;
537
+ }
538
+
539
+ try {
540
+ const url = new URL(window.location.href);
541
+ url.searchParams.delete(HANDOFF_QUERY_PARAM);
542
+ url.searchParams.delete(OPEN_QUERY_PARAM);
543
+
544
+ const hashValue = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
545
+ if (hashValue) {
546
+ const hashParams = new URLSearchParams(hashValue);
547
+ if (
548
+ hashParams.has(HANDOFF_QUERY_PARAM) ||
549
+ hashParams.has(OPEN_QUERY_PARAM)
550
+ ) {
551
+ hashParams.delete(HANDOFF_QUERY_PARAM);
552
+ hashParams.delete(OPEN_QUERY_PARAM);
553
+ const nextHash = hashParams.toString();
554
+ url.hash = nextHash ? `#${nextHash}` : "";
555
+ }
183
556
  }
184
557
 
185
- return parsed.messages
186
- .filter((message): message is ChatMessage => {
187
- if (!message || typeof message !== "object") {
188
- return false;
189
- }
190
- const candidate = message as Partial<ChatMessage>;
191
- return (
192
- (candidate.role === "user" || candidate.role === "assistant") &&
193
- typeof candidate.id === "string" &&
194
- typeof candidate.content === "string"
195
- );
196
- })
197
- .map((message) => ({
198
- id: message.id,
199
- role: message.role,
200
- content: message.content,
201
- }));
558
+ window.history.replaceState(
559
+ window.history.state,
560
+ "",
561
+ `${url.pathname}${url.search}${url.hash}`,
562
+ );
202
563
  } catch {
203
- return [];
564
+ // Leave the URL alone if parsing fails.
204
565
  }
205
566
  }
206
567
 
@@ -458,7 +819,8 @@ function renderMarkdownToHtml(
458
819
  const processor = unified()
459
820
  .use(remarkParse)
460
821
  .use(remarkGfm)
461
- .use(remarkRehype, { allowDangerousHtml: false });
822
+ .use(remarkRehype, { allowDangerousHtml: false })
823
+ .use(rehypeRebaseInternalLinks);
462
824
 
463
825
  if (linkTarget === "blank") {
464
826
  processor.use(rehypeOpenLinksInNewTab);
@@ -504,12 +866,15 @@ function extractErrorMessage(rawBody: string): string {
504
866
  return rawBody.trim();
505
867
  }
506
868
 
507
- function postParentMessage(type: string) {
869
+ function postParentMessage(
870
+ type: string,
871
+ payload: Record<string, unknown> = {},
872
+ ) {
508
873
  if (typeof window === "undefined") {
509
874
  return;
510
875
  }
511
876
 
512
- window.parent.postMessage({ type }, "*");
877
+ window.parent.postMessage({ type, ...payload }, "*");
513
878
  }
514
879
 
515
880
  function getApiPath(
@@ -578,20 +943,50 @@ export default function AssistantEmbedPanel({
578
943
  linkTarget = "current",
579
944
  allowApiPathQueryOverride = true,
580
945
  openSignal = 0,
946
+ panelSize,
947
+ onRequestOpen,
581
948
  onRequestClose,
949
+ onRequestPanelSizeToggle,
950
+ onCurrentLinkNavigate,
582
951
  }: AssistantEmbedPanelProps) {
952
+ const [initialPanelState] = useState<PersistedPanelState>(() =>
953
+ canSendChatRequest
954
+ ? readPersistedPanelState()
955
+ : createEmptyPersistedPanelState(),
956
+ );
583
957
  const [messages, setMessages] = useState<ChatMessage[]>(
584
- canSendChatRequest ? readPersistedMessages : [],
958
+ initialPanelState.messages,
585
959
  );
586
960
  const [input, setInput] = useState("");
587
- const [isBusy, setIsBusy] = useState(false);
588
- const [isAwaitingFirstToken, setIsAwaitingFirstToken] = useState(false);
961
+ const [isBusy, setIsBusy] = useState(initialPanelState.inFlight);
962
+ const [isAwaitingFirstToken, setIsAwaitingFirstToken] = useState(
963
+ initialPanelState.isAwaitingFirstToken,
964
+ );
589
965
  const [errorMessage, setErrorMessage] = useState("");
590
966
  const [showUnavailableState, setShowUnavailableState] =
591
967
  useState(!isChatAvailable);
592
968
  const [emptyStateAnimationKey, setEmptyStateAnimationKey] = useState(0);
969
+ const [localPanelSize, setLocalPanelSize] = useState<AssistantPanelSize>(
970
+ panelSize ?? initialPanelState.panelSize,
971
+ );
972
+ const [isShellFullscreen, setIsShellFullscreen] = useState(false);
593
973
  const activeRequestAbortRef = useRef<AbortController | null>(null);
594
974
  const scrollViewportRef = useRef<HTMLDivElement | null>(null);
975
+ const savedScrollTopRef = useRef(initialPanelState.scrollTop);
976
+ const scrollPersistenceFrameRef = useRef<number | null>(null);
977
+ const scrollPersistenceUnlockTimeoutRef = useRef<number | null>(null);
978
+ const isScrollPersistenceLockedRef = useRef(false);
979
+ const pendingHandoffsRef = useRef(new Map<string, PendingAssistantHandoff>());
980
+ const messagesRef = useRef(initialPanelState.messages);
981
+ const isBusyRef = useRef(initialPanelState.inFlight);
982
+ const isAwaitingFirstTokenRef = useRef(
983
+ initialPanelState.isAwaitingFirstToken,
984
+ );
985
+ const inFlightUpdatedAtRef = useRef(initialPanelState.inFlightUpdatedAt);
986
+ const panelSizeRef = useRef<AssistantPanelSize>(
987
+ panelSize ?? initialPanelState.panelSize,
988
+ );
989
+ const skipNextMessagesPersistRef = useRef(false);
595
990
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
596
991
  const resolvedApiPathRef = useRef(
597
992
  getApiPath(apiPath, allowApiPathQueryOverride),
@@ -608,33 +1003,500 @@ export default function AssistantEmbedPanel({
608
1003
  launcherIconColors?.light ?? launcherIconColor ?? "#ffffff";
609
1004
  const launcherIconColorDark =
610
1005
  launcherIconColors?.dark ?? launcherIconColor ?? "#ffffff";
1006
+ const resolvedPanelSize = panelSize ?? localPanelSize;
1007
+
1008
+ const persistPanelState = (
1009
+ nextMessages = messagesRef.current,
1010
+ scrollTop = savedScrollTopRef.current,
1011
+ options: PersistPanelStateOptions = {},
1012
+ ) => {
1013
+ const ownsActiveRequest = Boolean(activeRequestAbortRef.current);
1014
+ if (
1015
+ isBusyRef.current &&
1016
+ !ownsActiveRequest &&
1017
+ !options.allowBusyListenerWrite
1018
+ ) {
1019
+ return;
1020
+ }
1021
+
1022
+ if (!isBusyRef.current) {
1023
+ inFlightUpdatedAtRef.current = 0;
1024
+ } else if (
1025
+ ownsActiveRequest &&
1026
+ options.refreshInFlightTimestamp !== false
1027
+ ) {
1028
+ inFlightUpdatedAtRef.current = Date.now();
1029
+ }
1030
+
1031
+ writePersistedPanelState({
1032
+ messages: nextMessages,
1033
+ scrollTop,
1034
+ inFlight: isBusyRef.current,
1035
+ isAwaitingFirstToken:
1036
+ isBusyRef.current && isAwaitingFirstTokenRef.current,
1037
+ inFlightUpdatedAt: inFlightUpdatedAtRef.current,
1038
+ panelSize: panelSizeRef.current,
1039
+ });
1040
+ };
1041
+
1042
+ const cancelQueuedScrollPersistence = () => {
1043
+ if (
1044
+ typeof window !== "undefined" &&
1045
+ scrollPersistenceFrameRef.current !== null
1046
+ ) {
1047
+ window.cancelAnimationFrame(scrollPersistenceFrameRef.current);
1048
+ scrollPersistenceFrameRef.current = null;
1049
+ }
1050
+ };
1051
+
1052
+ const clearScrollPersistenceUnlockTimeout = () => {
1053
+ if (
1054
+ typeof window !== "undefined" &&
1055
+ scrollPersistenceUnlockTimeoutRef.current !== null
1056
+ ) {
1057
+ window.clearTimeout(scrollPersistenceUnlockTimeoutRef.current);
1058
+ scrollPersistenceUnlockTimeoutRef.current = null;
1059
+ }
1060
+ };
1061
+
1062
+ const unlockScrollPersistence = () => {
1063
+ clearScrollPersistenceUnlockTimeout();
1064
+ isScrollPersistenceLockedRef.current = false;
1065
+ };
1066
+
1067
+ const lockScrollPersistence = () => {
1068
+ if (typeof window === "undefined") {
1069
+ isScrollPersistenceLockedRef.current = true;
1070
+ return;
1071
+ }
1072
+
1073
+ clearScrollPersistenceUnlockTimeout();
1074
+ isScrollPersistenceLockedRef.current = true;
1075
+ scrollPersistenceUnlockTimeoutRef.current = window.setTimeout(() => {
1076
+ scrollPersistenceUnlockTimeoutRef.current = null;
1077
+ isScrollPersistenceLockedRef.current = false;
1078
+ }, 1200);
1079
+ };
1080
+
1081
+ const snapshotThreadScrollPosition = () => {
1082
+ const viewport = scrollViewportRef.current;
1083
+ if (!viewport) {
1084
+ return;
1085
+ }
1086
+
1087
+ savedScrollTopRef.current = viewport.scrollTop;
1088
+ cancelQueuedScrollPersistence();
1089
+ persistPanelState();
1090
+ };
1091
+
1092
+ const restoreSavedScrollPosition = (unlockAfterRestore = false) => {
1093
+ const viewport = scrollViewportRef.current;
1094
+ if (!viewport) {
1095
+ if (unlockAfterRestore) {
1096
+ unlockScrollPersistence();
1097
+ }
1098
+ return;
1099
+ }
1100
+
1101
+ const maxScrollTop = Math.max(
1102
+ 0,
1103
+ viewport.scrollHeight - viewport.clientHeight,
1104
+ );
1105
+ viewport.scrollTop = clamp(savedScrollTopRef.current, 0, maxScrollTop);
1106
+
1107
+ if (unlockAfterRestore && typeof window !== "undefined") {
1108
+ window.requestAnimationFrame(() => {
1109
+ unlockScrollPersistence();
1110
+ });
1111
+ } else if (unlockAfterRestore) {
1112
+ unlockScrollPersistence();
1113
+ }
1114
+ };
1115
+
1116
+ const queueSavedScrollRestore = (unlockAfterRestore = false) => {
1117
+ if (typeof window === "undefined") {
1118
+ restoreSavedScrollPosition(unlockAfterRestore);
1119
+ return;
1120
+ }
1121
+
1122
+ window.requestAnimationFrame(() => {
1123
+ window.requestAnimationFrame(() => {
1124
+ restoreSavedScrollPosition(unlockAfterRestore);
1125
+ });
1126
+ });
1127
+ };
1128
+
1129
+ const queueThreadScrollToBottom = (nextMessages: ChatMessage[]) => {
1130
+ if (typeof window === "undefined") {
1131
+ return;
1132
+ }
1133
+
1134
+ window.requestAnimationFrame(() => {
1135
+ const viewport = scrollViewportRef.current;
1136
+ if (!viewport) {
1137
+ return;
1138
+ }
1139
+
1140
+ viewport.scrollTop = viewport.scrollHeight;
1141
+ savedScrollTopRef.current = viewport.scrollTop;
1142
+ persistPanelState(nextMessages, savedScrollTopRef.current);
1143
+ });
1144
+ };
1145
+
1146
+ const resetThreadScrollPosition = (nextMessages: ChatMessage[]) => {
1147
+ savedScrollTopRef.current = 0;
1148
+ if (scrollViewportRef.current) {
1149
+ scrollViewportRef.current.scrollTop = 0;
1150
+ }
1151
+ persistPanelState(nextMessages, 0);
1152
+ };
1153
+
1154
+ const setSharedBusy = (nextIsBusy: boolean) => {
1155
+ isBusyRef.current = nextIsBusy;
1156
+ inFlightUpdatedAtRef.current = nextIsBusy ? Date.now() : 0;
1157
+ setIsBusy(nextIsBusy);
1158
+ };
1159
+
1160
+ const setSharedAwaitingFirstToken = (nextIsAwaiting: boolean) => {
1161
+ isAwaitingFirstTokenRef.current = nextIsAwaiting;
1162
+ setIsAwaitingFirstToken(nextIsAwaiting);
1163
+ };
1164
+
1165
+ const notifyPanelSizeChange = (nextPanelSize: AssistantPanelSize) => {
1166
+ if (onRequestPanelSizeToggle) {
1167
+ onRequestPanelSizeToggle(nextPanelSize);
1168
+ return;
1169
+ }
1170
+
1171
+ postParentMessage("assistant-embed:set-panel-size", {
1172
+ size: nextPanelSize,
1173
+ });
1174
+ };
1175
+
1176
+ const setSharedPanelSize = (
1177
+ nextPanelSize: AssistantPanelSize,
1178
+ shouldNotifyShell = true,
1179
+ ) => {
1180
+ panelSizeRef.current = nextPanelSize;
1181
+ setLocalPanelSize(nextPanelSize);
1182
+ persistPanelState(messagesRef.current, savedScrollTopRef.current, {
1183
+ allowBusyListenerWrite: true,
1184
+ refreshInFlightTimestamp: false,
1185
+ });
1186
+
1187
+ if (shouldNotifyShell) {
1188
+ notifyPanelSizeChange(nextPanelSize);
1189
+ }
1190
+ };
1191
+
1192
+ const applyPersistedPanelState = (nextState: PersistedPanelState) => {
1193
+ messagesRef.current = nextState.messages;
1194
+ savedScrollTopRef.current = nextState.scrollTop;
1195
+ isBusyRef.current = nextState.inFlight;
1196
+ isAwaitingFirstTokenRef.current = nextState.isAwaitingFirstToken;
1197
+ inFlightUpdatedAtRef.current = nextState.inFlightUpdatedAt;
1198
+ panelSizeRef.current = nextState.panelSize;
1199
+ skipNextMessagesPersistRef.current = true;
1200
+ setMessages(nextState.messages);
1201
+ setIsBusy(nextState.inFlight);
1202
+ setIsAwaitingFirstToken(nextState.isAwaitingFirstToken);
1203
+ setLocalPanelSize(nextState.panelSize);
1204
+ queueSavedScrollRestore();
1205
+ };
1206
+
1207
+ const requestPanelOpen = () => {
1208
+ if (onRequestOpen) {
1209
+ onRequestOpen();
1210
+ return;
1211
+ }
1212
+
1213
+ postParentMessage("assistant-embed:open");
1214
+ };
1215
+
1216
+ const clearPendingHandoff = (handoffId: string) => {
1217
+ const pending = pendingHandoffsRef.current.get(handoffId);
1218
+ if (!pending) {
1219
+ return;
1220
+ }
1221
+
1222
+ window.clearTimeout(pending.timeoutId);
1223
+ pendingHandoffsRef.current.delete(handoffId);
1224
+ };
1225
+
1226
+ const openBlankLinkWithHandoff = (
1227
+ navigationRequest: CurrentLinkNavigationRequest,
1228
+ ) => {
1229
+ if (typeof window === "undefined") {
1230
+ return false;
1231
+ }
1232
+
1233
+ snapshotThreadScrollPosition();
1234
+
1235
+ const handoffId = createMessageId();
1236
+ const targetWindow = window.open(
1237
+ buildHandoffUrl(navigationRequest.href, handoffId),
1238
+ "_blank",
1239
+ );
1240
+ if (!targetWindow) {
1241
+ return false;
1242
+ }
1243
+
1244
+ const timeoutId = window.setTimeout(() => {
1245
+ pendingHandoffsRef.current.delete(handoffId);
1246
+ }, 30000);
1247
+
1248
+ pendingHandoffsRef.current.set(handoffId, {
1249
+ state: {
1250
+ messages: messagesRef.current,
1251
+ scrollTop: savedScrollTopRef.current,
1252
+ inFlight: isBusyRef.current,
1253
+ isAwaitingFirstToken:
1254
+ isBusyRef.current && isAwaitingFirstTokenRef.current,
1255
+ inFlightUpdatedAt:
1256
+ isBusyRef.current && activeRequestAbortRef.current
1257
+ ? Date.now()
1258
+ : inFlightUpdatedAtRef.current,
1259
+ panelSize: panelSizeRef.current,
1260
+ },
1261
+ targetWindow,
1262
+ timeoutId,
1263
+ });
1264
+
1265
+ return true;
1266
+ };
611
1267
 
612
1268
  useEffect(() => {
613
1269
  return () => {
614
1270
  activeRequestAbortRef.current?.abort();
615
1271
  activeRequestAbortRef.current = null;
1272
+ if (
1273
+ typeof window !== "undefined" &&
1274
+ scrollPersistenceFrameRef.current !== null
1275
+ ) {
1276
+ window.cancelAnimationFrame(scrollPersistenceFrameRef.current);
1277
+ }
1278
+ clearScrollPersistenceUnlockTimeout();
1279
+ for (const handoffId of pendingHandoffsRef.current.keys()) {
1280
+ clearPendingHandoff(handoffId);
1281
+ }
616
1282
  };
617
1283
  }, []);
618
1284
 
1285
+ useEffect(() => {
1286
+ messagesRef.current = messages;
1287
+ if (skipNextMessagesPersistRef.current) {
1288
+ skipNextMessagesPersistRef.current = false;
1289
+ return;
1290
+ }
1291
+ persistPanelState(messages);
1292
+ }, [messages]);
1293
+
619
1294
  useEffect(() => {
620
1295
  if (typeof window === "undefined") {
621
1296
  return;
622
1297
  }
623
1298
 
624
- try {
625
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ messages }));
626
- } catch {
627
- // Ignore storage failures in private mode or constrained embeds.
1299
+ if (!isBusy || !activeRequestAbortRef.current) {
1300
+ return;
628
1301
  }
629
- }, [messages]);
1302
+
1303
+ const inFlightHeartbeat = window.setInterval(() => {
1304
+ if (!activeRequestAbortRef.current) {
1305
+ return;
1306
+ }
1307
+
1308
+ persistPanelState();
1309
+ }, IN_FLIGHT_HEARTBEAT_MS);
1310
+
1311
+ return () => {
1312
+ window.clearInterval(inFlightHeartbeat);
1313
+ };
1314
+ }, [isBusy]);
630
1315
 
631
1316
  useEffect(() => {
632
- const viewport = scrollViewportRef.current;
633
- if (!viewport) {
1317
+ if (typeof window === "undefined") {
634
1318
  return;
635
1319
  }
636
- viewport.scrollTop = viewport.scrollHeight;
637
- }, [messages, isAwaitingFirstToken]);
1320
+
1321
+ const handleStorage = (event: StorageEvent) => {
1322
+ if (
1323
+ event.key !== ASSISTANT_PANEL_STORAGE_KEY ||
1324
+ event.newValue === null
1325
+ ) {
1326
+ return;
1327
+ }
1328
+
1329
+ if (activeRequestAbortRef.current) {
1330
+ return;
1331
+ }
1332
+
1333
+ applyPersistedPanelState(parsePersistedPanelState(event.newValue));
1334
+ };
1335
+
1336
+ window.addEventListener("storage", handleStorage);
1337
+ return () => {
1338
+ window.removeEventListener("storage", handleStorage);
1339
+ };
1340
+ }, []);
1341
+
1342
+ useEffect(() => {
1343
+ queueSavedScrollRestore();
1344
+
1345
+ if (typeof document === "undefined") {
1346
+ return;
1347
+ }
1348
+
1349
+ const persistCurrentScrollPosition = () => {
1350
+ if (isScrollPersistenceLockedRef.current) {
1351
+ return;
1352
+ }
1353
+
1354
+ const viewport = scrollViewportRef.current;
1355
+ if (!viewport) {
1356
+ return;
1357
+ }
1358
+
1359
+ savedScrollTopRef.current = viewport.scrollTop;
1360
+ persistPanelState();
1361
+ };
1362
+
1363
+ const handleAfterSwap = () => {
1364
+ queueSavedScrollRestore(isScrollPersistenceLockedRef.current);
1365
+ };
1366
+
1367
+ document.addEventListener(
1368
+ "astro:before-preparation",
1369
+ persistCurrentScrollPosition,
1370
+ );
1371
+ document.addEventListener("astro:after-swap", handleAfterSwap);
1372
+ return () => {
1373
+ document.removeEventListener(
1374
+ "astro:before-preparation",
1375
+ persistCurrentScrollPosition,
1376
+ );
1377
+ document.removeEventListener("astro:after-swap", handleAfterSwap);
1378
+ };
1379
+ }, []);
1380
+
1381
+ useEffect(() => {
1382
+ if (typeof window === "undefined") {
1383
+ return;
1384
+ }
1385
+
1386
+ const handleHandoffMessage = (event: MessageEvent) => {
1387
+ if (
1388
+ event.origin !== window.location.origin ||
1389
+ !event.data ||
1390
+ typeof event.data !== "object"
1391
+ ) {
1392
+ return;
1393
+ }
1394
+
1395
+ const data = event.data as {
1396
+ type?: unknown;
1397
+ handoffId?: unknown;
1398
+ };
1399
+ if (typeof data.handoffId !== "string") {
1400
+ return;
1401
+ }
1402
+
1403
+ const pending = pendingHandoffsRef.current.get(data.handoffId);
1404
+ if (!pending || event.source !== pending.targetWindow) {
1405
+ return;
1406
+ }
1407
+
1408
+ if (data.type === HANDOFF_READY_TYPE) {
1409
+ pending.targetWindow.postMessage(
1410
+ {
1411
+ type: HANDOFF_STATE_TYPE,
1412
+ handoffId: data.handoffId,
1413
+ state: pending.state,
1414
+ },
1415
+ window.location.origin,
1416
+ );
1417
+ return;
1418
+ }
1419
+
1420
+ if (data.type === HANDOFF_ACK_TYPE) {
1421
+ clearPendingHandoff(data.handoffId);
1422
+ }
1423
+ };
1424
+
1425
+ window.addEventListener("message", handleHandoffMessage);
1426
+ return () => {
1427
+ window.removeEventListener("message", handleHandoffMessage);
1428
+ };
1429
+ }, []);
1430
+
1431
+ useEffect(() => {
1432
+ if (typeof window === "undefined") {
1433
+ return;
1434
+ }
1435
+
1436
+ const { handoffId, shouldOpen } = readAssistantUrlSignal();
1437
+ if (!handoffId && !shouldOpen) {
1438
+ return;
1439
+ }
1440
+
1441
+ requestPanelOpen();
1442
+ clearAssistantUrlSignal();
1443
+
1444
+ if (!handoffId || !window.opener) {
1445
+ return;
1446
+ }
1447
+
1448
+ const handleIncomingHandoff = (event: MessageEvent) => {
1449
+ if (
1450
+ event.origin !== window.location.origin ||
1451
+ event.source !== window.opener ||
1452
+ !event.data ||
1453
+ typeof event.data !== "object"
1454
+ ) {
1455
+ return;
1456
+ }
1457
+
1458
+ const data = event.data as {
1459
+ type?: unknown;
1460
+ handoffId?: unknown;
1461
+ state?: unknown;
1462
+ };
1463
+ if (data.type !== HANDOFF_STATE_TYPE || data.handoffId !== handoffId) {
1464
+ return;
1465
+ }
1466
+
1467
+ const handoffState = normalizePersistedPanelState(data.state);
1468
+ const nextState = {
1469
+ ...handoffState,
1470
+ inFlight: isBusyRef.current,
1471
+ isAwaitingFirstToken:
1472
+ isBusyRef.current && isAwaitingFirstTokenRef.current,
1473
+ inFlightUpdatedAt: isBusyRef.current ? inFlightUpdatedAtRef.current : 0,
1474
+ };
1475
+ applyPersistedPanelState(nextState);
1476
+ writePersistedPanelState(nextState);
1477
+
1478
+ window.opener?.postMessage(
1479
+ { type: HANDOFF_ACK_TYPE, handoffId },
1480
+ window.location.origin,
1481
+ );
1482
+ window.removeEventListener("message", handleIncomingHandoff);
1483
+ };
1484
+
1485
+ window.addEventListener("message", handleIncomingHandoff);
1486
+ window.opener.postMessage(
1487
+ { type: HANDOFF_READY_TYPE, handoffId },
1488
+ window.location.origin,
1489
+ );
1490
+
1491
+ const handoffTimeout = window.setTimeout(() => {
1492
+ window.removeEventListener("message", handleIncomingHandoff);
1493
+ }, 5000);
1494
+
1495
+ return () => {
1496
+ window.clearTimeout(handoffTimeout);
1497
+ window.removeEventListener("message", handleIncomingHandoff);
1498
+ };
1499
+ }, []);
638
1500
 
639
1501
  const resizeChatInputTextarea = () => {
640
1502
  const textarea = inputRef.current;
@@ -669,21 +1531,50 @@ export default function AssistantEmbedPanel({
669
1531
  resizeChatInputTextarea();
670
1532
  }, [input]);
671
1533
 
1534
+ useEffect(() => {
1535
+ if (panelSize) {
1536
+ setSharedPanelSize(panelSize, false);
1537
+ }
1538
+ }, [panelSize]);
1539
+
1540
+ useEffect(() => {
1541
+ notifyPanelSizeChange(resolvedPanelSize);
1542
+ }, [resolvedPanelSize]);
1543
+
672
1544
  useEffect(() => {
673
1545
  if (typeof window === "undefined") {
674
1546
  return;
675
1547
  }
676
1548
 
677
1549
  const handlePanelMessage = (event: MessageEvent) => {
678
- if (
679
- !event.data ||
680
- typeof event.data !== "object" ||
681
- event.data.type !== "assistant-embed:panel-opened"
682
- ) {
1550
+ if (!event.data || typeof event.data !== "object") {
683
1551
  return;
684
1552
  }
685
1553
 
686
- setEmptyStateAnimationKey((previous) => previous + 1);
1554
+ const data = event.data as {
1555
+ type?: unknown;
1556
+ size?: unknown;
1557
+ isFullscreen?: unknown;
1558
+ };
1559
+
1560
+ if (data.type === "assistant-embed:panel-opened") {
1561
+ setEmptyStateAnimationKey((previous) => previous + 1);
1562
+ queueSavedScrollRestore();
1563
+ postParentMessage("assistant-embed:get-panel-layout");
1564
+ return;
1565
+ }
1566
+
1567
+ if (data.type === "assistant-embed:set-panel-layout") {
1568
+ setIsShellFullscreen(data.isFullscreen === true);
1569
+ return;
1570
+ }
1571
+
1572
+ if (
1573
+ data.type === "assistant-embed:set-panel-size" &&
1574
+ (data.size === "default" || data.size === "expanded")
1575
+ ) {
1576
+ setSharedPanelSize(data.size, true);
1577
+ }
687
1578
  };
688
1579
 
689
1580
  window.addEventListener("message", handlePanelMessage);
@@ -694,15 +1585,17 @@ export default function AssistantEmbedPanel({
694
1585
 
695
1586
  useEffect(() => {
696
1587
  setEmptyStateAnimationKey((previous) => previous + 1);
1588
+ queueSavedScrollRestore();
697
1589
  }, [openSignal]);
698
1590
 
699
1591
  const handleStartNewChat = () => {
700
1592
  activeRequestAbortRef.current?.abort();
701
1593
  activeRequestAbortRef.current = null;
1594
+ setSharedBusy(false);
1595
+ setSharedAwaitingFirstToken(false);
1596
+ resetThreadScrollPosition([]);
702
1597
  setMessages([]);
703
1598
  setInput("");
704
- setIsBusy(false);
705
- setIsAwaitingFirstToken(false);
706
1599
  setErrorMessage("");
707
1600
  setShowUnavailableState(false);
708
1601
  };
@@ -716,13 +1609,20 @@ export default function AssistantEmbedPanel({
716
1609
  postParentMessage("assistant-embed:close");
717
1610
  };
718
1611
 
1612
+ const handlePanelSizeToggle = () => {
1613
+ const nextPanelSize =
1614
+ resolvedPanelSize === "expanded" ? "default" : "expanded";
1615
+ setSharedPanelSize(nextPanelSize);
1616
+ };
1617
+
719
1618
  const handleUnavailableBack = () => {
720
1619
  activeRequestAbortRef.current?.abort();
721
1620
  activeRequestAbortRef.current = null;
1621
+ setSharedBusy(false);
1622
+ setSharedAwaitingFirstToken(false);
1623
+ resetThreadScrollPosition([]);
722
1624
  setMessages([]);
723
1625
  setInput("");
724
- setIsBusy(false);
725
- setIsAwaitingFirstToken(false);
726
1626
  setErrorMessage("");
727
1627
  setShowUnavailableState(false);
728
1628
  };
@@ -739,8 +1639,8 @@ export default function AssistantEmbedPanel({
739
1639
  }
740
1640
 
741
1641
  setErrorMessage("");
742
- setIsBusy(true);
743
- setIsAwaitingFirstToken(true);
1642
+ setSharedBusy(true);
1643
+ setSharedAwaitingFirstToken(true);
744
1644
 
745
1645
  const userMessage: ChatMessage = {
746
1646
  id: createMessageId(),
@@ -752,6 +1652,7 @@ export default function AssistantEmbedPanel({
752
1652
 
753
1653
  setInput("");
754
1654
  setMessages(nextConversation);
1655
+ queueThreadScrollToBottom(nextConversation);
755
1656
 
756
1657
  activeRequestAbortRef.current?.abort();
757
1658
  const abortController = new AbortController();
@@ -842,7 +1743,7 @@ export default function AssistantEmbedPanel({
842
1743
  ) {
843
1744
  if (!hasReceivedFirstTextDelta) {
844
1745
  hasReceivedFirstTextDelta = true;
845
- setIsAwaitingFirstToken(false);
1746
+ setSharedAwaitingFirstToken(false);
846
1747
  }
847
1748
 
848
1749
  setMessages((previous) => {
@@ -885,8 +1786,9 @@ export default function AssistantEmbedPanel({
885
1786
  if (activeRequestAbortRef.current === abortController) {
886
1787
  activeRequestAbortRef.current = null;
887
1788
  }
888
- setIsBusy(false);
889
- setIsAwaitingFirstToken(false);
1789
+ setSharedBusy(false);
1790
+ setSharedAwaitingFirstToken(false);
1791
+ persistPanelState();
890
1792
  }
891
1793
  };
892
1794
 
@@ -915,39 +1817,73 @@ export default function AssistantEmbedPanel({
915
1817
  ".ask-ai-copy-code-button",
916
1818
  ) as HTMLButtonElement | null;
917
1819
 
918
- if (!copyButton) {
919
- return;
920
- }
1820
+ if (copyButton) {
1821
+ event.preventDefault();
1822
+ event.stopPropagation();
921
1823
 
922
- event.preventDefault();
923
- event.stopPropagation();
1824
+ if (copyButton.dataset.copying === "true") {
1825
+ return;
1826
+ }
1827
+
1828
+ const preElement = copyButton.closest("pre");
1829
+ const codeElement = preElement?.querySelector("code");
1830
+ if (!codeElement) {
1831
+ return;
1832
+ }
924
1833
 
925
- if (copyButton.dataset.copying === "true") {
1834
+ const codeText = codeElement.textContent ?? "";
1835
+ copyButton.dataset.copying = "true";
1836
+
1837
+ void (async () => {
1838
+ const didCopy = await copyToClipboard(codeText);
1839
+ if (didCopy) {
1840
+ copyButton.dataset.copied = "true";
1841
+ } else {
1842
+ delete copyButton.dataset.copied;
1843
+ }
1844
+
1845
+ window.setTimeout(() => {
1846
+ delete copyButton.dataset.copied;
1847
+ delete copyButton.dataset.copying;
1848
+ }, 1200);
1849
+ })();
926
1850
  return;
927
1851
  }
928
1852
 
929
- const preElement = copyButton.closest("pre");
930
- const codeElement = preElement?.querySelector("code");
931
- if (!codeElement) {
1853
+ const blankNavigationRequest =
1854
+ linkTarget === "blank"
1855
+ ? getSameOriginBlankNavigationRequest(event)
1856
+ : null;
1857
+ if (blankNavigationRequest) {
1858
+ event.preventDefault();
1859
+ event.stopPropagation();
1860
+ const didOpen = openBlankLinkWithHandoff(blankNavigationRequest);
1861
+ if (!didOpen && typeof window !== "undefined") {
1862
+ window.open(
1863
+ blankNavigationRequest.href,
1864
+ "_blank",
1865
+ "noopener,noreferrer",
1866
+ );
1867
+ }
932
1868
  return;
933
1869
  }
934
1870
 
935
- const codeText = codeElement.textContent ?? "";
936
- copyButton.dataset.copying = "true";
937
-
938
- void (async () => {
939
- const didCopy = await copyToClipboard(codeText);
940
- if (didCopy) {
941
- copyButton.dataset.copied = "true";
942
- } else {
943
- delete copyButton.dataset.copied;
944
- }
1871
+ const navigationRequest =
1872
+ linkTarget === "current" && onCurrentLinkNavigate
1873
+ ? getSameOriginNavigationRequest(event)
1874
+ : null;
1875
+ if (!navigationRequest) {
1876
+ return;
1877
+ }
945
1878
 
946
- window.setTimeout(() => {
947
- delete copyButton.dataset.copied;
948
- delete copyButton.dataset.copying;
949
- }, 1200);
950
- })();
1879
+ event.preventDefault();
1880
+ event.stopPropagation();
1881
+ snapshotThreadScrollPosition();
1882
+ lockScrollPersistence();
1883
+ onCurrentLinkNavigate(
1884
+ navigationRequest.href,
1885
+ navigationRequest.sourceElement,
1886
+ );
951
1887
  };
952
1888
 
953
1889
  const handleThreadViewportWheel = (event: ChatViewportWheelEvent) => {
@@ -993,6 +1929,26 @@ export default function AssistantEmbedPanel({
993
1929
  }
994
1930
  };
995
1931
 
1932
+ const handleThreadViewportScroll = (event: ChatViewportScrollEvent) => {
1933
+ if (isScrollPersistenceLockedRef.current) {
1934
+ return;
1935
+ }
1936
+
1937
+ savedScrollTopRef.current = event.currentTarget.scrollTop;
1938
+
1939
+ if (
1940
+ typeof window === "undefined" ||
1941
+ scrollPersistenceFrameRef.current !== null
1942
+ ) {
1943
+ return;
1944
+ }
1945
+
1946
+ scrollPersistenceFrameRef.current = window.requestAnimationFrame(() => {
1947
+ scrollPersistenceFrameRef.current = null;
1948
+ persistPanelState();
1949
+ });
1950
+ };
1951
+
996
1952
  const panelClassName = [
997
1953
  "relative flex min-h-0 flex-col overflow-hidden text-neutral-900 shadow-2xl dark:text-neutral-50",
998
1954
  panelSurface === "inline"
@@ -1002,8 +1958,8 @@ export default function AssistantEmbedPanel({
1002
1958
 
1003
1959
  return (
1004
1960
  <div className={panelClassName}>
1005
- <header className="flex items-center justify-between gap-3 px-2 pt-2 pb-1">
1006
- <div className="flex min-w-0 items-center gap-2 ">
1961
+ <header className="flex items-center justify-between gap-2 px-2 pt-2 pb-1">
1962
+ <div className="flex min-w-0 flex-1 items-center gap-2">
1007
1963
  <AssistantPanelIcon
1008
1964
  color={launcherIconColor}
1009
1965
  imageSrc={launcherIconImageSrc}
@@ -1017,32 +1973,61 @@ export default function AssistantEmbedPanel({
1017
1973
  Assistant
1018
1974
  </p>
1019
1975
  </div>
1976
+ {messages.length > 0 ? (
1977
+ <button
1978
+ type="button"
1979
+ className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-neutral-900/8 px-2 py-1 ml-1.5 text-[12px] text-neutral-500 transition hover:bg-neutral-900/4 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/5 dark:text-neutral-300 dark:hover:bg-white/10 cursor-pointer"
1980
+ onClick={handleStartNewChat}
1981
+ aria-label="Clear chat"
1982
+ title="Clear chat"
1983
+ >
1984
+ <Icon
1985
+ icon="lucide:trash-2"
1986
+ className="size-3.5 -ml-px"
1987
+ aria-hidden="true"
1988
+ />
1989
+ Clear
1990
+ </button>
1991
+ ) : null}
1020
1992
  </div>
1021
- {messages.length > 0 ? (
1993
+ <div className="flex shrink-0 items-center">
1994
+ {!isShellFullscreen ? (
1995
+ <button
1996
+ type="button"
1997
+ className="inline-flex items-center justify-center rounded-md size-9 rotate-90 text-[13px] text-neutral-500 hover:bg-neutral-900/5 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
1998
+ onClick={handlePanelSizeToggle}
1999
+ aria-label={
2000
+ resolvedPanelSize === "expanded"
2001
+ ? "Use default panel size"
2002
+ : "Expand panel"
2003
+ }
2004
+ title={
2005
+ resolvedPanelSize === "expanded"
2006
+ ? "Default size"
2007
+ : "Expand panel"
2008
+ }
2009
+ >
2010
+ <Icon
2011
+ icon={
2012
+ resolvedPanelSize === "expanded"
2013
+ ? "lucide:minimize-2"
2014
+ : "lucide:maximize-2"
2015
+ }
2016
+ className="size-4"
2017
+ aria-hidden="true"
2018
+ />
2019
+ </button>
2020
+ ) : null}
1022
2021
  <button
1023
2022
  type="button"
1024
- className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-neutral-900/8 px-2 py-1 text-[12px] text-neutral-500 transition hover:bg-neutral-900/4 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/5 dark:text-neutral-300 dark:hover:bg-white/10 cursor-pointer"
1025
- onClick={handleStartNewChat}
1026
- aria-label="Clear chat"
1027
- title="Clear chat"
2023
+ className="inline-flex items-center justify-center rounded-md size-9 text-[13px] text-neutral-500 hover:bg-neutral-900/5 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
2024
+ onClick={handleRequestClose}
2025
+ aria-label="Close chat"
2026
+ title="Close"
1028
2027
  >
1029
- <Icon
1030
- icon="lucide:trash-2"
1031
- className="size-3.5 -ml-px"
1032
- aria-hidden="true"
1033
- />
1034
- Clear
2028
+ <Icon icon="lucide:x" className="size-5" aria-hidden="true" />
1035
2029
  </button>
1036
- ) : null}
1037
- <button
1038
- type="button"
1039
- className="inline-flex items-center justify-center gap-1 rounded-md size-9 text-[13px] text-neutral-500 hover:bg-neutral-900/5 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
1040
- onClick={handleRequestClose}
1041
- aria-label="Close chat"
1042
- title="Close"
1043
- >
1044
- <Icon icon="lucide:x" className="size-5" aria-hidden="true" />
1045
- </button>
2030
+ </div>
1046
2031
  </header>
1047
2032
 
1048
2033
  {!showUnavailableState ? (
@@ -1050,6 +2035,7 @@ export default function AssistantEmbedPanel({
1050
2035
  <div
1051
2036
  ref={scrollViewportRef}
1052
2037
  className={`mask-t-from-[calc(100%-1rem)] flex-1 overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 py-4 ${messages.length > 0 ? "pb-60" : ""} mb-10 space-y-6`}
2038
+ onScroll={handleThreadViewportScroll}
1053
2039
  onWheel={handleThreadViewportWheel}
1054
2040
  >
1055
2041
  {messages.length === 0 ? (