@firstlovecenter/ai-chat 0.9.0 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to `@firstlovecenter/ai-chat` are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.2] — 2026-05-09
9
+
10
+ Fixes a streaming-vs-navigation race that caused the first turn of a brand-new conversation to vanish on reload.
11
+
12
+ ### Fixed
13
+
14
+ - **Race condition on lazy-create-on-submit**. When the user sent the first message in a fresh conversation, the client used `router.push('/chat/<uid>')` to update the URL. App-Router navigation **unmounted the chat component mid-streaming**, so the in-flight SSE / Vercel-AI data stream was dropped on the floor. The newly-mounted page would then re-hydrate from `/api/chat/sessions/<uid>` *before* the assistant message had finished persisting — so reload showed an empty thread. Now the URL is updated via `window.history.replaceState` (shallow update, no remount), and the streaming connection survives. Affects both `AiChat` and `VercelChat`.
15
+
16
+ ### Added
17
+
18
+ - **Defensive `meta` event handling**. The custom-SSE route emits `event: meta` carrying the persistent `chatSessionId` as its first frame; the Vercel route emits the same as a `{ type: 'meta' }` data part. Both shells now sync `activeSessionId` and the URL from that event as a backstop, so even if the lazy-create POST silently fails the client still picks up the server-resolved session id.
19
+
20
+ ## [0.9.1] — 2026-05-09
21
+
22
+ Lets a host mount multiple chat surfaces side-by-side without yanking users between shells.
23
+
24
+ ### Added
25
+
26
+ - **`AiChatProps.basePath`** (`string`, optional, default `'/chat'`) — URL prefix the chat surface is mounted at. Sidebar `<Link>` entries resolve to `${basePath}/${sessionId}`, `+ New chat` pushes `${basePath}?new`, and the open-session router push uses the same prefix. Both `AiChat` and `VercelChat` honour it.
27
+
28
+ ### Migration notes
29
+
30
+ - Pure addition — `basePath` defaults to `/chat`, so existing `/chat`-mounted surfaces work unchanged.
31
+ - Hosts that want a second surface (e.g. an admin AI page at `/admin/ai`) need to (a) pass `basePath="/admin/ai"`, (b) add a matching dynamic segment (`/admin/ai/[id]`) so reload/bookmark URLs resolve, and (c) wire a redirect-to-most-recent at the basePath that respects `?new`.
32
+ - **Tailwind v4 hosts**: the package ships no CSS, so its component classes need to be in your Tailwind content scan. Add `@source "../node_modules/@firstlovecenter/ai-chat";` to your `globals.css` (path relative to the CSS file). Without this, the in-chat sidebar's `absolute`/`translate-x-*`/`bg-sidebar` classes are stripped from the production bundle and the sidebar renders as a static flex column instead of an overlay.
33
+
8
34
  ## [0.9.0] — 2026-05-09
9
35
 
10
36
  Chat session and message identifiers are now short URL-safe UIDs (12 chars) instead of auto-incrementing BIGINTs. URLs like `/chat/V1StGXR8_Z5j` don't leak per-user chat counts and are unguessable, which matters as soon as a session URL is ever shared.
package/dist/ui/index.cjs CHANGED
@@ -678,7 +678,8 @@ function AiChat({
678
678
  userFirstName,
679
679
  scopeLabel,
680
680
  initialProvider,
681
- initialSessionId = null
681
+ initialSessionId = null,
682
+ basePath = "/chat"
682
683
  }) {
683
684
  const router = navigation.useRouter();
684
685
  const [sessions, setSessions] = React.useState([]);
@@ -768,16 +769,16 @@ function AiChat({
768
769
  }, []);
769
770
  const syncUrl = React.useCallback(
770
771
  (id) => {
771
- router.push(id == null ? "/chat" : `/chat/${id}`);
772
+ router.push(id == null ? basePath : `${basePath}/${id}`);
772
773
  },
773
- [router]
774
+ [router, basePath]
774
775
  );
775
776
  const newChat = React.useCallback(() => {
776
777
  setActiveSessionId(null);
777
778
  setAnswers([]);
778
779
  setQuestion("");
779
- router.push("/chat?new");
780
- }, [router]);
780
+ router.push(`${basePath}?new`);
781
+ }, [router, basePath]);
781
782
  const changeProvider = React.useCallback(
782
783
  async (next) => {
783
784
  if (next === provider || providerSaving) return;
@@ -873,7 +874,9 @@ function AiChat({
873
874
  const data = await create.json();
874
875
  sessionId = data.session.id;
875
876
  setActiveSessionId(sessionId);
876
- syncUrl(sessionId);
877
+ if (typeof window !== "undefined") {
878
+ window.history.replaceState(null, "", `${basePath}/${sessionId}`);
879
+ }
877
880
  setSessions((prev) => [
878
881
  { id: data.session.id, title: data.session.title, updatedAt: null },
879
882
  ...prev
@@ -935,6 +938,17 @@ function AiChat({
935
938
  const events = buffer.split("\n\n");
936
939
  buffer = events.pop() ?? "";
937
940
  for (const raw of events) {
941
+ const meta = parseMetaChatSessionId(raw);
942
+ if (meta != null) {
943
+ setActiveSessionId((prev) => prev ?? meta);
944
+ if (typeof window !== "undefined" && !window.location.pathname.endsWith(`/${meta}`)) {
945
+ window.history.replaceState(
946
+ null,
947
+ "",
948
+ `${basePath}/${meta}`
949
+ );
950
+ }
951
+ }
938
952
  handleEvent(raw, setAnswers);
939
953
  }
940
954
  }
@@ -1030,7 +1044,7 @@ function AiChat({
1030
1044
  /* @__PURE__ */ jsxRuntime.jsx(
1031
1045
  Link__default.default,
1032
1046
  {
1033
- href: `/chat/${s.id}`,
1047
+ href: `${basePath}/${s.id}`,
1034
1048
  onClick: () => setSidebarOpen(false),
1035
1049
  className: cn(
1036
1050
  "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
@@ -1393,6 +1407,22 @@ function UserChip({ text }) {
1393
1407
  )
1394
1408
  ] });
1395
1409
  }
1410
+ function parseMetaChatSessionId(raw) {
1411
+ const lines = raw.split("\n");
1412
+ let event = "";
1413
+ let dataStr = "";
1414
+ for (const line of lines) {
1415
+ if (line.startsWith("event: ")) event = line.slice(7).trim();
1416
+ else if (line.startsWith("data: ")) dataStr += line.slice(6);
1417
+ }
1418
+ if (event !== "meta") return null;
1419
+ try {
1420
+ const parsed = JSON.parse(dataStr || "{}");
1421
+ return typeof parsed.chatSessionId === "string" && parsed.chatSessionId.length > 0 ? parsed.chatSessionId : null;
1422
+ } catch {
1423
+ return null;
1424
+ }
1425
+ }
1396
1426
  function handleEvent(raw, setAnswers) {
1397
1427
  const lines = raw.split("\n");
1398
1428
  let event = "";
@@ -1513,7 +1543,8 @@ function VercelChat({
1513
1543
  userFirstName,
1514
1544
  scopeLabel,
1515
1545
  initialProvider,
1516
- initialSessionId = null
1546
+ initialSessionId = null,
1547
+ basePath = "/chat"
1517
1548
  }) {
1518
1549
  const router = navigation.useRouter();
1519
1550
  const [sessions, setSessions] = React.useState([]);
@@ -1644,6 +1675,20 @@ function VercelChat({
1644
1675
  cancelled = true;
1645
1676
  };
1646
1677
  }, [initialSessionId, setMessages]);
1678
+ React.useEffect(() => {
1679
+ if (!Array.isArray(data)) return;
1680
+ for (const raw of data) {
1681
+ const part = asDataPart(raw);
1682
+ if (!part || part.type !== "meta") continue;
1683
+ const id = part.value.chatSessionId;
1684
+ if (!id) continue;
1685
+ setActiveSessionId((prev) => prev ?? id);
1686
+ if (typeof window !== "undefined" && !window.location.pathname.endsWith(`/${id}`)) {
1687
+ window.history.replaceState(null, "", `${basePath}/${id}`);
1688
+ }
1689
+ break;
1690
+ }
1691
+ }, [data, basePath]);
1647
1692
  const answers = React.useMemo(() => {
1648
1693
  const liveBlocks = [];
1649
1694
  const liveErrors = [];
@@ -1744,9 +1789,9 @@ function VercelChat({
1744
1789
  }, [answers.length]);
1745
1790
  const syncUrl = React.useCallback(
1746
1791
  (id) => {
1747
- router.push(id == null ? "/chat" : `/chat/${id}`);
1792
+ router.push(id == null ? basePath : `${basePath}/${id}`);
1748
1793
  },
1749
- [router]
1794
+ [router, basePath]
1750
1795
  );
1751
1796
  const newChat = React.useCallback(() => {
1752
1797
  setActiveSessionId(null);
@@ -1756,8 +1801,8 @@ function VercelChat({
1756
1801
  setHydratedErrors({});
1757
1802
  setStartedAt({});
1758
1803
  setInput("");
1759
- router.push("/chat?new");
1760
- }, [setMessages, setInput, router]);
1804
+ router.push(`${basePath}?new`);
1805
+ }, [setMessages, setInput, router, basePath]);
1761
1806
  const changeProvider = React.useCallback(
1762
1807
  async (next) => {
1763
1808
  if (next === provider || providerSaving) return;
@@ -1866,7 +1911,13 @@ function VercelChat({
1866
1911
  const json = await create.json();
1867
1912
  activeSessionIdRef.current = json.session.id;
1868
1913
  setActiveSessionId(json.session.id);
1869
- syncUrl(json.session.id);
1914
+ if (typeof window !== "undefined") {
1915
+ window.history.replaceState(
1916
+ null,
1917
+ "",
1918
+ `${basePath}/${json.session.id}`
1919
+ );
1920
+ }
1870
1921
  setSessions((prev) => [
1871
1922
  { id: json.session.id, title: json.session.title, updatedAt: null },
1872
1923
  ...prev
@@ -1987,7 +2038,7 @@ function VercelChat({
1987
2038
  /* @__PURE__ */ jsxRuntime.jsx(
1988
2039
  Link__default.default,
1989
2040
  {
1990
- href: `/chat/${s.id}`,
2041
+ href: `${basePath}/${s.id}`,
1991
2042
  onClick: () => setSidebarOpen(false),
1992
2043
  className: cn(
1993
2044
  "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",