@xenide-io/the-old-ui-theme 0.9.7 → 0.9.9

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.
@@ -16,6 +16,9 @@ import {
16
16
  import {
17
17
  ArrowDown,
18
18
  ArrowUp,
19
+ Check,
20
+ Copy,
21
+ EditPencil,
19
22
  Erase,
20
23
  Sparks,
21
24
  Square,
@@ -157,6 +160,8 @@ export function SuiteAiPanel({
157
160
  title = "Shelly AI",
158
161
  fetchChat,
159
162
  sendMessage,
163
+ editMessage,
164
+ cancelMessage,
160
165
  clearChat,
161
166
  brandIcon: BrandIcon,
162
167
  spinner: Spinner,
@@ -180,6 +185,14 @@ export function SuiteAiPanel({
180
185
  prompt: string;
181
186
  signal?: AbortSignal;
182
187
  }) => Promise<{ messages: SuiteAiChatMessage[]; reply: string }>;
188
+ /** Rewrite the thread from a user message. Falls back to sendMessage. */
189
+ editMessage?: (params: {
190
+ index: number;
191
+ prompt: string;
192
+ signal?: AbortSignal;
193
+ }) => Promise<{ messages: SuiteAiChatMessage[]; reply: string }>;
194
+ /** Stop the in-flight reply on the server. */
195
+ cancelMessage?: () => Promise<{ messages?: SuiteAiChatMessage[] }>;
183
196
  clearChat: () => Promise<void>;
184
197
  brandIcon?: ComponentType<{ className?: string }>;
185
198
  spinner: ComponentType<{ className?: string }>;
@@ -220,6 +233,9 @@ export function SuiteAiPanel({
220
233
  const textareaRef = useRef<HTMLTextAreaElement>(null);
221
234
  const abortRef = useRef<AbortController | null>(null);
222
235
  const pendingPromptRef = useRef<string | null>(null);
236
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
237
+ const [editDraft, setEditDraft] = useState("");
238
+ const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
223
239
  const waiting = loading || isWaitingForReply;
224
240
 
225
241
  useEffect(() => {
@@ -356,6 +372,14 @@ export function SuiteAiPanel({
356
372
  abortRef.current?.abort();
357
373
  abortRef.current = null;
358
374
  setLoading(false);
375
+ if (!cancelMessage) return;
376
+ void cancelMessage()
377
+ .then((result) => {
378
+ if (result?.messages) setMessages(result.messages);
379
+ })
380
+ .catch(() => {
381
+ // Best-effort server cancel; the local abort already stopped the UI.
382
+ });
359
383
  }
360
384
 
361
385
  function retry() {
@@ -367,6 +391,58 @@ export function SuiteAiPanel({
367
391
  else void hydrate();
368
392
  }
369
393
 
394
+ async function saveEdit(index: number) {
395
+ const trimmed = editDraft.trim();
396
+ if (!trimmed || waiting) return;
397
+ setEditingIndex(null);
398
+ setEditDraft("");
399
+ setError("");
400
+ setAtBottom(true);
401
+ setMessages((prev) => prev.slice(0, index));
402
+ const controller = new AbortController();
403
+ abortRef.current = controller;
404
+ setLoading(true);
405
+ try {
406
+ const result = editMessage
407
+ ? await editMessage({
408
+ index,
409
+ prompt: trimmed,
410
+ signal: controller.signal,
411
+ })
412
+ : await sendMessage({ prompt: trimmed, signal: controller.signal });
413
+ if (controller.signal.aborted) return;
414
+ const nextMessages = result.messages ?? [];
415
+ setMessages(nextMessages);
416
+ notifySuiteMutated(
417
+ nextMessages.flatMap((message) => message.actions ?? []),
418
+ );
419
+ } catch (err) {
420
+ if (controller.signal.aborted) return;
421
+ setError(
422
+ err instanceof Error
423
+ ? err.message
424
+ : "The AI request failed. Try again.",
425
+ );
426
+ void hydrate();
427
+ } finally {
428
+ if (abortRef.current === controller) abortRef.current = null;
429
+ setLoading(false);
430
+ }
431
+ }
432
+
433
+ async function handleCopy(index: number, content: string) {
434
+ try {
435
+ await navigator.clipboard.writeText(content);
436
+ setCopiedIndex(index);
437
+ window.setTimeout(
438
+ () => setCopiedIndex((current) => (current === index ? null : current)),
439
+ 1500,
440
+ );
441
+ } catch {
442
+ // clipboard unavailable
443
+ }
444
+ }
445
+
370
446
  async function handleClear() {
371
447
  if (loading) return;
372
448
  try {
@@ -516,14 +592,102 @@ export function SuiteAiPanel({
516
592
  {messages.map((message, index) => {
517
593
  const isUser = message.role === "user";
518
594
  if (isUser) {
595
+ if (editingIndex === index) {
596
+ return (
597
+ <div
598
+ key={`user-${index}`}
599
+ className="suite-msg-in suite-msg-in--user flex justify-end"
600
+ data-test="ask-ai-user"
601
+ >
602
+ <div className="w-full max-w-[85%] space-y-2">
603
+ <textarea
604
+ autoFocus
605
+ value={editDraft}
606
+ onChange={(event) => setEditDraft(event.target.value)}
607
+ onKeyDown={(event) => {
608
+ if (
609
+ event.key === "Enter" &&
610
+ !event.shiftKey &&
611
+ !event.nativeEvent.isComposing
612
+ ) {
613
+ event.preventDefault();
614
+ void saveEdit(index);
615
+ }
616
+ if (event.key === "Escape") {
617
+ setEditingIndex(null);
618
+ setEditDraft("");
619
+ }
620
+ }}
621
+ rows={2}
622
+ data-test="ask-ai-edit-input"
623
+ className="w-full resize-none rounded-2xl rounded-br-md border border-ph-border bg-ph-surface px-3 py-2 text-sm leading-relaxed text-ph-ink outline-none focus-visible:border-ph-brand/45 focus-visible:ring-2 focus-visible:ring-ph-focus/35"
624
+ />
625
+ <div className="flex justify-end gap-2">
626
+ <button
627
+ type="button"
628
+ onClick={() => {
629
+ setEditingIndex(null);
630
+ setEditDraft("");
631
+ }}
632
+ className="inline-flex min-h-8 items-center 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"
633
+ >
634
+ Cancel
635
+ </button>
636
+ <button
637
+ type="button"
638
+ data-test="ask-ai-edit-save"
639
+ disabled={!editDraft.trim() || waiting}
640
+ onClick={() => void saveEdit(index)}
641
+ className="inline-flex min-h-8 items-center rounded-lg bg-ph-brand px-2.5 text-xs font-medium 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"
642
+ >
643
+ Save
644
+ </button>
645
+ </div>
646
+ </div>
647
+ </div>
648
+ );
649
+ }
519
650
  return (
520
651
  <div
521
652
  key={`user-${index}`}
522
653
  className="suite-msg-in suite-msg-in--user flex justify-end"
523
654
  data-test="ask-ai-user"
524
655
  >
525
- <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]">
526
- {message.content}
656
+ <div className="group flex items-end gap-1.5">
657
+ {!waiting ? (
658
+ <div className="mb-0.5 flex items-center gap-0.5 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100">
659
+ <button
660
+ type="button"
661
+ onClick={() => void handleCopy(index, message.content)}
662
+ aria-label={
663
+ copiedIndex === index ? "Copied" : "Copy message"
664
+ }
665
+ data-test="ask-ai-copy"
666
+ className="flex h-7 w-7 items-center justify-center rounded-lg 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"
667
+ >
668
+ {copiedIndex === index ? (
669
+ <Check className="h-3.5 w-3.5" />
670
+ ) : (
671
+ <Copy className="h-3.5 w-3.5" />
672
+ )}
673
+ </button>
674
+ <button
675
+ type="button"
676
+ onClick={() => {
677
+ setEditDraft(message.content);
678
+ setEditingIndex(index);
679
+ }}
680
+ aria-label="Edit message"
681
+ data-test="ask-ai-edit"
682
+ className="flex h-7 w-7 items-center justify-center rounded-lg 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"
683
+ >
684
+ <EditPencil className="h-3.5 w-3.5" />
685
+ </button>
686
+ </div>
687
+ ) : null}
688
+ <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]">
689
+ {message.content}
690
+ </div>
527
691
  </div>
528
692
  </div>
529
693
  );