@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.
- package/dist/{chunk-54ZR4VL5.mjs → chunk-RZXHZWUB.mjs} +44 -17
- package/dist/{index-B5NOyoKS.d.mts → index-Od4pIP4F.d.mts} +7 -1
- package/dist/{index-B5NOyoKS.d.ts → index-Od4pIP4F.d.ts} +7 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -10
- package/dist/index.mjs +1 -1
- package/dist/suite.d.mts +152 -41
- package/dist/suite.d.ts +152 -41
- package/dist/suite.js +1366 -671
- package/dist/suite.mjs +1304 -610
- package/dist/ui.d.mts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +37 -10
- package/dist/ui.mjs +1 -1
- package/package.json +7 -3
- package/src/components/ui/Modal.tsx +70 -26
- package/src/styles/suite-skin.css +284 -24
- package/src/suite/components/ai-panel.test.ts +20 -0
- package/src/suite/components/ai-panel.tsx +494 -103
- package/src/suite/components/suite-app-layout.tsx +48 -28
- package/src/suite/components/suite-layout.tsx +84 -40
- package/src/suite/components/suite-mobile-drawer.tsx +35 -22
- package/src/suite/components/suite-notification-bell.tsx +9 -3
- package/src/suite/components/suite-settings-layout.tsx +82 -0
- package/src/suite/components/suite-settings-mobile-nav.tsx +18 -17
- package/src/suite/components/suite-skeleton.tsx +3 -3
- package/src/suite/components/today-calibrating.tsx +11 -35
- package/src/suite/components/today-page-frame.tsx +19 -11
- package/src/suite/components/today-ui.tsx +276 -19
- package/src/suite/index.ts +41 -25
- package/src/suite/lib/apps.ts +32 -2
- package/src/suite/lib/use-sidebar-width.ts +114 -0
- package/src/components/sections/AccordionShowcase.tsx +0 -45
- package/src/components/sections/AlertShowcase.tsx +0 -40
- package/src/components/sections/AvatarShowcase.tsx +0 -80
- package/src/components/sections/BadgeShowcase.tsx +0 -65
- package/src/components/sections/ButtonShowcase.tsx +0 -308
- package/src/components/sections/CalendarShowcase.tsx +0 -35
- package/src/components/sections/CardShowcase.tsx +0 -159
- package/src/components/sections/CodeMockupShowcase.tsx +0 -56
- package/src/components/sections/ColorPalette.tsx +0 -117
- package/src/components/sections/CommandPaletteShowcase.tsx +0 -48
- package/src/components/sections/CountdownShowcase.tsx +0 -43
- package/src/components/sections/DiffShowcase.tsx +0 -70
- package/src/components/sections/DrawerShowcase.tsx +0 -97
- package/src/components/sections/DropdownShowcase.tsx +0 -98
- package/src/components/sections/EmptyStateShowcase.tsx +0 -50
- package/src/components/sections/FilterChipsShowcase.tsx +0 -98
- package/src/components/sections/FormShowcase.tsx +0 -127
- package/src/components/sections/HoverCardShowcase.tsx +0 -50
- package/src/components/sections/IconShowcase.tsx +0 -121
- package/src/components/sections/IndicatorShowcase.tsx +0 -52
- package/src/components/sections/KbdShowcase.tsx +0 -57
- package/src/components/sections/ModalShowcase.tsx +0 -211
- package/src/components/sections/NavigationShowcase.tsx +0 -199
- package/src/components/sections/NewComponentsShowcase.tsx +0 -1052
- package/src/components/sections/ProductLayoutsShowcase.tsx +0 -78
- package/src/components/sections/ProgressShowcase.tsx +0 -82
- package/src/components/sections/QuillChartShowcase.tsx +0 -92
- package/src/components/sections/QuillDashboardShowcase.tsx +0 -149
- package/src/components/sections/SegmentedControlShowcase.tsx +0 -49
- package/src/components/sections/SetupThemeShowcase.tsx +0 -262
- package/src/components/sections/SidebarShowcase.tsx +0 -152
- package/src/components/sections/StackShowcase.tsx +0 -33
- package/src/components/sections/StepperShowcase.tsx +0 -37
- package/src/components/sections/SuiteShowcase.tsx +0 -669
- package/src/components/sections/SwapShowcase.tsx +0 -99
- package/src/components/sections/TabShowcase.tsx +0 -84
- package/src/components/sections/TableShowcase.tsx +0 -142
- package/src/components/sections/TimelineShowcase.tsx +0 -83
- package/src/components/sections/ToastShowcase.tsx +0 -108
- package/src/components/sections/TooltipShowcase.tsx +0 -38
- package/src/components/sections/UtilityShowcase.tsx +0 -81
- package/src/styles/excel-themes.css +0 -110
- package/src/styles/lemon-primitives.css +0 -550
|
@@ -1,60 +1,245 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
*
|
|
71
|
+
* Suite-wide Ask AI slide-over — Notion-style continuous chat.
|
|
13
72
|
*
|
|
14
|
-
* One
|
|
15
|
-
*
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 [
|
|
107
|
+
const [hydrating, setHydrating] = useState(false);
|
|
39
108
|
const [error, setError] = useState('');
|
|
40
|
-
const [
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
69
|
-
if (
|
|
253
|
+
async function handleClear() {
|
|
254
|
+
if (loading) return;
|
|
70
255
|
try {
|
|
71
|
-
await
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
436
|
+
aria-label="Close Ask AI"
|
|
99
437
|
/>
|
|
100
|
-
<aside
|
|
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="
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
472
|
+
<X className="h-4 w-4" />
|
|
131
473
|
</button>
|
|
132
|
-
|
|
474
|
+
</div>
|
|
133
475
|
</div>
|
|
134
476
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
+
Loading chat…
|
|
145
507
|
</div>
|
|
146
|
-
)}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|