@xenide-io/the-old-ui-theme 0.6.6 → 0.6.8

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.
@@ -1,41 +1,47 @@
1
- 'use client';
1
+ "use client";
2
2
 
3
3
  import {
4
4
  lazy,
5
5
  Suspense,
6
6
  useCallback,
7
7
  useEffect,
8
+ useId,
8
9
  useLayoutEffect,
9
10
  useRef,
10
11
  useState,
11
12
  type ComponentType,
12
- type KeyboardEvent as ReactKeyboardEvent,
13
- type PointerEvent as ReactPointerEvent,
13
+ type MouseEvent as ReactMouseEvent,
14
14
  type ReactNode,
15
- } from 'react';
15
+ } from "react";
16
16
  import {
17
17
  ArrowDown,
18
18
  ArrowUp,
19
- Check,
20
- Copy,
21
19
  Erase,
22
20
  Sparks,
23
21
  Square,
24
22
  WarningTriangle,
25
- Xmark as X,
26
- } from 'iconoir-react';
23
+ } from "iconoir-react";
27
24
 
28
- const SuiteAiBlockNoteMessage = lazy(() => import('./ai-message-blocknote'));
25
+ import { Tooltip } from "../../components/ui/Tooltip";
26
+ import {
27
+ classifySuiteHref,
28
+ type SuiteAppSlug,
29
+ } from "../lib/apps";
30
+
31
+ const SuiteAiBlockNoteMessage = lazy(() => import("./ai-message-blocknote"));
29
32
 
30
- export const SUITE_OPEN_ASK_AI_EVENT = 'shellstack:open-ask-ai';
33
+ export const SUITE_OPEN_ASK_AI_EVENT = "shellstack:open-ask-ai";
31
34
 
32
35
  export type SuiteAskAiOpenDetail = {
33
36
  prompt?: string;
34
37
  };
35
38
 
39
+ let pendingAskAiPrompt: string | null = null;
40
+
36
41
  /** Open the suite Ask AI panel from anywhere (Today card, command palette, …). */
37
42
  export function openSuiteAskAi(detail?: SuiteAskAiOpenDetail): void {
38
- if (typeof window === 'undefined') return;
43
+ pendingAskAiPrompt = detail?.prompt?.trim() || null;
44
+ if (typeof window === "undefined") return;
39
45
  window.dispatchEvent(
40
46
  new CustomEvent<SuiteAskAiOpenDetail>(SUITE_OPEN_ASK_AI_EVENT, {
41
47
  detail: detail ?? {},
@@ -43,104 +49,41 @@ export function openSuiteAskAi(detail?: SuiteAskAiOpenDetail): void {
43
49
  );
44
50
  }
45
51
 
52
+ /** Consume a prompt queued before the panel mounted. */
53
+ export function consumePendingAskAiPrompt(): string | null {
54
+ const next = pendingAskAiPrompt;
55
+ pendingAskAiPrompt = null;
56
+ return next;
57
+ }
58
+
46
59
  export interface SuiteAiPreset {
47
60
  label: string;
48
61
  prompt: string;
49
62
  }
50
63
 
51
64
  export interface SuiteAiChatMessage {
52
- role: 'user' | 'assistant';
65
+ role: "user" | "assistant";
53
66
  content: string;
54
67
  created_at?: string;
55
68
  }
56
69
 
57
- export type SuiteAiLauncherEdge = 'top' | 'right' | 'bottom' | 'left';
58
- type LauncherEdge = SuiteAiLauncherEdge;
59
- type LauncherPosition = { x: number; y: number };
60
-
61
- const LAUNCHER_EDGE_GAP = 12;
62
- const LAUNCHER_STORAGE_KEY = 'suite-ask-ai-launcher-position';
63
-
64
- export function resolveSuiteAiLauncherPosition(
65
- edge: LauncherEdge,
66
- desiredX: number,
67
- desiredY: number,
68
- viewportWidth: number,
69
- viewportHeight: number,
70
- launcherWidth: number,
71
- launcherHeight: number,
72
- ): LauncherPosition {
73
- const maxX = Math.max(
74
- LAUNCHER_EDGE_GAP,
75
- viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP,
76
- );
77
- const maxY = Math.max(
78
- LAUNCHER_EDGE_GAP,
79
- viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP,
80
- );
81
- const clampX = (x: number) => Math.min(Math.max(x, LAUNCHER_EDGE_GAP), maxX);
82
- const clampY = (y: number) => Math.min(Math.max(y, LAUNCHER_EDGE_GAP), maxY);
83
- switch (edge) {
84
- case 'left':
85
- return { x: LAUNCHER_EDGE_GAP, y: clampY(desiredY) };
86
- case 'right':
87
- return { x: maxX, y: clampY(desiredY) };
88
- case 'top':
89
- return { x: clampX(desiredX), y: LAUNCHER_EDGE_GAP };
90
- case 'bottom':
91
- return { x: clampX(desiredX), y: maxY };
92
- }
93
- }
94
-
95
- /** Which edge the launcher is closest to, used to dock after a drag. */
96
- export function resolveSuiteAiNearestEdge(
97
- x: number,
98
- y: number,
99
- width: number,
100
- height: number,
101
- viewportWidth: number,
102
- viewportHeight: number,
103
- ): LauncherEdge {
104
- const centerX = x + width / 2;
105
- const centerY = y + height / 2;
106
- const candidates: Array<{ edge: LauncherEdge; distance: number }> = [
107
- { edge: 'left', distance: centerX },
108
- { edge: 'right', distance: viewportWidth - centerX },
109
- { edge: 'top', distance: centerY },
110
- { edge: 'bottom', distance: viewportHeight - centerY },
111
- ];
112
- return candidates.reduce((nearest, candidate) =>
113
- candidate.distance < nearest.distance ? candidate : nearest,
114
- ).edge;
115
- }
116
-
117
- /** Which horizontal side the slide-over should open from for a docked edge. */
118
- export function resolveSuiteAiPanelSide(
119
- edge: LauncherEdge,
120
- x: number,
121
- viewportWidth: number,
122
- ): 'left' | 'right' {
123
- if (edge === 'left') return 'left';
124
- if (edge === 'right') return 'right';
125
- return x < viewportWidth / 2 ? 'left' : 'right';
126
- }
127
-
128
70
  /**
129
- * Suite-wide Ask AI slide-over — Notion-style continuous chat.
130
- *
131
- * One thread per workspace (persisted via injected fetch/send/clear helpers).
132
- * Not Kraken Deep Research. Apps mount this once in the authenticated shell.
71
+ * Suite-wide Ask AI chat — Notion-style continuous thread.
72
+ * Lives in the sidebar (or any parent that mounts it). Not Kraken Deep Research.
133
73
  */
134
74
  export function SuiteAiPanel({
135
75
  presets = [],
136
- emptyState = 'Ask anything about your workspace, or look something up online.',
137
- title = 'Ask AI',
76
+ emptyState = "Ask anything about your workspace, or look something up online.",
77
+ title = "Shelly AI",
138
78
  fetchChat,
139
79
  sendMessage,
140
80
  clearChat,
141
81
  brandIcon: BrandIcon,
142
82
  spinner: Spinner,
143
- hideLauncher = false,
83
+ open: openProp,
84
+ onOpenChange,
85
+ suiteAppBases,
86
+ onSwitchApp,
144
87
  }: {
145
88
  presets?: SuiteAiPreset[];
146
89
  emptyState?: ReactNode;
@@ -156,98 +99,49 @@ export function SuiteAiPanel({
156
99
  clearChat: () => Promise<void>;
157
100
  brandIcon?: ComponentType<{ className?: string }>;
158
101
  spinner: ComponentType<{ className?: string }>;
159
- /** When true, only Today / events open the panel (no floating button). */
160
- hideLauncher?: boolean;
102
+ open?: boolean;
103
+ onOpenChange?: (open: boolean) => void;
104
+ /** Origins for each ShellStack app — used to intercept workspace links. */
105
+ suiteAppBases?: Partial<Record<SuiteAppSlug, string>>;
106
+ /** Cross-app SSO. Same-app links just navigate to `path`. */
107
+ onSwitchApp?: (app: SuiteAppSlug, path: string) => void;
161
108
  }) {
162
- const [open, setOpen] = useState(false);
163
- const [prompt, setPrompt] = useState('');
109
+ const inputId = useId();
110
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
111
+ const open = openProp ?? uncontrolledOpen;
112
+ const setOpen = useCallback(
113
+ (next: boolean) => {
114
+ if (openProp === undefined) setUncontrolledOpen(next);
115
+ onOpenChange?.(next);
116
+ },
117
+ [onOpenChange, openProp],
118
+ );
119
+ const [prompt, setPrompt] = useState("");
164
120
  const [messages, setMessages] = useState<SuiteAiChatMessage[]>([]);
165
121
  const [loading, setLoading] = useState(false);
166
122
  const [hydrating, setHydrating] = useState(false);
167
- const [error, setError] = useState('');
168
- const [copiedId, setCopiedId] = useState<number | null>(null);
123
+ const [error, setError] = useState("");
169
124
  const [atBottom, setAtBottom] = useState(true);
170
- const [launcherSide, setLauncherSide] = useState<LauncherEdge>('right');
171
- const [panelSide, setPanelSide] = useState<'left' | 'right'>('right');
172
- const [launcherPosition, setLauncherPosition] =
173
- useState<LauncherPosition | null>(null);
174
- const [draggingLauncher, setDraggingLauncher] = useState(false);
175
125
  const scrollerRef = useRef<HTMLDivElement>(null);
176
126
  const textareaRef = useRef<HTMLTextAreaElement>(null);
177
127
  const abortRef = useRef<AbortController | null>(null);
178
- const launcherRef = useRef<HTMLButtonElement>(null);
179
- const launcherSideRef = useRef<LauncherEdge>('right');
180
- const launcherPositionRef = useRef<LauncherPosition | null>(null);
181
- const launcherDragRef = useRef<{
182
- pointerId: number;
183
- offsetX: number;
184
- offsetY: number;
185
- startX: number;
186
- startY: number;
187
- } | null>(null);
188
- const suppressLauncherClickRef = useRef(false);
189
128
  const pendingPromptRef = useRef<string | null>(null);
190
129
 
191
130
  useEffect(() => {
192
- const launcher = launcherRef.current;
193
- if (!launcher) return;
194
-
195
- let side: LauncherEdge = 'right';
196
- let desiredX = LAUNCHER_EDGE_GAP;
197
- let desiredY = window.innerHeight - launcher.offsetHeight - 20;
198
- try {
199
- const stored = JSON.parse(
200
- localStorage.getItem(LAUNCHER_STORAGE_KEY) || 'null',
201
- ) as {
202
- side?: LauncherEdge;
203
- x?: number;
204
- y?: number;
205
- } | null;
206
- if (
207
- stored?.side === 'top' ||
208
- stored?.side === 'right' ||
209
- stored?.side === 'bottom' ||
210
- stored?.side === 'left'
211
- )
212
- side = stored.side;
213
- if (typeof stored?.x === 'number') desiredX = stored.x;
214
- if (typeof stored?.y === 'number') desiredY = stored.y;
215
- } catch {
216
- // Ignore invalid saved launcher positions.
131
+ function onOpen(event: Event) {
132
+ const detail = (event as CustomEvent<SuiteAskAiOpenDetail>).detail;
133
+ const queued = detail?.prompt?.trim() || consumePendingAskAiPrompt();
134
+ setOpen(true);
135
+ if (queued) {
136
+ pendingPromptRef.current = queued;
137
+ setPrompt(queued);
138
+ }
217
139
  }
140
+ window.addEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
141
+ return () => window.removeEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
142
+ }, [setOpen]);
218
143
 
219
- launcherSideRef.current = side;
220
- setLauncherSide(side);
221
-
222
- const placeLauncher = () => {
223
- const element = launcherRef.current;
224
- if (!element) return;
225
- const next = resolveSuiteAiLauncherPosition(
226
- launcherSideRef.current,
227
- launcherPositionRef.current?.x ?? desiredX,
228
- launcherPositionRef.current?.y ?? desiredY,
229
- window.innerWidth,
230
- window.innerHeight,
231
- element.offsetWidth,
232
- element.offsetHeight,
233
- );
234
- launcherPositionRef.current = next;
235
- setLauncherPosition(next);
236
- setPanelSide(
237
- resolveSuiteAiPanelSide(
238
- launcherSideRef.current,
239
- next.x,
240
- window.innerWidth,
241
- ),
242
- );
243
- };
244
-
245
- placeLauncher();
246
- window.addEventListener('resize', placeLauncher);
247
- return () => window.removeEventListener('resize', placeLauncher);
248
- }, []);
249
-
250
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
144
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
251
145
  const el = scrollerRef.current;
252
146
  if (!el) return;
253
147
  el.scrollTo({ top: el.scrollHeight, behavior });
@@ -257,52 +151,36 @@ export function SuiteAiPanel({
257
151
  const handleScroll = useCallback(() => {
258
152
  const el = scrollerRef.current;
259
153
  if (!el) return;
260
- // Within 64px of the bottom counts as "pinned" — matches the
261
- // stick-to-bottom threshold most AI chats use for streaming reflow.
262
154
  const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
263
155
  setAtBottom(distance < 64);
264
156
  }, []);
265
157
 
266
158
  const hydrate = useCallback(async () => {
267
159
  setHydrating(true);
268
- setError('');
160
+ setError("");
269
161
  try {
270
162
  const data = await fetchChat();
271
163
  setMessages(data.messages ?? []);
272
164
  if (!data.configured) {
273
- setError('ShellStack AI is not configured for this workspace yet.');
165
+ setError("ShellStack AI is not configured for this workspace yet.");
274
166
  }
275
167
  } catch (err) {
276
168
  setError(
277
- err instanceof Error ? err.message : 'Could not load your Ask AI chat.',
169
+ err instanceof Error ? err.message : "Could not load Shelly AI.",
278
170
  );
279
171
  } finally {
280
172
  setHydrating(false);
281
173
  }
282
174
  }, [fetchChat]);
283
175
 
284
- useEffect(() => {
285
- function onOpen(event: Event) {
286
- const detail = (event as CustomEvent<SuiteAskAiOpenDetail>).detail;
287
- setOpen(true);
288
- if (detail?.prompt?.trim()) {
289
- pendingPromptRef.current = detail.prompt.trim();
290
- setPrompt(detail.prompt.trim());
291
- }
292
- }
293
- window.addEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
294
- return () => window.removeEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
295
- }, []);
296
-
297
176
  useEffect(() => {
298
177
  if (!open) return;
178
+ const queued = pendingPromptRef.current ?? consumePendingAskAiPrompt();
179
+ pendingPromptRef.current = queued;
299
180
  void hydrate().then(() => {
300
181
  const pending = pendingPromptRef.current;
301
182
  pendingPromptRef.current = null;
302
- if (pending) {
303
- // Leave prompt filled; user can send, or auto-send on next tick.
304
- setPrompt(pending);
305
- }
183
+ if (pending) setPrompt(pending);
306
184
  });
307
185
  }, [open, hydrate]);
308
186
 
@@ -310,26 +188,14 @@ export function SuiteAiPanel({
310
188
  if (open && atBottom) scrollToBottom();
311
189
  }, [messages, loading, open, atBottom, scrollToBottom]);
312
190
 
313
- // Focus the composer when the panel opens (but never steal focus mid-stream).
314
191
  useEffect(() => {
315
192
  if (open && !hydrating) textareaRef.current?.focus();
316
193
  }, [open, hydrating]);
317
194
 
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
195
  useLayoutEffect(() => {
330
196
  const el = textareaRef.current;
331
197
  if (!el) return;
332
- el.style.height = 'auto';
198
+ el.style.height = "auto";
333
199
  el.style.height = `${Math.min(el.scrollHeight, 176)}px`;
334
200
  }, [prompt, open]);
335
201
 
@@ -339,11 +205,10 @@ export function SuiteAiPanel({
339
205
  const controller = new AbortController();
340
206
  abortRef.current = controller;
341
207
  setLoading(true);
342
- setError('');
343
- setPrompt('');
208
+ setError("");
209
+ setPrompt("");
344
210
  setAtBottom(true);
345
- // Optimistic user message — replaced by server messages on success.
346
- setMessages((prev) => [...prev, { role: 'user', content: trimmed }]);
211
+ setMessages((prev) => [...prev, { role: "user", content: trimmed }]);
347
212
  try {
348
213
  const result = await sendMessage({
349
214
  prompt: trimmed,
@@ -352,11 +217,9 @@ export function SuiteAiPanel({
352
217
  if (controller.signal.aborted) return;
353
218
  setMessages(result.messages ?? []);
354
219
  } catch (err) {
355
- // A user-initiated stop is not an error — leave the optimistic
356
- // message in place and let them retry.
357
220
  if (controller.signal.aborted) return;
358
221
  setMessages((prev) =>
359
- prev[prev.length - 1]?.role === 'user' &&
222
+ prev[prev.length - 1]?.role === "user" &&
360
223
  prev[prev.length - 1]?.content === trimmed
361
224
  ? prev.slice(0, -1)
362
225
  : prev,
@@ -365,7 +228,7 @@ export function SuiteAiPanel({
365
228
  setError(
366
229
  err instanceof Error
367
230
  ? err.message
368
- : 'The AI request failed. Try again.',
231
+ : "The AI request failed. Try again.",
369
232
  );
370
233
  } finally {
371
234
  if (abortRef.current === controller) abortRef.current = null;
@@ -382,8 +245,8 @@ export function SuiteAiPanel({
382
245
  function retry() {
383
246
  const lastUser = [...messages]
384
247
  .reverse()
385
- .find((m) => m.role === 'user')?.content;
386
- setError('');
248
+ .find((message) => message.role === "user")?.content;
249
+ setError("");
387
250
  if (lastUser) void ask(lastUser);
388
251
  else void hydrate();
389
252
  }
@@ -393,457 +256,266 @@ export function SuiteAiPanel({
393
256
  try {
394
257
  await clearChat();
395
258
  setMessages([]);
396
- setError('');
259
+ setError("");
397
260
  } catch (err) {
398
261
  setError(
399
- err instanceof Error ? err.message : 'Could not clear this chat.',
262
+ err instanceof Error ? err.message : "Could not clear this chat.",
400
263
  );
401
264
  }
402
265
  }
403
266
 
404
- async function copyMessage(index: number, content: string) {
405
- try {
406
- await navigator.clipboard.writeText(content);
407
- setCopiedId(index);
408
- window.setTimeout(() => setCopiedId(null), 1500);
409
- } catch {
410
- // Clipboard unavailable.
411
- }
412
- }
413
-
414
- function saveLauncherPosition(
415
- edge: LauncherEdge,
416
- position: LauncherPosition,
417
- ) {
418
- try {
419
- localStorage.setItem(
420
- LAUNCHER_STORAGE_KEY,
421
- JSON.stringify({ side: edge, x: position.x, y: position.y }),
422
- );
423
- } catch {
424
- // Storage may be unavailable; dragging still works for this session.
267
+ function onConversationClick(event: ReactMouseEvent<HTMLDivElement>) {
268
+ if (event.defaultPrevented) return;
269
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
270
+ return;
425
271
  }
426
- }
427
-
428
- function dockLauncher(
429
- edge: LauncherEdge,
430
- desiredX: number,
431
- desiredY: number,
432
- ) {
433
- const launcher = launcherRef.current;
434
- if (!launcher) return;
435
- const next = resolveSuiteAiLauncherPosition(
436
- edge,
437
- desiredX,
438
- desiredY,
439
- window.innerWidth,
440
- window.innerHeight,
441
- launcher.offsetWidth,
442
- launcher.offsetHeight,
443
- );
444
- launcherSideRef.current = edge;
445
- launcherPositionRef.current = next;
446
- setLauncherSide(edge);
447
- setLauncherPosition(next);
448
- setPanelSide(resolveSuiteAiPanelSide(edge, next.x, window.innerWidth));
449
- saveLauncherPosition(edge, next);
450
- }
451
-
452
- function startLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
453
- if (event.pointerType === 'mouse' && event.button !== 0) return;
454
- const rect = event.currentTarget.getBoundingClientRect();
455
- launcherDragRef.current = {
456
- pointerId: event.pointerId,
457
- offsetX: event.clientX - rect.left,
458
- offsetY: event.clientY - rect.top,
459
- startX: event.clientX,
460
- startY: event.clientY,
461
- };
462
- suppressLauncherClickRef.current = false;
463
- event.currentTarget.setPointerCapture(event.pointerId);
464
- setDraggingLauncher(true);
465
- }
466
-
467
- function moveLauncher(event: ReactPointerEvent<HTMLButtonElement>) {
468
- const drag = launcherDragRef.current;
469
- if (!drag || drag.pointerId !== event.pointerId) return;
470
- const launcher = event.currentTarget;
471
- const maxX = Math.max(
472
- LAUNCHER_EDGE_GAP,
473
- window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP,
474
- );
475
- const maxY = Math.max(
476
- LAUNCHER_EDGE_GAP,
477
- window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP,
272
+ const anchor = (event.target as HTMLElement | null)?.closest("a");
273
+ if (!anchor || !suiteAppBases) return;
274
+ const href = anchor.href;
275
+ if (!href) return;
276
+ const match = classifySuiteHref(
277
+ href,
278
+ suiteAppBases,
279
+ window.location.origin,
478
280
  );
479
- const next = {
480
- x: Math.min(
481
- Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
482
- maxX,
483
- ),
484
- y: Math.min(
485
- Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
486
- maxY,
487
- ),
488
- };
489
- if (
490
- Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4
491
- ) {
492
- suppressLauncherClickRef.current = true;
493
- }
494
- launcherPositionRef.current = next;
495
- setLauncherPosition(next);
496
- }
497
-
498
- function finishLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
499
- const drag = launcherDragRef.current;
500
- if (!drag || drag.pointerId !== event.pointerId) return;
501
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
502
- event.currentTarget.releasePointerCapture(event.pointerId);
281
+ if (!match) return;
282
+ event.preventDefault();
283
+ if (match.sameApp) {
284
+ window.location.assign(match.path);
285
+ return;
503
286
  }
504
- launcherDragRef.current = null;
505
- setDraggingLauncher(false);
506
- const rect = event.currentTarget.getBoundingClientRect();
507
- const currentX = launcherPositionRef.current?.x ?? rect.left;
508
- const currentY = launcherPositionRef.current?.y ?? rect.top;
509
- const edge = resolveSuiteAiNearestEdge(
510
- currentX,
511
- currentY,
512
- rect.width,
513
- rect.height,
514
- window.innerWidth,
515
- window.innerHeight,
516
- );
517
- dockLauncher(edge, currentX, currentY);
287
+ if (onSwitchApp) onSwitchApp(match.slug, match.path);
288
+ else window.location.assign(anchor.href);
518
289
  }
519
290
 
520
- function moveLauncherWithKeyboard(
521
- event: ReactKeyboardEvent<HTMLButtonElement>,
522
- ) {
523
- const edgeByKey: Record<string, LauncherEdge> = {
524
- ArrowLeft: 'left',
525
- ArrowRight: 'right',
526
- ArrowUp: 'top',
527
- ArrowDown: 'bottom',
528
- };
529
- const edge = edgeByKey[event.key];
530
- if (!edge) return;
531
- event.preventDefault();
532
- const rect = event.currentTarget.getBoundingClientRect();
533
- const currentX = launcherPositionRef.current?.x ?? rect.left;
534
- const currentY = launcherPositionRef.current?.y ?? rect.top;
535
- dockLauncher(edge, currentX, currentY);
536
- }
291
+ if (!open) return null;
537
292
 
538
293
  const Icon = BrandIcon ?? Sparks;
539
294
 
540
295
  return (
541
- <>
542
- {!hideLauncher ? (
543
- <>
296
+ <aside
297
+ className="flex h-full min-h-0 flex-col bg-ph-canvas"
298
+ data-test="suite-ask-ai-panel"
299
+ aria-label={title}
300
+ >
301
+ <header className="flex items-center gap-2 px-2 pb-2">
302
+ <h2 className="sr-only">{title}</h2>
303
+ {messages.length > 0 ? (
544
304
  <button
545
- ref={launcherRef}
546
305
  type="button"
547
- data-test="ai-panel-launcher"
548
- onClick={() => {
549
- if (suppressLauncherClickRef.current) {
550
- suppressLauncherClickRef.current = false;
551
- return;
552
- }
553
- setOpen(true);
554
- }}
555
- onPointerDown={startLauncherDrag}
556
- onPointerMove={moveLauncher}
557
- onPointerUp={finishLauncherDrag}
558
- onPointerCancel={finishLauncherDrag}
559
- onKeyDown={moveLauncherWithKeyboard}
560
- className={`fixed z-30 flex touch-none select-none items-center gap-2 rounded-full border border-ph-border bg-ph-surface px-4 py-2.5 text-sm font-medium text-ph-ink shadow-ph hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand ${draggingLauncher ? 'cursor-grabbing' : 'cursor-grab'}`}
561
- style={
562
- launcherPosition
563
- ? { left: launcherPosition.x, top: launcherPosition.y }
564
- : { bottom: 20, right: 20 }
565
- }
566
- aria-label="Open Ask AI"
567
- aria-describedby="suite-ask-ai-launcher-help"
306
+ onClick={() => void handleClear()}
307
+ disabled={loading}
308
+ className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg px-2 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"
309
+ data-test="ask-ai-clear"
568
310
  >
569
- <Icon className="h-4 w-4 text-ph-brand" />
570
- Ask AI
311
+ <Erase className="h-3.5 w-3.5" />
312
+ Clear
571
313
  </button>
572
- <span id="suite-ask-ai-launcher-help" className="sr-only">
573
- Drag to reposition, or use arrow keys. The launcher docks to the
574
- nearest screen edge.
575
- </span>
576
- </>
577
- ) : null}
578
-
579
- {open ? (
314
+ ) : null}
315
+ </header>
316
+
317
+ <div className="relative min-h-0 flex-1">
580
318
  <div
581
- className="fixed inset-0 z-40"
582
- role="dialog"
583
- aria-modal="true"
584
- aria-label={title}
319
+ ref={scrollerRef}
320
+ onScroll={handleScroll}
321
+ onClick={onConversationClick}
322
+ role="log"
323
+ aria-live="polite"
324
+ aria-label="Conversation"
325
+ className="suite-scroll-lock h-full space-y-5 overflow-y-auto px-2 py-3"
585
326
  >
586
- <button
587
- type="button"
588
- className="absolute inset-0 bg-ph-ink/25"
589
- onClick={() => setOpen(false)}
590
- aria-label="Close Ask AI"
591
- tabIndex={-1}
592
- />
593
- <aside
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'}`}
595
- data-test="suite-ask-ai-panel"
596
- >
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 ? (
603
- <button
604
- type="button"
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"
609
- >
610
- <Erase className="h-3.5 w-3.5" />
611
- Clear
612
- </button>
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}
640
-
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}
673
-
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"
327
+ {hydrating && messages.length === 0 ? (
328
+ <div className="flex items-center gap-2 text-sm text-ph-subtle">
329
+ <Spinner className="h-4 w-4 animate-spin" />
330
+ Loading your chat…
331
+ </div>
332
+ ) : null}
333
+
334
+ {!hydrating && messages.length === 0 && !error ? (
335
+ <div className="mx-auto mt-4 flex max-w-sm flex-col items-center px-1 text-center">
336
+ <span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-ph-brand/10 text-ph-brand">
337
+ <Icon className="h-5 w-5" />
338
+ </span>
339
+ <p className="mt-3 font-display text-base font-semibold text-ph-ink">
340
+ How can I help?
341
+ </p>
342
+ <p className="mt-1 text-sm leading-relaxed text-ph-subtle">
343
+ {emptyState}
344
+ </p>
345
+ {presets.length > 0 ? (
346
+ <div className="mt-4 flex w-full flex-col gap-2">
347
+ {presets.map((preset) => (
348
+ <button
349
+ key={preset.label}
350
+ type="button"
351
+ onClick={() => void ask(preset.prompt)}
352
+ disabled={loading || hydrating}
353
+ className="group flex w-full items-center gap-2 rounded-xl border border-ph-border bg-ph-surface px-3 py-2 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"
694
354
  >
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" />
355
+ <Sparks className="h-4 w-4 shrink-0 text-ph-brand" />
356
+ <span className="min-w-0 flex-1 truncate">
357
+ {preset.label}
700
358
  </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>
359
+ <ArrowUp className="h-3.5 w-3.5 shrink-0 rotate-45 text-ph-mutedtext transition-transform group-hover:translate-x-0.5" />
360
+ </button>
361
+ ))}
362
+ </div>
363
+ ) : null}
364
+ </div>
365
+ ) : null}
366
+
367
+ {messages.map((message, index) => {
368
+ const isUser = message.role === "user";
369
+ if (isUser) {
370
+ return (
371
+ <div
372
+ key={`user-${index}`}
373
+ className="flex justify-end"
374
+ data-test="ask-ai-user"
375
+ >
376
+ <div className="w-fit max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-ph-brand px-3 py-2 text-sm leading-relaxed text-[var(--ph-on-accent)] [overflow-wrap:anywhere]">
377
+ {message.content}
744
378
  </div>
745
- ) : null}
746
-
747
- {loading ? (
748
- <span className="sr-only" aria-live="polite">
749
- Assistant is responding…
750
- </span>
751
- ) : null}
752
-
753
- {error ? (
754
- <div
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"
757
- >
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
379
+ </div>
380
+ );
381
+ }
382
+ return (
383
+ <div
384
+ key={`assistant-${index}`}
385
+ className="flex items-end gap-2"
386
+ data-test="ask-ai-assistant"
387
+ >
388
+ <span
389
+ className="mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand"
390
+ aria-hidden
391
+ >
392
+ <Icon className="h-3.5 w-3.5" />
393
+ </span>
394
+ <div className="min-w-0 max-w-[85%] rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink">
395
+ <Suspense
396
+ fallback={
397
+ <p className="whitespace-pre-wrap break-words">
398
+ {message.content}
762
399
  </p>
763
- <p className="mt-0.5 text-ph-subtle">{error}</p>
764
- <button
765
- type="button"
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"
769
- >
770
- Try again
771
- </button>
772
- </div>
773
- </div>
774
- ) : null}
400
+ }
401
+ >
402
+ <SuiteAiBlockNoteMessage markdown={message.content} />
403
+ </Suspense>
404
+ </div>
775
405
  </div>
776
-
777
- {!atBottom ? (
406
+ );
407
+ })}
408
+
409
+ {loading ? (
410
+ <div className="flex items-end gap-2" aria-hidden>
411
+ <span className="mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand">
412
+ <Icon className="h-3.5 w-3.5" />
413
+ </span>
414
+ <div className="flex items-center gap-1.5 rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2.5">
415
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce" />
416
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.15s]" />
417
+ <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.3s]" />
418
+ </div>
419
+ </div>
420
+ ) : null}
421
+
422
+ {loading ? (
423
+ <span className="sr-only" aria-live="polite">
424
+ Assistant is responding…
425
+ </span>
426
+ ) : null}
427
+
428
+ {error ? (
429
+ <div
430
+ role="alert"
431
+ className="flex items-start gap-2 rounded-xl border border-ph-danger/30 bg-ph-danger/10 px-3 py-2.5 text-sm text-ph-ink"
432
+ >
433
+ <WarningTriangle className="mt-0.5 h-4 w-4 shrink-0 text-ph-danger" />
434
+ <div className="min-w-0 flex-1">
435
+ <p className="font-medium text-ph-danger">Something went wrong</p>
436
+ <p className="mt-0.5 text-ph-subtle">{error}</p>
778
437
  <button
779
438
  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"
439
+ onClick={retry}
440
+ disabled={loading}
441
+ 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"
783
442
  >
784
- <ArrowDown className="h-4 w-4" />
443
+ Try again
785
444
  </button>
786
- ) : null}
787
- </div>
788
-
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>
795
- <textarea
796
- id="suite-ask-ai-input"
797
- ref={textareaRef}
798
- value={prompt}
799
- onChange={(e) => setPrompt(e.target.value)}
800
- onKeyDown={(e) => {
801
- if (
802
- e.key === 'Enter' &&
803
- !e.shiftKey &&
804
- !e.nativeEvent.isComposing
805
- ) {
806
- e.preventDefault();
807
- void ask(prompt);
808
- }
809
- }}
810
- placeholder="Ask anything…"
811
- rows={1}
812
- data-test="ask-ai-input"
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"
814
- />
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
- )}
837
445
  </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>
843
446
  </div>
844
- </aside>
447
+ ) : null}
448
+ </div>
449
+
450
+ {!atBottom ? (
451
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2">
452
+ <Tooltip content="Scroll to latest">
453
+ <button
454
+ type="button"
455
+ onClick={() => scrollToBottom("smooth")}
456
+ className="flex h-9 w-9 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"
457
+ aria-label="Scroll to latest"
458
+ >
459
+ <ArrowDown className="h-4 w-4" />
460
+ </button>
461
+ </Tooltip>
462
+ </div>
463
+ ) : null}
464
+ </div>
465
+
466
+ <div className="px-2 pt-2 pb-[max(2rem,calc(env(safe-area-inset-bottom)+1rem))]">
467
+ <div className="flex items-end gap-1.5 rounded-2xl border border-ph-border bg-ph-surface px-1.5 py-1 transition-colors focus-within:border-ph-brand/45 focus-within:ring-2 focus-within:ring-ph-focus/35">
468
+ <label htmlFor={inputId} className="sr-only">
469
+ Ask a question
470
+ </label>
471
+ <textarea
472
+ id={inputId}
473
+ ref={textareaRef}
474
+ value={prompt}
475
+ onChange={(event) => setPrompt(event.target.value)}
476
+ onKeyDown={(event) => {
477
+ if (
478
+ event.key === "Enter" &&
479
+ !event.shiftKey &&
480
+ !event.nativeEvent.isComposing
481
+ ) {
482
+ event.preventDefault();
483
+ void ask(prompt);
484
+ }
485
+ }}
486
+ placeholder="Ask anything…"
487
+ rows={1}
488
+ data-test="ask-ai-input"
489
+ className="max-h-36 min-h-10 flex-1 resize-none bg-transparent px-1.5 py-2 text-sm leading-relaxed text-ph-ink outline-none placeholder:text-ph-mutedtext"
490
+ />
491
+ {loading ? (
492
+ <Tooltip content="Stop generating">
493
+ <button
494
+ type="button"
495
+ onClick={stop}
496
+ data-test="ask-ai-stop"
497
+ className="flex h-9 w-9 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"
498
+ aria-label="Stop generating"
499
+ >
500
+ <Square className="h-3.5 w-3.5 fill-current" />
501
+ </button>
502
+ </Tooltip>
503
+ ) : (
504
+ <Tooltip content="Send message">
505
+ <button
506
+ type="button"
507
+ onClick={() => void ask(prompt)}
508
+ disabled={!prompt.trim()}
509
+ data-test="ask-ai-send"
510
+ className="flex h-9 w-9 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"
511
+ aria-label="Send message"
512
+ >
513
+ <ArrowUp className="h-4 w-4" />
514
+ </button>
515
+ </Tooltip>
516
+ )}
845
517
  </div>
846
- ) : null}
847
- </>
518
+ </div>
519
+ </aside>
848
520
  );
849
521
  }