@firstlovecenter/ai-chat 0.2.3 → 0.6.0

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/ui/index.cjs CHANGED
@@ -6,6 +6,7 @@ var lucideReact = require('lucide-react');
6
6
  var radixUi = require('radix-ui');
7
7
  var jsxRuntime = require('react/jsx-runtime');
8
8
  var RechartsPrimitive = require('recharts');
9
+ var react = require('@ai-sdk/react');
9
10
 
10
11
  function _interopNamespace(e) {
11
12
  if (e && e.__esModule) return e;
@@ -99,6 +100,92 @@ function DropdownMenuItem({
99
100
  }
100
101
  );
101
102
  }
103
+ function SidebarTitleEditor({
104
+ initial,
105
+ onSave,
106
+ onCancel
107
+ }) {
108
+ const [draft, setDraft] = React.useState(initial);
109
+ const commit = () => {
110
+ const trimmed = draft.trim();
111
+ if (trimmed && trimmed !== initial) onSave(trimmed);
112
+ else onCancel();
113
+ };
114
+ return /* @__PURE__ */ jsxRuntime.jsx(
115
+ "input",
116
+ {
117
+ autoFocus: true,
118
+ value: draft,
119
+ onChange: (e) => setDraft(e.target.value),
120
+ onFocus: (e) => e.currentTarget.select(),
121
+ onClick: (e) => e.stopPropagation(),
122
+ onBlur: commit,
123
+ onKeyDown: (e) => {
124
+ if (e.key === "Enter") {
125
+ e.preventDefault();
126
+ commit();
127
+ } else if (e.key === "Escape") {
128
+ e.preventDefault();
129
+ onCancel();
130
+ }
131
+ },
132
+ maxLength: 200,
133
+ className: "w-full rounded-md border border-sidebar-border bg-sidebar-accent/40 px-2 py-1.5 text-sm text-sidebar-foreground focus:outline-none focus:ring-1 focus:ring-ring"
134
+ }
135
+ );
136
+ }
137
+ function EditableTitle({
138
+ title,
139
+ onSave
140
+ }) {
141
+ const [editing, setEditing] = React.useState(false);
142
+ const [draft, setDraft] = React.useState(title);
143
+ React.useEffect(() => {
144
+ if (!editing) setDraft(title);
145
+ }, [title, editing]);
146
+ if (!editing) {
147
+ return /* @__PURE__ */ jsxRuntime.jsx(
148
+ "button",
149
+ {
150
+ type: "button",
151
+ onClick: () => {
152
+ setDraft(title);
153
+ setEditing(true);
154
+ },
155
+ className: "w-64 max-w-full min-w-0 truncate rounded px-2 py-0.5 text-left hover:bg-accent hover:text-foreground",
156
+ title: "Click to rename",
157
+ children: title
158
+ }
159
+ );
160
+ }
161
+ const commit = () => {
162
+ const trimmed = draft.trim();
163
+ setEditing(false);
164
+ if (trimmed && trimmed !== title) onSave(trimmed);
165
+ };
166
+ return /* @__PURE__ */ jsxRuntime.jsx(
167
+ "input",
168
+ {
169
+ autoFocus: true,
170
+ value: draft,
171
+ onChange: (e) => setDraft(e.target.value),
172
+ onFocus: (e) => e.currentTarget.select(),
173
+ onBlur: commit,
174
+ onKeyDown: (e) => {
175
+ if (e.key === "Enter") {
176
+ e.preventDefault();
177
+ commit();
178
+ } else if (e.key === "Escape") {
179
+ e.preventDefault();
180
+ setEditing(false);
181
+ setDraft(title);
182
+ }
183
+ },
184
+ className: "w-64 max-w-full min-w-0 rounded border border-border bg-background px-2 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
185
+ maxLength: 200
186
+ }
187
+ );
188
+ }
102
189
  function ChartCard({
103
190
  title,
104
191
  subtitle,
@@ -597,6 +684,7 @@ function AiChat({
597
684
  const textareaRef = React.useRef(null);
598
685
  const lastAnswerRef = React.useRef(null);
599
686
  const prevAnswersLen = React.useRef(0);
687
+ const autoOpenedRef = React.useRef(false);
600
688
  React.useLayoutEffect(() => {
601
689
  const el = textareaRef.current;
602
690
  if (!el) return;
@@ -611,7 +699,34 @@ function AiChat({
611
699
  const res = await fetch("/api/chat/sessions", { cache: "no-store" });
612
700
  if (!res.ok) return;
613
701
  const data = await res.json();
614
- if (!cancelled) setSessions(data.sessions ?? []);
702
+ if (cancelled) return;
703
+ const list = data.sessions ?? [];
704
+ setSessions(list);
705
+ if (!autoOpenedRef.current && list.length > 0) {
706
+ autoOpenedRef.current = true;
707
+ const mostRecentId = list[0].id;
708
+ setLoadingSession(true);
709
+ setActiveSessionId(mostRecentId);
710
+ try {
711
+ const sres = await fetch(`/api/chat/sessions/${mostRecentId}`, {
712
+ cache: "no-store"
713
+ });
714
+ if (cancelled) return;
715
+ if (!sres.ok) {
716
+ setAnswers([]);
717
+ return;
718
+ }
719
+ const sdata = await sres.json();
720
+ if (cancelled) return;
721
+ setAnswers(messagesToAnswers(sdata.messages ?? []));
722
+ } catch {
723
+ if (!cancelled) setAnswers([]);
724
+ } finally {
725
+ if (!cancelled) setLoadingSession(false);
726
+ }
727
+ } else if (!autoOpenedRef.current) {
728
+ autoOpenedRef.current = true;
729
+ }
615
730
  } catch {
616
731
  }
617
732
  }
@@ -1179,92 +1294,6 @@ function AnswerView({
1179
1294
  }
1180
1295
  );
1181
1296
  }
1182
- function SidebarTitleEditor({
1183
- initial,
1184
- onSave,
1185
- onCancel
1186
- }) {
1187
- const [draft, setDraft] = React.useState(initial);
1188
- const commit = () => {
1189
- const trimmed = draft.trim();
1190
- if (trimmed && trimmed !== initial) onSave(trimmed);
1191
- else onCancel();
1192
- };
1193
- return /* @__PURE__ */ jsxRuntime.jsx(
1194
- "input",
1195
- {
1196
- autoFocus: true,
1197
- value: draft,
1198
- onChange: (e) => setDraft(e.target.value),
1199
- onFocus: (e) => e.currentTarget.select(),
1200
- onClick: (e) => e.stopPropagation(),
1201
- onBlur: commit,
1202
- onKeyDown: (e) => {
1203
- if (e.key === "Enter") {
1204
- e.preventDefault();
1205
- commit();
1206
- } else if (e.key === "Escape") {
1207
- e.preventDefault();
1208
- onCancel();
1209
- }
1210
- },
1211
- maxLength: 200,
1212
- className: "w-full rounded-md border border-sidebar-border bg-sidebar-accent/40 px-2 py-1.5 text-sm text-sidebar-foreground focus:outline-none focus:ring-1 focus:ring-ring"
1213
- }
1214
- );
1215
- }
1216
- function EditableTitle({
1217
- title,
1218
- onSave
1219
- }) {
1220
- const [editing, setEditing] = React.useState(false);
1221
- const [draft, setDraft] = React.useState(title);
1222
- React.useEffect(() => {
1223
- if (!editing) setDraft(title);
1224
- }, [title, editing]);
1225
- if (!editing) {
1226
- return /* @__PURE__ */ jsxRuntime.jsx(
1227
- "button",
1228
- {
1229
- type: "button",
1230
- onClick: () => {
1231
- setDraft(title);
1232
- setEditing(true);
1233
- },
1234
- className: "w-64 max-w-full min-w-0 truncate rounded px-2 py-0.5 text-left hover:bg-accent hover:text-foreground",
1235
- title: "Click to rename",
1236
- children: title
1237
- }
1238
- );
1239
- }
1240
- const commit = () => {
1241
- const trimmed = draft.trim();
1242
- setEditing(false);
1243
- if (trimmed && trimmed !== title) onSave(trimmed);
1244
- };
1245
- return /* @__PURE__ */ jsxRuntime.jsx(
1246
- "input",
1247
- {
1248
- autoFocus: true,
1249
- value: draft,
1250
- onChange: (e) => setDraft(e.target.value),
1251
- onFocus: (e) => e.currentTarget.select(),
1252
- onBlur: commit,
1253
- onKeyDown: (e) => {
1254
- if (e.key === "Enter") {
1255
- e.preventDefault();
1256
- commit();
1257
- } else if (e.key === "Escape") {
1258
- e.preventDefault();
1259
- setEditing(false);
1260
- setDraft(title);
1261
- }
1262
- },
1263
- className: "w-64 max-w-full min-w-0 rounded border border-border bg-background px-2 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
1264
- maxLength: 200
1265
- }
1266
- );
1267
- }
1268
1297
  function blocksToPlainText(blocks) {
1269
1298
  return blocks.map((b) => {
1270
1299
  if (b.kind === "paragraph_brief") {
@@ -1429,9 +1458,917 @@ function messagesToAnswers(messages) {
1429
1458
  }
1430
1459
  return out;
1431
1460
  }
1461
+ function formatDuration2(ms) {
1462
+ if (ms < 0) ms = 0;
1463
+ const totalSec = Math.round(ms / 1e3);
1464
+ if (totalSec < 60) return `${totalSec}s`;
1465
+ const m = Math.floor(totalSec / 60);
1466
+ const s = totalSec % 60;
1467
+ return s === 0 ? `${m}m` : `${m}m ${s}s`;
1468
+ }
1469
+ var PROVIDER_LABELS2 = {
1470
+ claude: "Claude",
1471
+ grok: "Grok",
1472
+ gemini: "Gemini"
1473
+ };
1474
+ var TEXTAREA_MAX_PX2 = 176;
1475
+ var PROVIDER_DESCRIPTIONS2 = {
1476
+ claude: "Anthropic Claude on Vertex AI",
1477
+ grok: "xAI Grok on Vertex AI",
1478
+ gemini: "Google Gemini on Vertex AI"
1479
+ };
1480
+ function asDataPart(v) {
1481
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
1482
+ const t = v.type;
1483
+ if (typeof t !== "string") return null;
1484
+ return v;
1485
+ }
1486
+ function VercelChat({
1487
+ userFirstName,
1488
+ scopeLabel,
1489
+ initialProvider
1490
+ }) {
1491
+ const [sessions, setSessions] = React.useState([]);
1492
+ const [activeSessionId, setActiveSessionId] = React.useState(null);
1493
+ const [sidebarOpen, setSidebarOpen] = React.useState(false);
1494
+ const [loadingSession, setLoadingSession] = React.useState(false);
1495
+ const [provider, setProvider] = React.useState(initialProvider);
1496
+ const [providerSaving, setProviderSaving] = React.useState(false);
1497
+ const [editingSidebarId, setEditingSidebarId] = React.useState(null);
1498
+ const [hydratedBlocks, setHydratedBlocks] = React.useState({});
1499
+ const [hydratedProse, setHydratedProse] = React.useState({});
1500
+ const [hydratedErrors, setHydratedErrors] = React.useState({});
1501
+ const [startedAt, setStartedAt] = React.useState({});
1502
+ const threadRef = React.useRef(null);
1503
+ const textareaRef = React.useRef(null);
1504
+ const lastAnswerRef = React.useRef(null);
1505
+ const prevAnswersLen = React.useRef(0);
1506
+ const autoOpenedRef = React.useRef(false);
1507
+ const activeSessionIdRef = React.useRef(activeSessionId);
1508
+ const providerRef = React.useRef(provider);
1509
+ React.useEffect(() => {
1510
+ activeSessionIdRef.current = activeSessionId;
1511
+ }, [activeSessionId]);
1512
+ React.useEffect(() => {
1513
+ providerRef.current = provider;
1514
+ }, [provider]);
1515
+ const refreshSessions = React.useCallback(async () => {
1516
+ try {
1517
+ const res = await fetch("/api/chat/sessions", { cache: "no-store" });
1518
+ if (!res.ok) return;
1519
+ const data2 = await res.json();
1520
+ setSessions(data2.sessions ?? []);
1521
+ } catch {
1522
+ }
1523
+ }, []);
1524
+ const {
1525
+ messages,
1526
+ input,
1527
+ handleInputChange,
1528
+ handleSubmit,
1529
+ status,
1530
+ setMessages,
1531
+ setInput,
1532
+ data
1533
+ } = react.useChat({
1534
+ api: "/api/agent/vercel",
1535
+ // `body` here is captured at hook init and stale-closes over
1536
+ // `activeSessionId`/`provider`. Real per-submit body comes through
1537
+ // `experimental_prepareRequestBody` below, which reads from refs.
1538
+ experimental_prepareRequestBody: ({ messages: msgs }) => ({
1539
+ messages: msgs,
1540
+ chatSessionId: activeSessionIdRef.current,
1541
+ model: providerRef.current
1542
+ }),
1543
+ onFinish: () => {
1544
+ void refreshSessions();
1545
+ }
1546
+ });
1547
+ React.useLayoutEffect(() => {
1548
+ const el = textareaRef.current;
1549
+ if (!el) return;
1550
+ el.style.height = "0px";
1551
+ const next = Math.min(el.scrollHeight, TEXTAREA_MAX_PX2);
1552
+ el.style.height = `${next}px`;
1553
+ }, [input]);
1554
+ React.useEffect(() => {
1555
+ let cancelled = false;
1556
+ async function load() {
1557
+ try {
1558
+ const res = await fetch("/api/chat/sessions", { cache: "no-store" });
1559
+ if (!res.ok) return;
1560
+ const json = await res.json();
1561
+ if (cancelled) return;
1562
+ const list = json.sessions ?? [];
1563
+ setSessions(list);
1564
+ if (!autoOpenedRef.current && list.length > 0) {
1565
+ autoOpenedRef.current = true;
1566
+ const mostRecentId = list[0].id;
1567
+ setLoadingSession(true);
1568
+ setActiveSessionId(mostRecentId);
1569
+ try {
1570
+ const sres = await fetch(`/api/chat/sessions/${mostRecentId}`, {
1571
+ cache: "no-store"
1572
+ });
1573
+ if (cancelled) return;
1574
+ if (!sres.ok) {
1575
+ setMessages([]);
1576
+ setHydratedBlocks({});
1577
+ setHydratedProse({});
1578
+ setHydratedErrors({});
1579
+ return;
1580
+ }
1581
+ const sjson = await sres.json();
1582
+ if (cancelled) return;
1583
+ const { uiMessages, blocksMap, proseMap, errorsMap } = storedToUseChat(
1584
+ sjson.messages ?? []
1585
+ );
1586
+ setMessages(uiMessages);
1587
+ setHydratedBlocks(blocksMap);
1588
+ setHydratedProse(proseMap);
1589
+ setHydratedErrors(errorsMap);
1590
+ setStartedAt({});
1591
+ } catch {
1592
+ if (!cancelled) {
1593
+ setMessages([]);
1594
+ setHydratedBlocks({});
1595
+ setHydratedProse({});
1596
+ setHydratedErrors({});
1597
+ }
1598
+ } finally {
1599
+ if (!cancelled) setLoadingSession(false);
1600
+ }
1601
+ } else if (!autoOpenedRef.current) {
1602
+ autoOpenedRef.current = true;
1603
+ }
1604
+ } catch {
1605
+ }
1606
+ }
1607
+ void load();
1608
+ return () => {
1609
+ cancelled = true;
1610
+ };
1611
+ }, [setMessages]);
1612
+ const answers = React.useMemo(() => {
1613
+ const liveBlocks = [];
1614
+ const liveErrors = [];
1615
+ if (Array.isArray(data)) {
1616
+ for (const raw of data) {
1617
+ const part = asDataPart(raw);
1618
+ if (!part) continue;
1619
+ if (part.type === "block") liveBlocks.push(part.value);
1620
+ else if (part.type === "error") liveErrors.push(part.value);
1621
+ }
1622
+ }
1623
+ const out = [];
1624
+ let pendingQuestion = null;
1625
+ let liveAssistantSeen = 0;
1626
+ const liveTurns = [];
1627
+ {
1628
+ let current = [];
1629
+ let lastIdx = -1;
1630
+ for (const b of liveBlocks) {
1631
+ if (b.index <= lastIdx && current.length > 0) {
1632
+ liveTurns.push(current);
1633
+ current = [];
1634
+ }
1635
+ current.push(b);
1636
+ lastIdx = b.index;
1637
+ }
1638
+ if (current.length > 0) liveTurns.push(current);
1639
+ }
1640
+ for (const m of messages) {
1641
+ if (m.role === "user") {
1642
+ pendingQuestion = m.content;
1643
+ continue;
1644
+ }
1645
+ if (m.role !== "assistant") continue;
1646
+ const question = pendingQuestion ?? "";
1647
+ pendingQuestion = null;
1648
+ const hydrated = hydratedBlocks[m.id];
1649
+ const hydratedProseStr = hydratedProse[m.id];
1650
+ const hydratedError = hydratedErrors[m.id];
1651
+ let blocks = [];
1652
+ let prose = "";
1653
+ let error;
1654
+ if (hydrated != null) {
1655
+ blocks = hydrated;
1656
+ prose = hydratedProseStr ?? "";
1657
+ error = hydratedError;
1658
+ } else {
1659
+ const run = liveTurns[liveAssistantSeen] ?? [];
1660
+ const acc = [];
1661
+ for (const raw of run) {
1662
+ acc[raw.index] = sanitiseBlock(raw);
1663
+ }
1664
+ blocks = acc;
1665
+ prose = m.content ?? "";
1666
+ error = liveErrors[liveAssistantSeen];
1667
+ liveAssistantSeen += 1;
1668
+ }
1669
+ if (prose) {
1670
+ const firstPb = blocks.findIndex((b) => b && b.kind === "paragraph_brief");
1671
+ if (firstPb >= 0) {
1672
+ const target = blocks[firstPb];
1673
+ if (target.kind === "paragraph_brief") {
1674
+ blocks[firstPb] = { ...target, prose };
1675
+ }
1676
+ }
1677
+ }
1678
+ const isLive = hydrated == null;
1679
+ const turnDone = !isLive || status === "ready" || status === "error";
1680
+ const startedTs = startedAt[m.id];
1681
+ out.push({
1682
+ question,
1683
+ blocks: blocks.filter(Boolean),
1684
+ prose,
1685
+ done: turnDone,
1686
+ error,
1687
+ startedAt: startedTs,
1688
+ durationMs: turnDone && startedTs != null ? Date.now() - startedTs : void 0
1689
+ });
1690
+ }
1691
+ if (pendingQuestion != null) {
1692
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
1693
+ const ts = lastUser ? startedAt[lastUser.id] : void 0;
1694
+ out.push({
1695
+ question: pendingQuestion,
1696
+ blocks: [],
1697
+ prose: "",
1698
+ done: false,
1699
+ startedAt: ts
1700
+ });
1701
+ }
1702
+ return out;
1703
+ }, [messages, data, hydratedBlocks, hydratedProse, hydratedErrors, status, startedAt]);
1704
+ React.useEffect(() => {
1705
+ if (answers.length > prevAnswersLen.current && lastAnswerRef.current) {
1706
+ lastAnswerRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
1707
+ }
1708
+ prevAnswersLen.current = answers.length;
1709
+ }, [answers.length]);
1710
+ const newChat = React.useCallback(() => {
1711
+ setActiveSessionId(null);
1712
+ setMessages([]);
1713
+ setHydratedBlocks({});
1714
+ setHydratedProse({});
1715
+ setHydratedErrors({});
1716
+ setStartedAt({});
1717
+ setInput("");
1718
+ }, [setMessages, setInput]);
1719
+ const changeProvider = React.useCallback(
1720
+ async (next) => {
1721
+ if (next === provider || providerSaving) return;
1722
+ setProvider(next);
1723
+ setProviderSaving(true);
1724
+ try {
1725
+ const res = await fetch("/api/settings/me", {
1726
+ method: "PATCH",
1727
+ headers: { "Content-Type": "application/json" },
1728
+ body: JSON.stringify({ narrative_provider: next })
1729
+ });
1730
+ if (!res.ok) {
1731
+ setProvider((curr) => curr === next ? provider : curr);
1732
+ }
1733
+ } catch {
1734
+ setProvider((curr) => curr === next ? provider : curr);
1735
+ } finally {
1736
+ setProviderSaving(false);
1737
+ }
1738
+ },
1739
+ [provider, providerSaving]
1740
+ );
1741
+ const openSession = React.useCallback(
1742
+ async (id) => {
1743
+ setLoadingSession(true);
1744
+ setActiveSessionId(id);
1745
+ try {
1746
+ const res = await fetch(`/api/chat/sessions/${id}`, { cache: "no-store" });
1747
+ if (!res.ok) {
1748
+ setMessages([]);
1749
+ setHydratedBlocks({});
1750
+ setHydratedProse({});
1751
+ setHydratedErrors({});
1752
+ return;
1753
+ }
1754
+ const json = await res.json();
1755
+ const { uiMessages, blocksMap, proseMap, errorsMap } = storedToUseChat(
1756
+ json.messages ?? []
1757
+ );
1758
+ setMessages(uiMessages);
1759
+ setHydratedBlocks(blocksMap);
1760
+ setHydratedProse(proseMap);
1761
+ setHydratedErrors(errorsMap);
1762
+ setStartedAt({});
1763
+ } catch {
1764
+ setMessages([]);
1765
+ setHydratedBlocks({});
1766
+ setHydratedProse({});
1767
+ setHydratedErrors({});
1768
+ } finally {
1769
+ setLoadingSession(false);
1770
+ }
1771
+ },
1772
+ [setMessages]
1773
+ );
1774
+ const persistTitle = React.useCallback(
1775
+ async (id, title) => {
1776
+ const trimmed = title.trim();
1777
+ if (!trimmed) return;
1778
+ const res = await fetch(`/api/chat/sessions/${id}`, {
1779
+ method: "PATCH",
1780
+ headers: { "Content-Type": "application/json" },
1781
+ body: JSON.stringify({ title: trimmed })
1782
+ });
1783
+ if (res.ok) {
1784
+ setSessions(
1785
+ (prev) => prev.map((s) => s.id === id ? { ...s, title: trimmed } : s)
1786
+ );
1787
+ }
1788
+ },
1789
+ []
1790
+ );
1791
+ const startSidebarRename = React.useCallback((id) => {
1792
+ setEditingSidebarId(id);
1793
+ }, []);
1794
+ const deleteSession = React.useCallback(
1795
+ async (id) => {
1796
+ if (!window.confirm("Delete this chat? This cannot be undone.")) return;
1797
+ const res = await fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
1798
+ if (!res.ok) return;
1799
+ setSessions((prev) => prev.filter((s) => s.id !== id));
1800
+ if (activeSessionId === id) {
1801
+ setActiveSessionId(null);
1802
+ setMessages([]);
1803
+ setHydratedBlocks({});
1804
+ setHydratedProse({});
1805
+ setHydratedErrors({});
1806
+ }
1807
+ },
1808
+ [activeSessionId, setMessages]
1809
+ );
1810
+ const submitForm = React.useCallback(
1811
+ async (e) => {
1812
+ const trimmed = input.trim();
1813
+ if (!trimmed || status !== "ready") return;
1814
+ if (activeSessionIdRef.current == null) {
1815
+ try {
1816
+ const create = await fetch("/api/chat/sessions", {
1817
+ method: "POST",
1818
+ headers: { "Content-Type": "application/json" },
1819
+ body: JSON.stringify({})
1820
+ });
1821
+ if (create.ok) {
1822
+ const json = await create.json();
1823
+ activeSessionIdRef.current = json.session.id;
1824
+ setActiveSessionId(json.session.id);
1825
+ setSessions((prev) => [
1826
+ { id: json.session.id, title: json.session.title, updatedAt: null },
1827
+ ...prev
1828
+ ]);
1829
+ }
1830
+ } catch {
1831
+ }
1832
+ }
1833
+ handleSubmit(e);
1834
+ },
1835
+ [input, status, handleSubmit]
1836
+ );
1837
+ React.useEffect(() => {
1838
+ setStartedAt((prev) => {
1839
+ let changed = false;
1840
+ const next = { ...prev };
1841
+ for (const m of messages) {
1842
+ if (m.role === "user" && next[m.id] == null) {
1843
+ next[m.id] = Date.now();
1844
+ changed = true;
1845
+ }
1846
+ if (m.role === "assistant" && next[m.id] == null) {
1847
+ const idx = messages.findIndex((mm) => mm.id === m.id);
1848
+ for (let i = idx - 1; i >= 0; i -= 1) {
1849
+ const prior = messages[i];
1850
+ if (prior.role === "user" && next[prior.id] != null) {
1851
+ next[m.id] = next[prior.id];
1852
+ changed = true;
1853
+ break;
1854
+ }
1855
+ }
1856
+ }
1857
+ }
1858
+ return changed ? next : prev;
1859
+ });
1860
+ }, [messages]);
1861
+ const heroVisible = answers.length === 0 && !loadingSession;
1862
+ const greeting = React.useMemo(
1863
+ () => `Hi ${userFirstName || "there"}${userFirstName.endsWith("s") ? "" : ""},`,
1864
+ [userFirstName]
1865
+ );
1866
+ const activeTitle = activeSessionId ? sessions.find((s) => s.id === activeSessionId)?.title ?? "Chat" : "New chat";
1867
+ const retry = React.useCallback(
1868
+ (q) => {
1869
+ setInput(q);
1870
+ window.setTimeout(() => {
1871
+ const form = textareaRef.current?.form;
1872
+ if (form) form.requestSubmit();
1873
+ }, 0);
1874
+ },
1875
+ [setInput]
1876
+ );
1877
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex h-full min-h-0 w-full overflow-hidden rounded-lg border border-border bg-background", children: [
1878
+ /* @__PURE__ */ jsxRuntime.jsx(
1879
+ "div",
1880
+ {
1881
+ onClick: () => setSidebarOpen(false),
1882
+ "aria-hidden": "true",
1883
+ className: cn(
1884
+ "absolute inset-0 z-10 bg-background/60 backdrop-blur-sm transition-opacity duration-200",
1885
+ sidebarOpen ? "opacity-100" : "pointer-events-none opacity-0"
1886
+ )
1887
+ }
1888
+ ),
1889
+ /* @__PURE__ */ jsxRuntime.jsxs(
1890
+ "aside",
1891
+ {
1892
+ inert: !sidebarOpen,
1893
+ className: cn(
1894
+ "absolute inset-y-0 left-0 z-20 flex w-72 max-w-[85vw] flex-col border-r border-border bg-sidebar text-sidebar-foreground shadow-lg transition-transform duration-200 ease-out",
1895
+ sidebarOpen ? "translate-x-0" : "-translate-x-full"
1896
+ ),
1897
+ children: [
1898
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 border-b border-sidebar-border p-2", children: [
1899
+ /* @__PURE__ */ jsxRuntime.jsx(
1900
+ "button",
1901
+ {
1902
+ type: "button",
1903
+ onClick: () => setSidebarOpen(false),
1904
+ className: "inline-flex size-8 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground",
1905
+ "aria-label": "Close sidebar",
1906
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PanelLeftClose, { className: "size-4" })
1907
+ }
1908
+ ),
1909
+ /* @__PURE__ */ jsxRuntime.jsxs(
1910
+ "button",
1911
+ {
1912
+ type: "button",
1913
+ onClick: () => {
1914
+ newChat();
1915
+ setSidebarOpen(false);
1916
+ },
1917
+ className: "ml-auto inline-flex items-center gap-1.5 rounded-full border border-sidebar-border bg-sidebar-accent px-3 py-1.5 text-xs font-medium text-sidebar-accent-foreground hover:bg-sidebar-accent/80",
1918
+ children: [
1919
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Plus, { className: "size-3.5" }),
1920
+ "New chat"
1921
+ ]
1922
+ }
1923
+ )
1924
+ ] }),
1925
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex-1 overflow-y-auto p-2", children: sessions.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "px-2 py-3 text-xs text-sidebar-foreground/60", children: "No chats yet. Ask something below to start." }) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-col gap-0.5", children: sessions.map((s) => {
1926
+ const active = s.id === activeSessionId;
1927
+ const editing = s.id === editingSidebarId;
1928
+ if (editing) {
1929
+ return /* @__PURE__ */ jsxRuntime.jsx("li", { className: "px-1 py-1", children: /* @__PURE__ */ jsxRuntime.jsx(
1930
+ SidebarTitleEditor,
1931
+ {
1932
+ initial: s.title,
1933
+ onCancel: () => setEditingSidebarId(null),
1934
+ onSave: (next) => {
1935
+ setEditingSidebarId(null);
1936
+ void persistTitle(s.id, next);
1937
+ }
1938
+ }
1939
+ ) }, s.id);
1940
+ }
1941
+ return /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "group relative", children: [
1942
+ /* @__PURE__ */ jsxRuntime.jsx(
1943
+ "button",
1944
+ {
1945
+ type: "button",
1946
+ onClick: () => {
1947
+ void openSession(s.id);
1948
+ setSidebarOpen(false);
1949
+ },
1950
+ className: cn(
1951
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
1952
+ active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
1953
+ ),
1954
+ title: s.title,
1955
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: s.title })
1956
+ }
1957
+ ),
1958
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 group-hover:flex", children: [
1959
+ /* @__PURE__ */ jsxRuntime.jsx(
1960
+ "button",
1961
+ {
1962
+ type: "button",
1963
+ onClick: (e) => {
1964
+ e.stopPropagation();
1965
+ startSidebarRename(s.id);
1966
+ },
1967
+ "aria-label": "Rename",
1968
+ className: "inline-flex size-6 items-center justify-center rounded text-sidebar-foreground/60 hover:bg-sidebar hover:text-sidebar-foreground",
1969
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pencil, { className: "size-3" })
1970
+ }
1971
+ ),
1972
+ /* @__PURE__ */ jsxRuntime.jsx(
1973
+ "button",
1974
+ {
1975
+ type: "button",
1976
+ onClick: (e) => {
1977
+ e.stopPropagation();
1978
+ void deleteSession(s.id);
1979
+ },
1980
+ "aria-label": "Delete",
1981
+ className: "inline-flex size-6 items-center justify-center rounded text-sidebar-foreground/60 hover:bg-sidebar hover:text-destructive",
1982
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Trash2, { className: "size-3" })
1983
+ }
1984
+ )
1985
+ ] })
1986
+ ] }, s.id);
1987
+ }) }) })
1988
+ ]
1989
+ }
1990
+ ),
1991
+ /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "relative flex flex-1 flex-col overflow-hidden", children: [
1992
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground", children: [
1993
+ /* @__PURE__ */ jsxRuntime.jsx(
1994
+ "button",
1995
+ {
1996
+ type: "button",
1997
+ onClick: () => setSidebarOpen(true),
1998
+ "aria-label": "Open chat history",
1999
+ className: "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
2000
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Menu, { className: "size-4" })
2001
+ }
2002
+ ),
2003
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Sparkles, { className: "size-4 text-primary" }),
2004
+ activeSessionId != null ? /* @__PURE__ */ jsxRuntime.jsx(
2005
+ EditableTitle,
2006
+ {
2007
+ title: activeTitle,
2008
+ onSave: (next) => void persistTitle(activeSessionId, next)
2009
+ },
2010
+ activeSessionId
2011
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: activeTitle })
2012
+ ] }),
2013
+ /* @__PURE__ */ jsxRuntime.jsx(
2014
+ "div",
2015
+ {
2016
+ ref: threadRef,
2017
+ className: "min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-2",
2018
+ children: loadingSession ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex h-full items-center justify-center text-sm text-muted-foreground", children: [
2019
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "mr-2 size-4 animate-spin" }),
2020
+ " Loading conversation\u2026"
2021
+ ] }) : heroVisible ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex h-full flex-col items-center justify-center text-center", children: [
2022
+ /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "text-2xl font-medium tracking-tight text-foreground sm:text-3xl", children: greeting }),
2023
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-2xl font-light tracking-tight text-muted-foreground sm:text-3xl", children: "What's on your mind?" })
2024
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mx-auto flex w-full max-w-3xl flex-col gap-6", children: answers.map((a, idx) => /* @__PURE__ */ jsxRuntime.jsx(
2025
+ AnswerView2,
2026
+ {
2027
+ answer: a,
2028
+ onRetry: () => retry(a.question),
2029
+ forwardRef: idx === answers.length - 1 ? lastAnswerRef : void 0,
2030
+ isLast: idx === answers.length - 1
2031
+ },
2032
+ idx
2033
+ )) })
2034
+ }
2035
+ ),
2036
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "shrink-0 px-4 pb-4 pt-2", children: /* @__PURE__ */ jsxRuntime.jsxs(
2037
+ "form",
2038
+ {
2039
+ className: "mx-auto w-full max-w-3xl",
2040
+ onSubmit: (e) => {
2041
+ e.preventDefault();
2042
+ void submitForm(e);
2043
+ },
2044
+ children: [
2045
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 rounded-3xl border border-border bg-background px-4 py-3 shadow-sm", children: [
2046
+ /* @__PURE__ */ jsxRuntime.jsx(
2047
+ "textarea",
2048
+ {
2049
+ ref: textareaRef,
2050
+ value: input,
2051
+ onChange: handleInputChange,
2052
+ onKeyDown: (e) => {
2053
+ if (e.key === "Enter" && !e.shiftKey) {
2054
+ e.preventDefault();
2055
+ e.currentTarget.form?.requestSubmit();
2056
+ }
2057
+ },
2058
+ placeholder: "Ask AI...",
2059
+ rows: 1,
2060
+ disabled: status !== "ready",
2061
+ style: { maxHeight: TEXTAREA_MAX_PX2 },
2062
+ className: "w-full resize-none overflow-y-auto border-0 bg-transparent py-1 text-sm leading-6 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-0 disabled:opacity-60"
2063
+ }
2064
+ ),
2065
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2", children: [
2066
+ /* @__PURE__ */ jsxRuntime.jsxs(
2067
+ "span",
2068
+ {
2069
+ className: "inline-flex max-w-[60%] items-center gap-1.5 truncate rounded-full border border-border bg-muted/40 px-2.5 py-1 text-xs font-medium text-muted-foreground",
2070
+ title: `Scope: ${scopeLabel}`,
2071
+ children: [
2072
+ "Scope: ",
2073
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-foreground", children: scopeLabel })
2074
+ ]
2075
+ }
2076
+ ),
2077
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
2078
+ /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
2079
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsxs(
2080
+ "button",
2081
+ {
2082
+ type: "button",
2083
+ className: "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground",
2084
+ "aria-label": "Choose model",
2085
+ children: [
2086
+ PROVIDER_LABELS2[provider],
2087
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronDown, { className: "size-3.5" })
2088
+ ]
2089
+ }
2090
+ ) }),
2091
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "end", className: "w-56", children: ["claude", "grok", "gemini"].map((p) => /* @__PURE__ */ jsxRuntime.jsxs(
2092
+ DropdownMenuItem,
2093
+ {
2094
+ onSelect: () => void changeProvider(p),
2095
+ className: "flex items-start gap-2",
2096
+ children: [
2097
+ /* @__PURE__ */ jsxRuntime.jsx(
2098
+ lucideReact.Check,
2099
+ {
2100
+ className: cn(
2101
+ "mt-0.5 size-4",
2102
+ provider === p ? "opacity-100" : "opacity-0"
2103
+ )
2104
+ }
2105
+ ),
2106
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col", children: [
2107
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium", children: PROVIDER_LABELS2[p] }),
2108
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-muted-foreground", children: PROVIDER_DESCRIPTIONS2[p] })
2109
+ ] })
2110
+ ]
2111
+ },
2112
+ p
2113
+ )) })
2114
+ ] }),
2115
+ /* @__PURE__ */ jsxRuntime.jsx(
2116
+ "button",
2117
+ {
2118
+ type: "submit",
2119
+ disabled: status !== "ready" || !input.trim(),
2120
+ "aria-label": "Send",
2121
+ className: "inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors enabled:hover:bg-primary/90 disabled:opacity-40",
2122
+ children: status === "streaming" || status === "submitted" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "size-4 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ArrowUp, { className: "size-4" })
2123
+ }
2124
+ )
2125
+ ] })
2126
+ ] })
2127
+ ] }),
2128
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "mt-2 text-center text-xs text-muted-foreground", children: [
2129
+ PROVIDER_LABELS2[provider],
2130
+ " is AI and can make mistakes."
2131
+ ] })
2132
+ ]
2133
+ }
2134
+ ) })
2135
+ ] })
2136
+ ] });
2137
+ }
2138
+ function AnswerView2({
2139
+ answer,
2140
+ onRetry,
2141
+ forwardRef,
2142
+ isLast
2143
+ }) {
2144
+ const [copied, setCopied] = React.useState(false);
2145
+ const handleCopy = React.useCallback(async () => {
2146
+ const blockText = blocksToPlainText2(answer.blocks);
2147
+ const text = blockText || (answer.error ? `${answer.error.code}: ${answer.error.message}` : "");
2148
+ if (!text) return;
2149
+ try {
2150
+ await navigator.clipboard.writeText(text);
2151
+ setCopied(true);
2152
+ window.setTimeout(() => setCopied(false), 1500);
2153
+ } catch {
2154
+ }
2155
+ }, [answer.blocks, answer.error]);
2156
+ const showActions = answer.done && (answer.blocks.length > 0 || answer.error != null);
2157
+ const isThinking = !answer.done && answer.blocks.length === 0 && !answer.error;
2158
+ const [, forceTick] = React.useState(0);
2159
+ React.useEffect(() => {
2160
+ if (answer.done || answer.startedAt == null) return;
2161
+ const id = window.setInterval(() => forceTick((n) => n + 1), 1e3);
2162
+ return () => window.clearInterval(id);
2163
+ }, [answer.done, answer.startedAt]);
2164
+ const liveElapsed = answer.startedAt != null ? Date.now() - answer.startedAt : null;
2165
+ const finalDuration = answer.durationMs;
2166
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2167
+ "div",
2168
+ {
2169
+ ref: forwardRef,
2170
+ className: cn(
2171
+ "flex flex-col gap-4 scroll-mt-2",
2172
+ // Reserve space below the latest turn so the AI response has a
2173
+ // full screen to fill without bouncing the user. Calc subtracts
2174
+ // the chat header (~3rem) and the input pill area (~10rem) from
2175
+ // the dynamic viewport height.
2176
+ isLast && "min-h-[calc(100dvh-13rem)]"
2177
+ ),
2178
+ children: [
2179
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsxRuntime.jsx(UserChip2, { text: answer.question }) }),
2180
+ isThinking ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
2181
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Sparkles, { className: "size-4" }) }),
2182
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "flex items-center text-sm text-muted-foreground", children: [
2183
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "mr-1 inline size-3.5 animate-spin" }),
2184
+ "Thinking\u2026",
2185
+ liveElapsed != null && liveElapsed >= 1e3 && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-2 text-xs tabular-nums", children: [
2186
+ "(",
2187
+ formatDuration2(liveElapsed),
2188
+ ")"
2189
+ ] })
2190
+ ] })
2191
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-3", children: [
2192
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Sparkles, { className: "size-4" }) }),
2193
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: [
2194
+ /* @__PURE__ */ jsxRuntime.jsx(AnswerBlocks, { blocks: answer.blocks }),
2195
+ answer.error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "wrap-break-word whitespace-pre-wrap rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100", children: [
2196
+ answer.error.code,
2197
+ ": ",
2198
+ answer.error.message
2199
+ ] }),
2200
+ showActions && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
2201
+ (answer.blocks.length > 0 || answer.error != null) && /* @__PURE__ */ jsxRuntime.jsx(
2202
+ "button",
2203
+ {
2204
+ type: "button",
2205
+ onClick: handleCopy,
2206
+ "aria-label": answer.blocks.length === 0 && answer.error ? "Copy error" : "Copy response",
2207
+ title: copied ? "Copied" : "Copy",
2208
+ className: "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
2209
+ children: copied ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { className: "size-4" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Copy, { className: "size-4" })
2210
+ }
2211
+ ),
2212
+ /* @__PURE__ */ jsxRuntime.jsx(
2213
+ "button",
2214
+ {
2215
+ type: "button",
2216
+ onClick: onRetry,
2217
+ "aria-label": "Retry",
2218
+ title: "Retry",
2219
+ className: "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
2220
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.RotateCcw, { className: "size-4" })
2221
+ }
2222
+ ),
2223
+ finalDuration != null && finalDuration >= 1e3 && /* @__PURE__ */ jsxRuntime.jsx(
2224
+ "span",
2225
+ {
2226
+ className: "ml-1 text-xs text-muted-foreground tabular-nums",
2227
+ title: "Time taken to generate this response",
2228
+ children: formatDuration2(finalDuration)
2229
+ }
2230
+ )
2231
+ ] })
2232
+ ] })
2233
+ ] })
2234
+ ]
2235
+ }
2236
+ );
2237
+ }
2238
+ function blocksToPlainText2(blocks) {
2239
+ return blocks.map((b) => {
2240
+ if (b.kind === "paragraph_brief") {
2241
+ return b.prose || b.key_facts.join(". ");
2242
+ }
2243
+ if (b.kind === "list") {
2244
+ const lines = b.items.map(
2245
+ (it, i) => b.style === "numbered" ? `${i + 1}. ${it}` : `- ${it}`
2246
+ );
2247
+ return [b.title, ...lines].filter(Boolean).join("\n");
2248
+ }
2249
+ if (b.kind === "chart") {
2250
+ return `${b.title} (chart)`;
2251
+ }
2252
+ if (b.kind === "table") {
2253
+ const header = b.columns.join(" ");
2254
+ const rows = b.rows.map(
2255
+ (r) => r.map((c) => c == null ? "\u2014" : String(c)).join(" ")
2256
+ );
2257
+ return [b.title, header, ...rows].join("\n");
2258
+ }
2259
+ if (b.kind === "callout") return b.text;
2260
+ return "";
2261
+ }).filter(Boolean).join("\n\n");
2262
+ }
2263
+ function UserChip2({ text }) {
2264
+ const [expanded, setExpanded] = React.useState(false);
2265
+ const [overflowing, setOverflowing] = React.useState(false);
2266
+ const ref = React.useRef(null);
2267
+ React.useLayoutEffect(() => {
2268
+ const el = ref.current;
2269
+ if (!el || expanded) return;
2270
+ setOverflowing(el.scrollHeight > el.clientHeight + 1);
2271
+ }, [text, expanded]);
2272
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "max-w-[85%] rounded-2xl bg-muted px-4 py-2 text-sm text-foreground", children: [
2273
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative", children: [
2274
+ /* @__PURE__ */ jsxRuntime.jsx(
2275
+ "div",
2276
+ {
2277
+ ref,
2278
+ className: cn(
2279
+ "whitespace-pre-wrap wrap-break-word leading-6",
2280
+ // 3 lines × leading-6 (24px) = 72px → max-h-18.
2281
+ !expanded && "max-h-18 overflow-hidden"
2282
+ ),
2283
+ children: text
2284
+ }
2285
+ ),
2286
+ !expanded && overflowing && /* @__PURE__ */ jsxRuntime.jsx(
2287
+ "div",
2288
+ {
2289
+ "aria-hidden": "true",
2290
+ className: "pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-muted to-transparent"
2291
+ }
2292
+ )
2293
+ ] }),
2294
+ (overflowing || expanded) && /* @__PURE__ */ jsxRuntime.jsx(
2295
+ "button",
2296
+ {
2297
+ type: "button",
2298
+ onClick: () => setExpanded((v) => !v),
2299
+ className: "mt-1 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground",
2300
+ children: expanded ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2301
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronUp, { className: "size-3.5" }),
2302
+ "Show less"
2303
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2304
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ChevronDown, { className: "size-3.5" }),
2305
+ "Show more"
2306
+ ] })
2307
+ }
2308
+ )
2309
+ ] });
2310
+ }
2311
+ function storedToUseChat(stored) {
2312
+ const uiMessages = [];
2313
+ const blocksMap = {};
2314
+ const proseMap = {};
2315
+ const errorsMap = {};
2316
+ for (const m of stored) {
2317
+ if (m.role === "user") {
2318
+ const id = `user-${m.id}`;
2319
+ const question = m.question ?? "";
2320
+ uiMessages.push({ id, role: "user", content: question });
2321
+ continue;
2322
+ }
2323
+ if (m.role === "assistant") {
2324
+ const id = `assistant-${m.id}`;
2325
+ const blocks = [];
2326
+ const stored2 = m.blocks ?? [];
2327
+ stored2.forEach((b, i) => {
2328
+ const sanitised = sanitiseBlock({ ...b});
2329
+ if (sanitised.kind === "paragraph_brief") {
2330
+ sanitised.prose = m.prose?.[String(i)] ?? "";
2331
+ }
2332
+ blocks[i] = sanitised;
2333
+ });
2334
+ const joinedProse = Object.values(m.prose ?? {}).filter(Boolean).join("\n\n");
2335
+ uiMessages.push({
2336
+ id,
2337
+ role: "assistant",
2338
+ content: joinedProse
2339
+ });
2340
+ blocksMap[id] = blocks;
2341
+ if (joinedProse) proseMap[id] = joinedProse;
2342
+ if (m.errorJson) errorsMap[id] = m.errorJson;
2343
+ }
2344
+ }
2345
+ return { uiMessages, blocksMap, proseMap, errorsMap };
2346
+ }
2347
+
2348
+ // src/ui/index.tsx
2349
+ var chatInterfaces = [
2350
+ {
2351
+ id: "custom",
2352
+ label: "Custom",
2353
+ description: "Bespoke SSE chat with structured blocks and prose narrator pass.",
2354
+ Component: AiChat
2355
+ },
2356
+ {
2357
+ id: "vercel",
2358
+ label: "Vercel",
2359
+ description: "Vercel AI SDK chat with native tool loop and data-stream protocol.",
2360
+ Component: VercelChat
2361
+ }
2362
+ ];
2363
+ function getChatInterface(id) {
2364
+ return chatInterfaces.find((c) => c.id === id) ?? chatInterfaces[0];
2365
+ }
1432
2366
 
1433
2367
  exports.AiChat = AiChat;
1434
2368
  exports.AnswerBlocks = AnswerBlocks;
2369
+ exports.VercelChat = VercelChat;
2370
+ exports.chatInterfaces = chatInterfaces;
2371
+ exports.getChatInterface = getChatInterface;
1435
2372
  exports.sanitiseBlock = sanitiseBlock;
1436
2373
  //# sourceMappingURL=index.cjs.map
1437
2374
  //# sourceMappingURL=index.cjs.map