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