@xenide-io/the-old-ui-theme 0.6.7 → 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.
- package/dist/{chunk-3G43JEI3.mjs → chunk-VOCHIR3W.mjs} +2 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +1 -1
- package/dist/suite.d.mts +64 -15
- package/dist/suite.d.ts +64 -15
- package/dist/suite.js +750 -758
- package/dist/suite.mjs +571 -585
- package/dist/ui.js +2 -2
- package/dist/ui.mjs +1 -1
- package/package.json +1 -1
- package/src/styles/suite-skin.css +6 -0
- package/src/styles/themes.css +35 -33
- package/src/suite/components/ai-panel.test.ts +14 -40
- package/src/suite/components/ai-panel.tsx +305 -633
- package/src/suite/components/suite-app-layout.tsx +3 -2
- package/src/suite/components/suite-layout.test.tsx +36 -2
- package/src/suite/components/suite-sidebar.tsx +235 -102
- package/src/suite/components/today-ui.tsx +1 -1
- package/src/suite/components/workspace-switcher.tsx +162 -0
- package/src/suite/index.ts +9 -0
- package/src/suite/lib/apps.test.ts +40 -0
- package/src/suite/lib/apps.ts +37 -0
- package/src/suite/lib/use-sidebar-width.ts +11 -1
|
@@ -1,41 +1,47 @@
|
|
|
1
|
-
|
|
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
|
|
13
|
-
type PointerEvent as ReactPointerEvent,
|
|
13
|
+
type MouseEvent as ReactMouseEvent,
|
|
14
14
|
type ReactNode,
|
|
15
|
-
} from
|
|
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
|
-
|
|
26
|
-
} from 'iconoir-react';
|
|
23
|
+
} from "iconoir-react";
|
|
27
24
|
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
137
|
-
title =
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
163
|
-
const [
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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(
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
:
|
|
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((
|
|
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 :
|
|
262
|
+
err instanceof Error ? err.message : "Could not clear this chat.",
|
|
400
263
|
);
|
|
401
264
|
}
|
|
402
265
|
}
|
|
403
266
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
<
|
|
570
|
-
|
|
311
|
+
<Erase className="h-3.5 w-3.5" />
|
|
312
|
+
Clear
|
|
571
313
|
</button>
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
</>
|
|
577
|
-
) : null}
|
|
578
|
-
|
|
579
|
-
{open ? (
|
|
314
|
+
) : null}
|
|
315
|
+
</header>
|
|
316
|
+
|
|
317
|
+
<div className="relative min-h-0 flex-1">
|
|
580
318
|
<div
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
className=
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
<
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
<
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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={
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
|
|
518
|
+
</div>
|
|
519
|
+
</aside>
|
|
848
520
|
);
|
|
849
521
|
}
|