@xenide-io/the-old-ui-theme 0.9.5 → 0.9.6

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.
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ import "./chunk-FWCSY2DS.mjs";
3
+
4
+ // src/suite/components/ai-message-markdown.tsx
5
+ import ReactMarkdown from "react-markdown";
6
+ import remarkGfm from "remark-gfm";
7
+ import { jsx } from "react/jsx-runtime";
8
+ var WORD_STEP_MS = 18;
9
+ var MAX_DELAY_MS = 600;
10
+ var SKIP_TAGS = /* @__PURE__ */ new Set(["code", "pre", "style", "script"]);
11
+ function rehypeStaggerWords() {
12
+ return (tree) => {
13
+ let wordIndex = 0;
14
+ const visit = (node) => {
15
+ if (!node.children) return;
16
+ if (node.tagName && SKIP_TAGS.has(node.tagName)) return;
17
+ const next = [];
18
+ for (const child of node.children) {
19
+ const isText = child.type === "text" && typeof child.value === "string" && /\S/.test(child.value);
20
+ if (!isText) {
21
+ visit(child);
22
+ next.push(child);
23
+ continue;
24
+ }
25
+ for (const part of child.value.split(/(\s+)/)) {
26
+ if (!part) continue;
27
+ if (/^\s+$/.test(part)) {
28
+ next.push({ type: "text", value: part });
29
+ continue;
30
+ }
31
+ const delay = Math.min(wordIndex * WORD_STEP_MS, MAX_DELAY_MS);
32
+ wordIndex += 1;
33
+ next.push({
34
+ type: "element",
35
+ tagName: "span",
36
+ properties: {
37
+ className: ["suite-word-in"],
38
+ style: `animation-delay:${delay}ms`
39
+ },
40
+ children: [{ type: "text", value: part }]
41
+ });
42
+ }
43
+ }
44
+ node.children = next;
45
+ };
46
+ visit(tree);
47
+ };
48
+ }
49
+ function SuiteAiMarkdownMessage({
50
+ markdown,
51
+ animate = false
52
+ }) {
53
+ return /* @__PURE__ */ jsx("div", { className: "suite-ai-markdown", children: /* @__PURE__ */ jsx(
54
+ ReactMarkdown,
55
+ {
56
+ remarkPlugins: [remarkGfm],
57
+ rehypePlugins: animate ? [rehypeStaggerWords] : void 0,
58
+ components: { img: () => null },
59
+ children: markdown
60
+ }
61
+ ) });
62
+ }
63
+ export {
64
+ SuiteAiMarkdownMessage as default
65
+ };
package/dist/suite.js CHANGED
@@ -64,19 +64,59 @@ var ai_message_markdown_exports = {};
64
64
  __export(ai_message_markdown_exports, {
65
65
  default: () => SuiteAiMarkdownMessage
66
66
  });
67
+ function rehypeStaggerWords() {
68
+ return (tree) => {
69
+ let wordIndex = 0;
70
+ const visit = (node) => {
71
+ if (!node.children) return;
72
+ if (node.tagName && SKIP_TAGS.has(node.tagName)) return;
73
+ const next = [];
74
+ for (const child of node.children) {
75
+ const isText = child.type === "text" && typeof child.value === "string" && /\S/.test(child.value);
76
+ if (!isText) {
77
+ visit(child);
78
+ next.push(child);
79
+ continue;
80
+ }
81
+ for (const part of child.value.split(/(\s+)/)) {
82
+ if (!part) continue;
83
+ if (/^\s+$/.test(part)) {
84
+ next.push({ type: "text", value: part });
85
+ continue;
86
+ }
87
+ const delay = Math.min(wordIndex * WORD_STEP_MS, MAX_DELAY_MS);
88
+ wordIndex += 1;
89
+ next.push({
90
+ type: "element",
91
+ tagName: "span",
92
+ properties: {
93
+ className: ["suite-word-in"],
94
+ style: `animation-delay:${delay}ms`
95
+ },
96
+ children: [{ type: "text", value: part }]
97
+ });
98
+ }
99
+ }
100
+ node.children = next;
101
+ };
102
+ visit(tree);
103
+ };
104
+ }
67
105
  function SuiteAiMarkdownMessage({
68
- markdown
106
+ markdown,
107
+ animate = false
69
108
  }) {
70
109
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "suite-ai-markdown", children: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
71
110
  import_react_markdown.default,
72
111
  {
73
112
  remarkPlugins: [import_remark_gfm.default],
113
+ rehypePlugins: animate ? [rehypeStaggerWords] : void 0,
74
114
  components: { img: () => null },
75
115
  children: markdown
76
116
  }
77
117
  ) });
78
118
  }
79
- var import_react_markdown, import_remark_gfm, import_jsx_runtime23;
119
+ var import_react_markdown, import_remark_gfm, import_jsx_runtime23, WORD_STEP_MS, MAX_DELAY_MS, SKIP_TAGS;
80
120
  var init_ai_message_markdown = __esm({
81
121
  "src/suite/components/ai-message-markdown.tsx"() {
82
122
  "use strict";
@@ -84,6 +124,9 @@ var init_ai_message_markdown = __esm({
84
124
  import_react_markdown = __toESM(require("react-markdown"));
85
125
  import_remark_gfm = __toESM(require("remark-gfm"));
86
126
  import_jsx_runtime23 = require("react/jsx-runtime");
127
+ WORD_STEP_MS = 18;
128
+ MAX_DELAY_MS = 600;
129
+ SKIP_TAGS = /* @__PURE__ */ new Set(["code", "pre", "style", "script"]);
87
130
  }
88
131
  });
89
132
 
@@ -3665,6 +3708,10 @@ function SuiteAiPanel({
3665
3708
  else window.location.assign(new URL(openPath, href).toString());
3666
3709
  }
3667
3710
  if (!open) return null;
3711
+ const lastAssistantIndex = messages.reduce(
3712
+ (found, message, index) => message.role === "assistant" ? index : found,
3713
+ -1
3714
+ );
3668
3715
  const Icon = BrandIcon != null ? BrandIcon : import_iconoir_react8.Sparks;
3669
3716
  return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
3670
3717
  "aside",
@@ -3733,7 +3780,7 @@ function SuiteAiPanel({
3733
3780
  return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
3734
3781
  "div",
3735
3782
  {
3736
- className: "flex justify-end",
3783
+ className: "suite-msg-in suite-msg-in--user flex justify-end",
3737
3784
  "data-test": "ask-ai-user",
3738
3785
  children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("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]", children: message.content })
3739
3786
  },
@@ -3749,12 +3796,12 @@ function SuiteAiPanel({
3749
3796
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
3750
3797
  "span",
3751
3798
  {
3752
- className: "mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand",
3799
+ className: "suite-msg-avatar mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand",
3753
3800
  "aria-hidden": true,
3754
3801
  children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(Icon, { className: "h-3.5 w-3.5" })
3755
3802
  }
3756
3803
  ),
3757
- /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "min-w-0 max-w-[85%] space-y-2", children: [
3804
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "suite-msg-bubble min-w-0 max-w-[85%] space-y-2", children: [
3758
3805
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
3759
3806
  import_react12.Suspense,
3760
3807
  {
@@ -3766,13 +3813,19 @@ function SuiteAiPanel({
3766
3813
  "aria-label": "Rendering response",
3767
3814
  children: [
3768
3815
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "sr-only", children: "Rendering response\u2026" }),
3769
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "h-3 w-11/12 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" }),
3770
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "h-3 w-full rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" }),
3771
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "h-3 w-3/5 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" })
3816
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "suite-shimmer h-3 w-11/12 rounded bg-ph-mutedtext/20" }),
3817
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "suite-shimmer h-3 w-full rounded bg-ph-mutedtext/20" }),
3818
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "suite-shimmer h-3 w-3/5 rounded bg-ph-mutedtext/20" })
3772
3819
  ]
3773
3820
  }
3774
3821
  ),
3775
- children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(SuiteAiMarkdownMessage2, { markdown: message.content })
3822
+ children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
3823
+ SuiteAiMarkdownMessage2,
3824
+ {
3825
+ markdown: message.content,
3826
+ animate: index === lastAssistantIndex
3827
+ }
3828
+ )
3776
3829
  }
3777
3830
  ) }),
3778
3831
  ((_a = message.actions) != null ? _a : []).map((action) => /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
@@ -3836,9 +3889,9 @@ function SuiteAiPanel({
3836
3889
  waiting ? /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "flex items-end gap-2", "aria-hidden": true, children: [
3837
3890
  /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("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", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(Icon, { className: "h-3.5 w-3.5" }) }),
3838
3891
  /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "flex items-center gap-1.5 rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2.5", children: [
3839
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce" }),
3840
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.15s]" }),
3841
- /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.3s]" })
3892
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "suite-think-dot h-1.5 w-1.5 rounded-full bg-ph-mutedtext" }),
3893
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "suite-think-dot suite-think-dot--2 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" }),
3894
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "suite-think-dot suite-think-dot--3 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" })
3842
3895
  ] })
3843
3896
  ] }) : null,
3844
3897
  waiting ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "sr-only", "aria-live": "polite", children: "Assistant is responding\u2026" }) : null,
package/dist/suite.mjs CHANGED
@@ -2386,7 +2386,7 @@ import {
2386
2386
  WarningTriangle
2387
2387
  } from "iconoir-react";
2388
2388
  import { Fragment as Fragment5, jsx as jsx17, jsxs as jsxs14 } from "react/jsx-runtime";
2389
- var SuiteAiMarkdownMessage = lazy(() => import("./ai-message-markdown-TSPYON2F.mjs"));
2389
+ var SuiteAiMarkdownMessage = lazy(() => import("./ai-message-markdown-AUEMCO7K.mjs"));
2390
2390
  var SUITE_OPEN_ASK_AI_EVENT = "shellstack:open-ask-ai";
2391
2391
  var SUITE_ASK_AI_OPEN_KEY = "shellstack:shelly-open";
2392
2392
  var SUITE_ASK_AI_OPEN_QUERY = "shelly";
@@ -2685,6 +2685,10 @@ function SuiteAiPanel({
2685
2685
  else window.location.assign(new URL(openPath, href).toString());
2686
2686
  }
2687
2687
  if (!open) return null;
2688
+ const lastAssistantIndex = messages.reduce(
2689
+ (found, message, index) => message.role === "assistant" ? index : found,
2690
+ -1
2691
+ );
2688
2692
  const Icon = BrandIcon != null ? BrandIcon : Sparks;
2689
2693
  return /* @__PURE__ */ jsxs14(
2690
2694
  "aside",
@@ -2753,7 +2757,7 @@ function SuiteAiPanel({
2753
2757
  return /* @__PURE__ */ jsx17(
2754
2758
  "div",
2755
2759
  {
2756
- className: "flex justify-end",
2760
+ className: "suite-msg-in suite-msg-in--user flex justify-end",
2757
2761
  "data-test": "ask-ai-user",
2758
2762
  children: /* @__PURE__ */ jsx17("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]", children: message.content })
2759
2763
  },
@@ -2769,12 +2773,12 @@ function SuiteAiPanel({
2769
2773
  /* @__PURE__ */ jsx17(
2770
2774
  "span",
2771
2775
  {
2772
- className: "mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand",
2776
+ className: "suite-msg-avatar mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand",
2773
2777
  "aria-hidden": true,
2774
2778
  children: /* @__PURE__ */ jsx17(Icon, { className: "h-3.5 w-3.5" })
2775
2779
  }
2776
2780
  ),
2777
- /* @__PURE__ */ jsxs14("div", { className: "min-w-0 max-w-[85%] space-y-2", children: [
2781
+ /* @__PURE__ */ jsxs14("div", { className: "suite-msg-bubble min-w-0 max-w-[85%] space-y-2", children: [
2778
2782
  /* @__PURE__ */ jsx17("div", { className: "rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink", children: /* @__PURE__ */ jsx17(
2779
2783
  Suspense,
2780
2784
  {
@@ -2786,13 +2790,19 @@ function SuiteAiPanel({
2786
2790
  "aria-label": "Rendering response",
2787
2791
  children: [
2788
2792
  /* @__PURE__ */ jsx17("span", { className: "sr-only", children: "Rendering response\u2026" }),
2789
- /* @__PURE__ */ jsx17("div", { className: "h-3 w-11/12 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" }),
2790
- /* @__PURE__ */ jsx17("div", { className: "h-3 w-full rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" }),
2791
- /* @__PURE__ */ jsx17("div", { className: "h-3 w-3/5 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" })
2793
+ /* @__PURE__ */ jsx17("div", { className: "suite-shimmer h-3 w-11/12 rounded bg-ph-mutedtext/20" }),
2794
+ /* @__PURE__ */ jsx17("div", { className: "suite-shimmer h-3 w-full rounded bg-ph-mutedtext/20" }),
2795
+ /* @__PURE__ */ jsx17("div", { className: "suite-shimmer h-3 w-3/5 rounded bg-ph-mutedtext/20" })
2792
2796
  ]
2793
2797
  }
2794
2798
  ),
2795
- children: /* @__PURE__ */ jsx17(SuiteAiMarkdownMessage, { markdown: message.content })
2799
+ children: /* @__PURE__ */ jsx17(
2800
+ SuiteAiMarkdownMessage,
2801
+ {
2802
+ markdown: message.content,
2803
+ animate: index === lastAssistantIndex
2804
+ }
2805
+ )
2796
2806
  }
2797
2807
  ) }),
2798
2808
  ((_a = message.actions) != null ? _a : []).map((action) => /* @__PURE__ */ jsxs14(
@@ -2856,9 +2866,9 @@ function SuiteAiPanel({
2856
2866
  waiting ? /* @__PURE__ */ jsxs14("div", { className: "flex items-end gap-2", "aria-hidden": true, children: [
2857
2867
  /* @__PURE__ */ jsx17("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", children: /* @__PURE__ */ jsx17(Icon, { className: "h-3.5 w-3.5" }) }),
2858
2868
  /* @__PURE__ */ jsxs14("div", { className: "flex items-center gap-1.5 rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2.5", children: [
2859
- /* @__PURE__ */ jsx17("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce" }),
2860
- /* @__PURE__ */ jsx17("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.15s]" }),
2861
- /* @__PURE__ */ jsx17("span", { className: "h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.3s]" })
2869
+ /* @__PURE__ */ jsx17("span", { className: "suite-think-dot h-1.5 w-1.5 rounded-full bg-ph-mutedtext" }),
2870
+ /* @__PURE__ */ jsx17("span", { className: "suite-think-dot suite-think-dot--2 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" }),
2871
+ /* @__PURE__ */ jsx17("span", { className: "suite-think-dot suite-think-dot--3 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" })
2862
2872
  ] })
2863
2873
  ] }) : null,
2864
2874
  waiting ? /* @__PURE__ */ jsx17("span", { className: "sr-only", "aria-live": "polite", children: "Assistant is responding\u2026" }) : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -797,6 +797,164 @@ button:hover > .ph-card {
797
797
  border-top: 1px solid var(--ph-border-subtle);
798
798
  }
799
799
 
800
+ /* ── Ask AI conversation motion ─────────────────────────────────────────
801
+ Replies arrive whole (the suite chat is polled, not streamed), so the
802
+ motion carries the "alive" signal instead: a soft one-shot entrance per
803
+ message, a breathing thinking indicator, and a shimmer for the skeleton.
804
+ Only opacity + transform animate — height/margin would fight the
805
+ scroller. All of it collapses under prefers-reduced-motion. */
806
+ @keyframes suite-msg-in {
807
+ from {
808
+ opacity: 0;
809
+ transform: translateY(8px) scale(0.97);
810
+ filter: blur(4px);
811
+ }
812
+ 60% {
813
+ opacity: 1;
814
+ }
815
+ to {
816
+ opacity: 1;
817
+ transform: none;
818
+ filter: blur(0);
819
+ }
820
+ }
821
+ @keyframes suite-msg-in-user {
822
+ /* The user's own turn grows out of the composer corner, per the chat
823
+ scroller guidance, so sending feels anchored to where they typed. */
824
+ from {
825
+ opacity: 0;
826
+ transform: translateY(12px) scale(0.96);
827
+ filter: blur(4px);
828
+ }
829
+ 60% {
830
+ opacity: 1;
831
+ }
832
+ to {
833
+ opacity: 1;
834
+ transform: none;
835
+ filter: blur(0);
836
+ }
837
+ }
838
+ .suite-msg-in {
839
+ /* Slight overshoot rather than a flat ease-out: the bubble reads as
840
+ landing rather than fading up. Origin sits at the tail corner so it
841
+ appears to grow from the avatar (assistant) / composer (user). */
842
+ animation: suite-msg-in 280ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
843
+ transform-origin: 0 100%;
844
+ }
845
+ .suite-msg-in--user {
846
+ animation-name: suite-msg-in-user;
847
+ animation-duration: 240ms;
848
+ transform-origin: 100% 100%;
849
+ }
850
+ /* Avatar lands first, bubble follows — a small layer separation. The bubble
851
+ only settles (no blur) because the words themselves carry the reveal. */
852
+ @keyframes suite-msg-settle {
853
+ from {
854
+ opacity: 0;
855
+ transform: translateY(4px);
856
+ }
857
+ to {
858
+ opacity: 1;
859
+ transform: none;
860
+ }
861
+ }
862
+ .suite-msg-avatar {
863
+ animation: suite-msg-in 260ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
864
+ }
865
+ .suite-msg-bubble {
866
+ animation: suite-msg-settle 200ms ease-out both;
867
+ }
868
+
869
+ /* ── Answer reveal ──────────────────────────────────────────────────────
870
+ Replies land in one payload (the suite chat is polled), so the words
871
+ themselves are staggered to read as if they were being written. Delay is
872
+ applied per word inline by the rehype plugin in ai-message-markdown.tsx
873
+ and capped so a long answer still finishes settling in well under a
874
+ second. `both` fill keeps each word hidden until its turn. */
875
+ @keyframes suite-word-in {
876
+ from {
877
+ opacity: 0;
878
+ filter: blur(2px);
879
+ }
880
+ to {
881
+ opacity: 1;
882
+ filter: blur(0);
883
+ }
884
+ }
885
+ .suite-word-in {
886
+ animation: suite-word-in 240ms ease-out both;
887
+ }
888
+
889
+ @keyframes suite-think-breathe {
890
+ 0%,
891
+ 100% {
892
+ opacity: 0.3;
893
+ transform: scale(0.8);
894
+ }
895
+ 50% {
896
+ opacity: 1;
897
+ transform: scale(1);
898
+ }
899
+ }
900
+ .suite-think-dot {
901
+ animation: suite-think-breathe 1.4s ease-in-out infinite;
902
+ }
903
+ .suite-think-dot--2 {
904
+ animation-delay: 0.16s;
905
+ }
906
+ .suite-think-dot--3 {
907
+ animation-delay: 0.32s;
908
+ }
909
+
910
+ /* Sweep a translateX band rather than background-position: the latter
911
+ repaints every frame, the former stays on the compositor. */
912
+ @keyframes suite-shimmer {
913
+ from {
914
+ transform: translateX(-100%);
915
+ }
916
+ to {
917
+ transform: translateX(200%);
918
+ }
919
+ }
920
+ .suite-shimmer {
921
+ position: relative;
922
+ overflow: hidden;
923
+ }
924
+ .suite-shimmer::after {
925
+ content: "";
926
+ position: absolute;
927
+ inset: 0 auto 0 0;
928
+ width: 60%;
929
+ background-image: linear-gradient(
930
+ 90deg,
931
+ transparent,
932
+ color-mix(in oklab, var(--ph-text-primary) 9%, transparent),
933
+ transparent
934
+ );
935
+ animation: suite-shimmer 1.6s linear infinite;
936
+ }
937
+
938
+ @media (prefers-reduced-motion: reduce) {
939
+ .suite-msg-in,
940
+ .suite-msg-in--user,
941
+ .suite-msg-avatar,
942
+ .suite-msg-bubble,
943
+ .suite-word-in {
944
+ animation: none;
945
+ filter: none;
946
+ }
947
+ .suite-think-dot {
948
+ animation: none;
949
+ opacity: 0.7;
950
+ transform: none;
951
+ }
952
+ .suite-shimmer::after {
953
+ animation: none;
954
+ opacity: 0;
955
+ }
956
+ }
957
+
800
958
  /* ── Auth / OAuth / launch handoff ──────────────────────────────────────
801
959
  One calm opacity fade — no stacked entrance reveals on bridge pages. */
802
960
  .suite-auth-handoff {
@@ -38,4 +38,44 @@ describe("SuiteAiMarkdownMessage", () => {
38
38
  expect(container.querySelector('[contenteditable]')).toBeNull();
39
39
  expect(container.querySelector("img")).toBeNull();
40
40
  });
41
+
42
+ it("leaves words untouched unless animate is set", () => {
43
+ const { container } = render(
44
+ <SuiteThemeProvider config={themeConfig}>
45
+ <SuiteAiMarkdownMessage markdown={"Hello there world"} />
46
+ </SuiteThemeProvider>,
47
+ );
48
+
49
+ expect(container.querySelectorAll(".suite-word-in")).toHaveLength(0);
50
+ expect(container.textContent).toContain("Hello there world");
51
+ });
52
+
53
+ it("staggers each word when animate is set", () => {
54
+ const { container } = render(
55
+ <SuiteThemeProvider config={themeConfig}>
56
+ <SuiteAiMarkdownMessage markdown={"Hello there world"} animate />
57
+ </SuiteThemeProvider>,
58
+ );
59
+
60
+ const words = container.querySelectorAll<HTMLElement>(".suite-word-in");
61
+ expect(words).toHaveLength(3);
62
+ expect(words[0].textContent).toBe("Hello");
63
+ expect(words[0].style.animationDelay).toBe("0ms");
64
+ expect(words[1].style.animationDelay).toBe("18ms");
65
+ expect(words[2].style.animationDelay).toBe("36ms");
66
+ // Copy still yields the original prose.
67
+ expect(container.textContent).toBe("Hello there world");
68
+ });
69
+
70
+ it("does not stagger code spans", () => {
71
+ const { container } = render(
72
+ <SuiteThemeProvider config={themeConfig}>
73
+ <SuiteAiMarkdownMessage markdown={"Run `npm run dev` now"} animate />
74
+ </SuiteThemeProvider>,
75
+ );
76
+
77
+ const code = container.querySelector("code");
78
+ expect(code).toBeTruthy();
79
+ expect(code?.querySelector(".suite-word-in")).toBeNull();
80
+ });
41
81
  });
@@ -1,17 +1,91 @@
1
1
  "use client";
2
2
 
3
- import ReactMarkdown from "react-markdown";
3
+ import ReactMarkdown, { type Options } from "react-markdown";
4
4
  import remarkGfm from "remark-gfm";
5
5
 
6
+ /** Per-word stagger step, and the ceiling so a long reply still settles fast. */
7
+ const WORD_STEP_MS = 18;
8
+ const MAX_DELAY_MS = 600;
9
+
10
+ /** Tags whose text is left alone — animating code word by word reads badly. */
11
+ const SKIP_TAGS = new Set(["code", "pre", "style", "script"]);
12
+
13
+ interface HastNode {
14
+ type: string;
15
+ tagName?: string;
16
+ value?: string;
17
+ properties?: Record<string, unknown>;
18
+ children?: HastNode[];
19
+ }
20
+
21
+ /**
22
+ * Wrap each word of prose in a span that fades in on a stagger, so a reply
23
+ * that arrived in one payload reads as if it were being written.
24
+ *
25
+ * Delay lives inline on the span, so React re-rendering the same tree reuses
26
+ * the same elements and the animation does not replay.
27
+ */
28
+ function rehypeStaggerWords() {
29
+ return (tree: HastNode) => {
30
+ let wordIndex = 0;
31
+
32
+ const visit = (node: HastNode) => {
33
+ if (!node.children) return;
34
+ if (node.tagName && SKIP_TAGS.has(node.tagName)) return;
35
+
36
+ const next: HastNode[] = [];
37
+ for (const child of node.children) {
38
+ const isText =
39
+ child.type === "text" &&
40
+ typeof child.value === "string" &&
41
+ /\S/.test(child.value);
42
+
43
+ if (!isText) {
44
+ visit(child);
45
+ next.push(child);
46
+ continue;
47
+ }
48
+
49
+ for (const part of child.value!.split(/(\s+)/)) {
50
+ if (!part) continue;
51
+ if (/^\s+$/.test(part)) {
52
+ next.push({ type: "text", value: part });
53
+ continue;
54
+ }
55
+ const delay = Math.min(wordIndex * WORD_STEP_MS, MAX_DELAY_MS);
56
+ wordIndex += 1;
57
+ next.push({
58
+ type: "element",
59
+ tagName: "span",
60
+ properties: {
61
+ className: ["suite-word-in"],
62
+ style: `animation-delay:${delay}ms`,
63
+ },
64
+ children: [{ type: "text", value: part }],
65
+ });
66
+ }
67
+ }
68
+ node.children = next;
69
+ };
70
+
71
+ visit(tree);
72
+ };
73
+ }
74
+
6
75
  export default function SuiteAiMarkdownMessage({
7
76
  markdown,
77
+ animate = false,
8
78
  }: {
9
79
  markdown: string;
80
+ animate?: boolean;
10
81
  }) {
11
82
  return (
12
83
  <div className="suite-ai-markdown">
13
84
  <ReactMarkdown
14
85
  remarkPlugins={[remarkGfm]}
86
+ rehypePlugins={
87
+ (animate ? [rehypeStaggerWords] : undefined) as Options["rehypePlugins"]
88
+ }
15
89
  components={{ img: () => null }}
16
90
  >
17
91
  {markdown}
@@ -425,6 +425,13 @@ export function SuiteAiPanel({
425
425
 
426
426
  if (!open) return null;
427
427
 
428
+ // Only the newest reply gets the staggered word reveal; animating the whole
429
+ // transcript would replay whenever the panel remounts mid-conversation.
430
+ const lastAssistantIndex = messages.reduce(
431
+ (found, message, index) => (message.role === "assistant" ? index : found),
432
+ -1,
433
+ );
434
+
428
435
  const Icon = BrandIcon ?? Sparks;
429
436
 
430
437
  return (
@@ -505,7 +512,7 @@ export function SuiteAiPanel({
505
512
  return (
506
513
  <div
507
514
  key={`user-${index}`}
508
- className="flex justify-end"
515
+ className="suite-msg-in suite-msg-in--user flex justify-end"
509
516
  data-test="ask-ai-user"
510
517
  >
511
518
  <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]">
@@ -521,12 +528,12 @@ export function SuiteAiPanel({
521
528
  data-test="ask-ai-assistant"
522
529
  >
523
530
  <span
524
- className="mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand"
531
+ className="suite-msg-avatar mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand"
525
532
  aria-hidden
526
533
  >
527
534
  <Icon className="h-3.5 w-3.5" />
528
535
  </span>
529
- <div className="min-w-0 max-w-[85%] space-y-2">
536
+ <div className="suite-msg-bubble min-w-0 max-w-[85%] space-y-2">
530
537
  <div className="rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink">
531
538
  <Suspense
532
539
  fallback={
@@ -536,13 +543,16 @@ export function SuiteAiPanel({
536
543
  aria-label="Rendering response"
537
544
  >
538
545
  <span className="sr-only">Rendering response…</span>
539
- <div className="h-3 w-11/12 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
540
- <div className="h-3 w-full rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
541
- <div className="h-3 w-3/5 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
546
+ <div className="suite-shimmer h-3 w-11/12 rounded bg-ph-mutedtext/20" />
547
+ <div className="suite-shimmer h-3 w-full rounded bg-ph-mutedtext/20" />
548
+ <div className="suite-shimmer h-3 w-3/5 rounded bg-ph-mutedtext/20" />
542
549
  </div>
543
550
  }
544
551
  >
545
- <SuiteAiMarkdownMessage markdown={message.content} />
552
+ <SuiteAiMarkdownMessage
553
+ markdown={message.content}
554
+ animate={index === lastAssistantIndex}
555
+ />
546
556
  </Suspense>
547
557
  </div>
548
558
  {(message.actions ?? []).map((action) => (
@@ -612,9 +622,9 @@ export function SuiteAiPanel({
612
622
  <Icon className="h-3.5 w-3.5" />
613
623
  </span>
614
624
  <div className="flex items-center gap-1.5 rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2.5">
615
- <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce" />
616
- <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.15s]" />
617
- <span className="h-1.5 w-1.5 rounded-full bg-ph-mutedtext motion-safe:animate-bounce [animation-delay:0.3s]" />
625
+ <span className="suite-think-dot h-1.5 w-1.5 rounded-full bg-ph-mutedtext" />
626
+ <span className="suite-think-dot suite-think-dot--2 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" />
627
+ <span className="suite-think-dot suite-think-dot--3 h-1.5 w-1.5 rounded-full bg-ph-mutedtext" />
618
628
  </div>
619
629
  </div>
620
630
  ) : null}
@@ -1,22 +0,0 @@
1
- "use client";
2
- import "./chunk-FWCSY2DS.mjs";
3
-
4
- // src/suite/components/ai-message-markdown.tsx
5
- import ReactMarkdown from "react-markdown";
6
- import remarkGfm from "remark-gfm";
7
- import { jsx } from "react/jsx-runtime";
8
- function SuiteAiMarkdownMessage({
9
- markdown
10
- }) {
11
- return /* @__PURE__ */ jsx("div", { className: "suite-ai-markdown", children: /* @__PURE__ */ jsx(
12
- ReactMarkdown,
13
- {
14
- remarkPlugins: [remarkGfm],
15
- components: { img: () => null },
16
- children: markdown
17
- }
18
- ) });
19
- }
20
- export {
21
- SuiteAiMarkdownMessage as default
22
- };