@xenide-io/the-old-ui-theme 0.4.4 → 0.4.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.
Files changed (76) hide show
  1. package/dist/{chunk-54ZR4VL5.mjs → chunk-RZXHZWUB.mjs} +44 -17
  2. package/dist/{index-B5NOyoKS.d.mts → index-Od4pIP4F.d.mts} +7 -1
  3. package/dist/{index-B5NOyoKS.d.ts → index-Od4pIP4F.d.ts} +7 -1
  4. package/dist/index.d.mts +2 -2
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +37 -10
  7. package/dist/index.mjs +1 -1
  8. package/dist/suite.d.mts +152 -41
  9. package/dist/suite.d.ts +152 -41
  10. package/dist/suite.js +1366 -671
  11. package/dist/suite.mjs +1304 -610
  12. package/dist/ui.d.mts +1 -1
  13. package/dist/ui.d.ts +1 -1
  14. package/dist/ui.js +37 -10
  15. package/dist/ui.mjs +1 -1
  16. package/package.json +7 -3
  17. package/src/components/ui/Modal.tsx +70 -26
  18. package/src/styles/suite-skin.css +284 -24
  19. package/src/suite/components/ai-panel.test.ts +20 -0
  20. package/src/suite/components/ai-panel.tsx +494 -103
  21. package/src/suite/components/suite-app-layout.tsx +48 -28
  22. package/src/suite/components/suite-layout.tsx +84 -40
  23. package/src/suite/components/suite-mobile-drawer.tsx +35 -22
  24. package/src/suite/components/suite-notification-bell.tsx +9 -3
  25. package/src/suite/components/suite-settings-layout.tsx +82 -0
  26. package/src/suite/components/suite-settings-mobile-nav.tsx +18 -17
  27. package/src/suite/components/suite-skeleton.tsx +3 -3
  28. package/src/suite/components/today-calibrating.tsx +11 -35
  29. package/src/suite/components/today-page-frame.tsx +19 -11
  30. package/src/suite/components/today-ui.tsx +276 -19
  31. package/src/suite/index.ts +41 -25
  32. package/src/suite/lib/apps.ts +32 -2
  33. package/src/suite/lib/use-sidebar-width.ts +114 -0
  34. package/src/components/sections/AccordionShowcase.tsx +0 -45
  35. package/src/components/sections/AlertShowcase.tsx +0 -40
  36. package/src/components/sections/AvatarShowcase.tsx +0 -80
  37. package/src/components/sections/BadgeShowcase.tsx +0 -65
  38. package/src/components/sections/ButtonShowcase.tsx +0 -308
  39. package/src/components/sections/CalendarShowcase.tsx +0 -35
  40. package/src/components/sections/CardShowcase.tsx +0 -159
  41. package/src/components/sections/CodeMockupShowcase.tsx +0 -56
  42. package/src/components/sections/ColorPalette.tsx +0 -117
  43. package/src/components/sections/CommandPaletteShowcase.tsx +0 -48
  44. package/src/components/sections/CountdownShowcase.tsx +0 -43
  45. package/src/components/sections/DiffShowcase.tsx +0 -70
  46. package/src/components/sections/DrawerShowcase.tsx +0 -97
  47. package/src/components/sections/DropdownShowcase.tsx +0 -98
  48. package/src/components/sections/EmptyStateShowcase.tsx +0 -50
  49. package/src/components/sections/FilterChipsShowcase.tsx +0 -98
  50. package/src/components/sections/FormShowcase.tsx +0 -127
  51. package/src/components/sections/HoverCardShowcase.tsx +0 -50
  52. package/src/components/sections/IconShowcase.tsx +0 -121
  53. package/src/components/sections/IndicatorShowcase.tsx +0 -52
  54. package/src/components/sections/KbdShowcase.tsx +0 -57
  55. package/src/components/sections/ModalShowcase.tsx +0 -211
  56. package/src/components/sections/NavigationShowcase.tsx +0 -199
  57. package/src/components/sections/NewComponentsShowcase.tsx +0 -1052
  58. package/src/components/sections/ProductLayoutsShowcase.tsx +0 -78
  59. package/src/components/sections/ProgressShowcase.tsx +0 -82
  60. package/src/components/sections/QuillChartShowcase.tsx +0 -92
  61. package/src/components/sections/QuillDashboardShowcase.tsx +0 -149
  62. package/src/components/sections/SegmentedControlShowcase.tsx +0 -49
  63. package/src/components/sections/SetupThemeShowcase.tsx +0 -262
  64. package/src/components/sections/SidebarShowcase.tsx +0 -152
  65. package/src/components/sections/StackShowcase.tsx +0 -33
  66. package/src/components/sections/StepperShowcase.tsx +0 -37
  67. package/src/components/sections/SuiteShowcase.tsx +0 -669
  68. package/src/components/sections/SwapShowcase.tsx +0 -99
  69. package/src/components/sections/TabShowcase.tsx +0 -84
  70. package/src/components/sections/TableShowcase.tsx +0 -142
  71. package/src/components/sections/TimelineShowcase.tsx +0 -83
  72. package/src/components/sections/ToastShowcase.tsx +0 -108
  73. package/src/components/sections/TooltipShowcase.tsx +0 -38
  74. package/src/components/sections/UtilityShowcase.tsx +0 -81
  75. package/src/styles/excel-themes.css +0 -110
  76. package/src/styles/lemon-primitives.css +0 -550
@@ -1,60 +1,245 @@
1
1
  'use client';
2
2
 
3
- import { useState, type ComponentType, type ReactNode } from 'react';
4
- import { Check, Copy, Xmark as X } from 'iconoir-react';
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ComponentType,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { Check, Copy, Sparks, Xmark as X } from 'iconoir-react';
14
+
15
+ export const SUITE_OPEN_ASK_AI_EVENT = 'shellstack:open-ask-ai';
16
+
17
+ export type SuiteAskAiOpenDetail = {
18
+ prompt?: string;
19
+ };
20
+
21
+ /** Open the suite Ask AI panel from anywhere (Today card, command palette, …). */
22
+ export function openSuiteAskAi(detail?: SuiteAskAiOpenDetail): void {
23
+ if (typeof window === 'undefined') return;
24
+ window.dispatchEvent(
25
+ new CustomEvent<SuiteAskAiOpenDetail>(SUITE_OPEN_ASK_AI_EVENT, {
26
+ detail: detail ?? {},
27
+ }),
28
+ );
29
+ }
5
30
 
6
31
  export interface SuiteAiPreset {
7
32
  label: string;
8
33
  prompt: string;
9
34
  }
10
35
 
36
+ export interface SuiteAiChatMessage {
37
+ role: 'user' | 'assistant';
38
+ content: string;
39
+ created_at?: string;
40
+ }
41
+
42
+ type LauncherSide = 'left' | 'right';
43
+ type LauncherPosition = { x: number; y: number };
44
+
45
+ const LAUNCHER_EDGE_GAP = 12;
46
+ const LAUNCHER_STORAGE_KEY = 'suite-ask-ai-launcher-position';
47
+
48
+ export function resolveSuiteAiLauncherPosition(
49
+ side: LauncherSide,
50
+ desiredY: number,
51
+ viewportWidth: number,
52
+ viewportHeight: number,
53
+ launcherWidth: number,
54
+ launcherHeight: number,
55
+ ): LauncherPosition {
56
+ const maxX = Math.max(
57
+ LAUNCHER_EDGE_GAP,
58
+ viewportWidth - launcherWidth - LAUNCHER_EDGE_GAP,
59
+ );
60
+ const maxY = Math.max(
61
+ LAUNCHER_EDGE_GAP,
62
+ viewportHeight - launcherHeight - LAUNCHER_EDGE_GAP,
63
+ );
64
+ return {
65
+ x: side === 'left' ? LAUNCHER_EDGE_GAP : maxX,
66
+ y: Math.min(Math.max(desiredY, LAUNCHER_EDGE_GAP), maxY),
67
+ };
68
+ }
69
+
11
70
  /**
12
- * Thin ShellStack AI slide-over shared by suite apps.
71
+ * Suite-wide Ask AI slide-over Notion-style continuous chat.
13
72
  *
14
- * One-shot completions via the shared `/api/ai/complete` endpoint. Apps
15
- * inject their API helpers, preset prompts, brand icon and spinner. Renders
16
- * its own floating launcher so it can be mounted once in the authenticated
17
- * app shell.
73
+ * One thread per workspace (persisted via injected fetch/send/clear helpers).
74
+ * Not Kraken Deep Research. Apps mount this once in the authenticated shell.
18
75
  */
19
76
  export function SuiteAiPanel({
20
- presets,
21
- emptyState,
22
- fetchStatus,
23
- complete,
77
+ presets = [],
78
+ emptyState = 'Ask anything about your workspace, or look something up online.',
79
+ title = 'Ask AI',
80
+ fetchChat,
81
+ sendMessage,
82
+ clearChat,
24
83
  brandIcon: BrandIcon,
25
84
  spinner: Spinner,
85
+ hideLauncher = false,
26
86
  }: {
27
- presets: SuiteAiPreset[];
28
- /** Idle-state hint copy shown before the first request. */
29
- emptyState: ReactNode;
30
- fetchStatus: () => Promise<{ configured: boolean }>;
31
- complete: (params: { prompt: string }) => Promise<{ text: string }>;
32
- brandIcon: ComponentType<{ className?: string }>;
87
+ presets?: SuiteAiPreset[];
88
+ emptyState?: ReactNode;
89
+ title?: string;
90
+ fetchChat: () => Promise<{
91
+ messages: SuiteAiChatMessage[];
92
+ configured: boolean;
93
+ }>;
94
+ sendMessage: (params: {
95
+ prompt: string;
96
+ }) => Promise<{ messages: SuiteAiChatMessage[]; reply: string }>;
97
+ clearChat: () => Promise<void>;
98
+ brandIcon?: ComponentType<{ className?: string }>;
33
99
  spinner: ComponentType<{ className?: string }>;
100
+ /** When true, only Today / events open the panel (no floating button). */
101
+ hideLauncher?: boolean;
34
102
  }) {
35
103
  const [open, setOpen] = useState(false);
36
104
  const [prompt, setPrompt] = useState('');
105
+ const [messages, setMessages] = useState<SuiteAiChatMessage[]>([]);
37
106
  const [loading, setLoading] = useState(false);
38
- const [answer, setAnswer] = useState('');
107
+ const [hydrating, setHydrating] = useState(false);
39
108
  const [error, setError] = useState('');
40
- const [copied, setCopied] = useState(false);
109
+ const [copiedId, setCopiedId] = useState<number | null>(null);
110
+ const [launcherSide, setLauncherSide] = useState<LauncherSide>('right');
111
+ const [launcherPosition, setLauncherPosition] =
112
+ useState<LauncherPosition | null>(null);
113
+ const [draggingLauncher, setDraggingLauncher] = useState(false);
114
+ const scrollerRef = useRef<HTMLDivElement>(null);
115
+ const launcherRef = useRef<HTMLButtonElement>(null);
116
+ const launcherSideRef = useRef<LauncherSide>('right');
117
+ const launcherPositionRef = useRef<LauncherPosition | null>(null);
118
+ const launcherDragRef = useRef<{
119
+ pointerId: number;
120
+ offsetX: number;
121
+ offsetY: number;
122
+ startX: number;
123
+ startY: number;
124
+ } | null>(null);
125
+ const suppressLauncherClickRef = useRef(false);
126
+ const pendingPromptRef = useRef<string | null>(null);
127
+
128
+ useEffect(() => {
129
+ const launcher = launcherRef.current;
130
+ if (!launcher) return;
131
+
132
+ let side: LauncherSide = 'right';
133
+ let desiredY = window.innerHeight - launcher.offsetHeight - 20;
134
+ try {
135
+ const stored = JSON.parse(
136
+ localStorage.getItem(LAUNCHER_STORAGE_KEY) || 'null',
137
+ ) as {
138
+ side?: LauncherSide;
139
+ y?: number;
140
+ } | null;
141
+ if (stored?.side === 'left' || stored?.side === 'right')
142
+ side = stored.side;
143
+ if (typeof stored?.y === 'number') desiredY = stored.y;
144
+ } catch {
145
+ // Ignore invalid saved launcher positions.
146
+ }
147
+
148
+ launcherSideRef.current = side;
149
+ setLauncherSide(side);
150
+
151
+ const placeLauncher = () => {
152
+ const element = launcherRef.current;
153
+ if (!element) return;
154
+ const next = resolveSuiteAiLauncherPosition(
155
+ launcherSideRef.current,
156
+ launcherPositionRef.current?.y ?? desiredY,
157
+ window.innerWidth,
158
+ window.innerHeight,
159
+ element.offsetWidth,
160
+ element.offsetHeight,
161
+ );
162
+ launcherPositionRef.current = next;
163
+ setLauncherPosition(next);
164
+ };
165
+
166
+ placeLauncher();
167
+ window.addEventListener('resize', placeLauncher);
168
+ return () => window.removeEventListener('resize', placeLauncher);
169
+ }, []);
170
+
171
+ const scrollToBottom = useCallback(() => {
172
+ const el = scrollerRef.current;
173
+ if (!el) return;
174
+ el.scrollTop = el.scrollHeight;
175
+ }, []);
176
+
177
+ const hydrate = useCallback(async () => {
178
+ setHydrating(true);
179
+ setError('');
180
+ try {
181
+ const data = await fetchChat();
182
+ setMessages(data.messages ?? []);
183
+ if (!data.configured) {
184
+ setError('ShellStack AI is not configured for this workspace yet.');
185
+ }
186
+ } catch (err) {
187
+ setError(
188
+ err instanceof Error ? err.message : 'Could not load your Ask AI chat.',
189
+ );
190
+ } finally {
191
+ setHydrating(false);
192
+ }
193
+ }, [fetchChat]);
194
+
195
+ useEffect(() => {
196
+ function onOpen(event: Event) {
197
+ const detail = (event as CustomEvent<SuiteAskAiOpenDetail>).detail;
198
+ setOpen(true);
199
+ if (detail?.prompt?.trim()) {
200
+ pendingPromptRef.current = detail.prompt.trim();
201
+ setPrompt(detail.prompt.trim());
202
+ }
203
+ }
204
+ window.addEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
205
+ return () => window.removeEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
206
+ }, []);
207
+
208
+ useEffect(() => {
209
+ if (!open) return;
210
+ void hydrate().then(() => {
211
+ const pending = pendingPromptRef.current;
212
+ pendingPromptRef.current = null;
213
+ if (pending) {
214
+ // Leave prompt filled; user can send, or auto-send on next tick.
215
+ setPrompt(pending);
216
+ }
217
+ });
218
+ }, [open, hydrate]);
219
+
220
+ useEffect(() => {
221
+ if (open) scrollToBottom();
222
+ }, [messages, loading, open, scrollToBottom]);
41
223
 
42
224
  async function ask(text: string) {
43
225
  const trimmed = text.trim();
44
226
  if (!trimmed || loading) return;
45
227
  setLoading(true);
46
228
  setError('');
47
- setAnswer('');
48
- setCopied(false);
229
+ setPrompt('');
230
+ // Optimistic user bubble — replaced by server messages on success.
231
+ setMessages((prev) => [...prev, { role: 'user', content: trimmed }]);
49
232
  try {
50
- const status = await fetchStatus();
51
- if (!status.configured) {
52
- setError('ShellStack AI is not configured for this workspace yet.');
53
- return;
54
- }
55
- const result = await complete({ prompt: trimmed });
56
- setAnswer(result.text);
233
+ const result = await sendMessage({ prompt: trimmed });
234
+ setMessages(result.messages ?? []);
57
235
  } catch (err) {
236
+ setMessages((prev) =>
237
+ prev[prev.length - 1]?.role === 'user' &&
238
+ prev[prev.length - 1]?.content === trimmed
239
+ ? prev.slice(0, -1)
240
+ : prev,
241
+ );
242
+ setPrompt(trimmed);
58
243
  setError(
59
244
  err instanceof Error
60
245
  ? err.message
@@ -65,107 +250,311 @@ export function SuiteAiPanel({
65
250
  }
66
251
  }
67
252
 
68
- async function copyAnswer() {
69
- if (!answer) return;
253
+ async function handleClear() {
254
+ if (loading) return;
70
255
  try {
71
- await navigator.clipboard.writeText(answer);
72
- setCopied(true);
73
- window.setTimeout(() => setCopied(false), 1500);
256
+ await clearChat();
257
+ setMessages([]);
258
+ setError('');
259
+ } catch (err) {
260
+ setError(
261
+ err instanceof Error ? err.message : 'Could not clear this chat.',
262
+ );
263
+ }
264
+ }
265
+
266
+ async function copyMessage(index: number, content: string) {
267
+ try {
268
+ await navigator.clipboard.writeText(content);
269
+ setCopiedId(index);
270
+ window.setTimeout(() => setCopiedId(null), 1500);
74
271
  } catch {
75
- // Clipboard unavailable; ignore.
272
+ // Clipboard unavailable.
76
273
  }
77
274
  }
78
275
 
276
+ function saveLauncherPosition(
277
+ side: LauncherSide,
278
+ position: LauncherPosition,
279
+ ) {
280
+ try {
281
+ localStorage.setItem(
282
+ LAUNCHER_STORAGE_KEY,
283
+ JSON.stringify({ side, y: position.y }),
284
+ );
285
+ } catch {
286
+ // Storage may be unavailable; dragging still works for this session.
287
+ }
288
+ }
289
+
290
+ function dockLauncher(side: LauncherSide, desiredY: number) {
291
+ const launcher = launcherRef.current;
292
+ if (!launcher) return;
293
+ const next = resolveSuiteAiLauncherPosition(
294
+ side,
295
+ desiredY,
296
+ window.innerWidth,
297
+ window.innerHeight,
298
+ launcher.offsetWidth,
299
+ launcher.offsetHeight,
300
+ );
301
+ launcherSideRef.current = side;
302
+ launcherPositionRef.current = next;
303
+ setLauncherSide(side);
304
+ setLauncherPosition(next);
305
+ saveLauncherPosition(side, next);
306
+ }
307
+
308
+ function startLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
309
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
310
+ const rect = event.currentTarget.getBoundingClientRect();
311
+ launcherDragRef.current = {
312
+ pointerId: event.pointerId,
313
+ offsetX: event.clientX - rect.left,
314
+ offsetY: event.clientY - rect.top,
315
+ startX: event.clientX,
316
+ startY: event.clientY,
317
+ };
318
+ suppressLauncherClickRef.current = false;
319
+ event.currentTarget.setPointerCapture(event.pointerId);
320
+ setDraggingLauncher(true);
321
+ }
322
+
323
+ function moveLauncher(event: ReactPointerEvent<HTMLButtonElement>) {
324
+ const drag = launcherDragRef.current;
325
+ if (!drag || drag.pointerId !== event.pointerId) return;
326
+ const launcher = event.currentTarget;
327
+ const maxX = Math.max(
328
+ LAUNCHER_EDGE_GAP,
329
+ window.innerWidth - launcher.offsetWidth - LAUNCHER_EDGE_GAP,
330
+ );
331
+ const maxY = Math.max(
332
+ LAUNCHER_EDGE_GAP,
333
+ window.innerHeight - launcher.offsetHeight - LAUNCHER_EDGE_GAP,
334
+ );
335
+ const next = {
336
+ x: Math.min(
337
+ Math.max(event.clientX - drag.offsetX, LAUNCHER_EDGE_GAP),
338
+ maxX,
339
+ ),
340
+ y: Math.min(
341
+ Math.max(event.clientY - drag.offsetY, LAUNCHER_EDGE_GAP),
342
+ maxY,
343
+ ),
344
+ };
345
+ if (
346
+ Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4
347
+ ) {
348
+ suppressLauncherClickRef.current = true;
349
+ }
350
+ launcherPositionRef.current = next;
351
+ setLauncherPosition(next);
352
+ }
353
+
354
+ function finishLauncherDrag(event: ReactPointerEvent<HTMLButtonElement>) {
355
+ const drag = launcherDragRef.current;
356
+ if (!drag || drag.pointerId !== event.pointerId) return;
357
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
358
+ event.currentTarget.releasePointerCapture(event.pointerId);
359
+ }
360
+ launcherDragRef.current = null;
361
+ setDraggingLauncher(false);
362
+ const rect = event.currentTarget.getBoundingClientRect();
363
+ const currentX = launcherPositionRef.current?.x ?? rect.left;
364
+ const side: LauncherSide =
365
+ currentX + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
366
+ dockLauncher(side, launcherPositionRef.current?.y ?? rect.top);
367
+ }
368
+
369
+ function moveLauncherWithKeyboard(
370
+ event: ReactKeyboardEvent<HTMLButtonElement>,
371
+ ) {
372
+ if (
373
+ !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
374
+ )
375
+ return;
376
+ event.preventDefault();
377
+ const currentY =
378
+ launcherPositionRef.current?.y ??
379
+ event.currentTarget.getBoundingClientRect().top;
380
+ if (event.key === 'ArrowLeft') dockLauncher('left', currentY);
381
+ else if (event.key === 'ArrowRight') dockLauncher('right', currentY);
382
+ else
383
+ dockLauncher(
384
+ launcherSideRef.current,
385
+ currentY + (event.key === 'ArrowUp' ? -24 : 24),
386
+ );
387
+ }
388
+
389
+ const Icon = BrandIcon ?? Sparks;
390
+
79
391
  return (
80
392
  <>
81
- <button
82
- type="button"
83
- data-test="ai-panel-launcher"
84
- onClick={() => setOpen(true)}
85
- className="fixed bottom-5 right-5 z-30 flex 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"
86
- aria-label="Open ShellStack AI"
87
- >
88
- <BrandIcon className="h-4 w-4 text-ph-brand" />
89
- Ask AI
90
- </button>
91
-
92
- {open && (
393
+ {!hideLauncher ? (
394
+ <>
395
+ <button
396
+ ref={launcherRef}
397
+ type="button"
398
+ data-test="ai-panel-launcher"
399
+ onClick={() => {
400
+ if (suppressLauncherClickRef.current) {
401
+ suppressLauncherClickRef.current = false;
402
+ return;
403
+ }
404
+ setOpen(true);
405
+ }}
406
+ onPointerDown={startLauncherDrag}
407
+ onPointerMove={moveLauncher}
408
+ onPointerUp={finishLauncherDrag}
409
+ onPointerCancel={finishLauncherDrag}
410
+ onKeyDown={moveLauncherWithKeyboard}
411
+ 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'}`}
412
+ style={
413
+ launcherPosition
414
+ ? { left: launcherPosition.x, top: launcherPosition.y }
415
+ : { bottom: 20, right: 20 }
416
+ }
417
+ aria-label="Open Ask AI"
418
+ aria-describedby="suite-ask-ai-launcher-help"
419
+ >
420
+ <Icon className="h-4 w-4 text-ph-brand" />
421
+ Ask AI
422
+ </button>
423
+ <span id="suite-ask-ai-launcher-help" className="sr-only">
424
+ Drag to reposition, or use arrow keys. The launcher docks to the
425
+ nearest screen edge.
426
+ </span>
427
+ </>
428
+ ) : null}
429
+
430
+ {open ? (
93
431
  <div className="fixed inset-0 z-40" role="dialog" aria-modal="true">
94
432
  <button
95
433
  type="button"
96
434
  className="absolute inset-0 bg-black/25"
97
435
  onClick={() => setOpen(false)}
98
- aria-label="Close AI panel"
436
+ aria-label="Close Ask AI"
99
437
  />
100
- <aside className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col border-l border-ph-border bg-ph-surface shadow-2xl">
438
+ <aside
439
+ className={`absolute inset-y-0 flex w-full max-w-md flex-col bg-ph-surface shadow-2xl ${launcherSide === 'left' ? 'left-0 border-r border-ph-border' : 'right-0 border-l border-ph-border'}`}
440
+ data-test="suite-ask-ai-panel"
441
+ >
101
442
  <div className="flex items-center justify-between border-b border-ph-border px-4 py-3">
102
- <div className="flex items-center gap-2">
103
- <BrandIcon className="h-4 w-4 text-ph-brand" />
104
- <span className="text-sm font-semibold text-ph-ink">
105
- ShellStack AI
106
- </span>
443
+ <div className="min-w-0">
444
+ <div className="flex items-center gap-2">
445
+ <Icon className="h-4 w-4 shrink-0 text-ph-brand" />
446
+ <span className="text-sm font-semibold text-ph-ink">
447
+ {title}
448
+ </span>
449
+ </div>
450
+ <p className="mt-0.5 text-[11px] text-ph-mutedtext">
451
+ Shared across your suite · same chat in every app
452
+ </p>
107
453
  </div>
108
- <button
109
- type="button"
110
- onClick={() => setOpen(false)}
111
- className="rounded p-1 hover:bg-ph-muted"
112
- aria-label="Close"
113
- >
114
- <X className="h-4 w-4" />
115
- </button>
116
- </div>
117
-
118
- <div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
119
- {presets.map((preset) => (
454
+ <div className="flex items-center gap-1">
455
+ {messages.length > 0 ? (
456
+ <button
457
+ type="button"
458
+ onClick={() => void handleClear()}
459
+ disabled={loading}
460
+ className="rounded-md px-2 py-1 text-xs text-ph-mutedtext hover:bg-ph-muted hover:text-ph-ink disabled:opacity-50"
461
+ data-test="ask-ai-clear"
462
+ >
463
+ Clear
464
+ </button>
465
+ ) : null}
120
466
  <button
121
- key={preset.label}
122
467
  type="button"
123
- onClick={() => {
124
- setPrompt(preset.prompt);
125
- void ask(preset.prompt);
126
- }}
127
- disabled={loading}
128
- className="rounded-md border border-ph-border px-2 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
468
+ onClick={() => setOpen(false)}
469
+ className="rounded p-1 hover:bg-ph-muted"
470
+ aria-label="Close"
129
471
  >
130
- {preset.label}
472
+ <X className="h-4 w-4" />
131
473
  </button>
132
- ))}
474
+ </div>
133
475
  </div>
134
476
 
135
- <div className="flex-1 space-y-3 overflow-y-auto p-4">
136
- {error && (
477
+ {presets.length > 0 && messages.length === 0 ? (
478
+ <div className="flex flex-wrap gap-1.5 border-b border-ph-border px-4 py-2">
479
+ {presets.map((preset) => (
480
+ <button
481
+ key={preset.label}
482
+ type="button"
483
+ onClick={() => void ask(preset.prompt)}
484
+ disabled={loading || hydrating}
485
+ className="rounded-full border border-ph-border px-2.5 py-1 text-xs text-ph-ink hover:bg-ph-muted disabled:opacity-50"
486
+ >
487
+ {preset.label}
488
+ </button>
489
+ ))}
490
+ </div>
491
+ ) : null}
492
+
493
+ <div
494
+ ref={scrollerRef}
495
+ className="flex-1 space-y-3 overflow-y-auto p-4"
496
+ >
497
+ {error ? (
137
498
  <p className="bg-ph-danger/10 rounded-lg px-3 py-2 text-sm text-ph-danger">
138
499
  {error}
139
500
  </p>
140
- )}
141
- {loading && (
501
+ ) : null}
502
+
503
+ {hydrating && messages.length === 0 ? (
142
504
  <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
143
505
  <Spinner className="h-4 w-4 animate-spin" />
144
- Thinking
506
+ Loading chat
145
507
  </div>
146
- )}
147
- {answer && !loading && (
148
- <div className="rounded-lg bg-ph-muted px-3 py-2 text-sm text-ph-ink">
149
- <p className="whitespace-pre-wrap">{answer}</p>
150
- <button
151
- type="button"
152
- onClick={() => void copyAnswer()}
153
- className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
508
+ ) : null}
509
+
510
+ {!hydrating && messages.length === 0 && !error ? (
511
+ <div className="mt-10 px-2 text-center">
512
+ <p className="font-display text-base font-semibold text-ph-ink">
513
+ How can I help?
514
+ </p>
515
+ <p className="mt-2 text-sm leading-relaxed text-ph-subtle">
516
+ {emptyState}
517
+ </p>
518
+ </div>
519
+ ) : null}
520
+
521
+ {messages.map((message, index) => {
522
+ const isUser = message.role === 'user';
523
+ return (
524
+ <div
525
+ key={`${message.role}-${index}-${message.content.slice(0, 24)}`}
526
+ className={
527
+ isUser
528
+ ? 'ml-8 rounded-2xl bg-ph-brand/10 px-3.5 py-2.5 text-sm text-ph-ink'
529
+ : 'mr-4 rounded-2xl bg-ph-muted px-3.5 py-2.5 text-sm text-ph-ink'
530
+ }
531
+ data-test={isUser ? 'ask-ai-user' : 'ask-ai-assistant'}
154
532
  >
155
- {copied ? (
156
- <Check className="h-3 w-3" />
157
- ) : (
158
- <Copy className="h-3 w-3" />
159
- )}
160
- {copied ? 'Copied' : 'Copy'}
161
- </button>
533
+ <p className="whitespace-pre-wrap">{message.content}</p>
534
+ {!isUser ? (
535
+ <button
536
+ type="button"
537
+ onClick={() => void copyMessage(index, message.content)}
538
+ className="mt-2 inline-flex items-center gap-1 text-xs text-ph-mutedtext hover:text-ph-ink"
539
+ >
540
+ {copiedId === index ? (
541
+ <Check className="h-3 w-3" />
542
+ ) : (
543
+ <Copy className="h-3 w-3" />
544
+ )}
545
+ {copiedId === index ? 'Copied' : 'Copy'}
546
+ </button>
547
+ ) : null}
548
+ </div>
549
+ );
550
+ })}
551
+
552
+ {loading ? (
553
+ <div className="flex items-center gap-2 text-sm text-ph-mutedtext">
554
+ <Spinner className="h-4 w-4 animate-spin" />
555
+ Thinking…
162
556
  </div>
163
- )}
164
- {!answer && !loading && !error && (
165
- <p className="mt-8 text-center text-xs text-ph-mutedtext">
166
- {emptyState}
167
- </p>
168
- )}
557
+ ) : null}
169
558
  </div>
170
559
 
171
560
  <div className="border-t border-ph-border p-3">
@@ -182,13 +571,15 @@ export function SuiteAiPanel({
182
571
  placeholder="Ask anything…"
183
572
  rows={2}
184
573
  disabled={loading}
185
- className="flex-1 resize-none rounded-lg border border-ph-border bg-ph-canvas p-2 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
574
+ data-test="ask-ai-input"
575
+ className="flex-1 resize-none rounded-xl border border-ph-border bg-ph-canvas p-2.5 text-sm text-ph-ink outline-none focus:ring-1 focus:ring-ph-brand"
186
576
  />
187
577
  <button
188
578
  type="button"
189
579
  onClick={() => void ask(prompt)}
190
580
  disabled={loading || !prompt.trim()}
191
- className="self-end rounded-lg bg-ph-brand px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
581
+ data-test="ask-ai-send"
582
+ className="self-end rounded-xl bg-ph-brand px-3.5 py-2 text-sm font-medium text-white disabled:opacity-50"
192
583
  >
193
584
  Ask
194
585
  </button>
@@ -196,7 +587,7 @@ export function SuiteAiPanel({
196
587
  </div>
197
588
  </aside>
198
589
  </div>
199
- )}
590
+ ) : null}
200
591
  </>
201
592
  );
202
593
  }