@pylonsync/create-pylon 0.19.1 → 0.20.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.
Files changed (675) hide show
  1. package/bin/create-pylon.js +30 -7
  2. package/package.json +1 -1
  3. package/templates/agency/.env.example +7 -0
  4. package/templates/agency/README.md +28 -3
  5. package/templates/agency/app/(marketing)/contact-form.tsx +20 -18
  6. package/templates/agency/app/(marketing)/page.tsx +3 -3
  7. package/templates/agency/app/(marketing)/work/page.tsx +3 -3
  8. package/templates/agency/app/auth-form.tsx +4 -4
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +110 -64
  10. package/templates/agency/app/dashboard/page.tsx +14 -16
  11. package/templates/agency/app/icon.svg +3 -0
  12. package/templates/agency/app/login/page.tsx +12 -14
  13. package/templates/agency/components/marketing.tsx +12 -5
  14. package/templates/agency/functions/seedStudioBackoffice.ts +42 -11
  15. package/templates/agency/lib/agency.ts +22 -0
  16. package/templates/agency/lib/demo.ts +118 -0
  17. package/templates/agency/lib/site.config.ts +19 -13
  18. package/templates/agency/public/favicon.ico +0 -0
  19. package/templates/agency/tests/agency.test.ts +57 -0
  20. package/templates/ai-chat/.env.example +13 -27
  21. package/templates/ai-chat/README.md +53 -60
  22. package/templates/ai-chat/app/(chat)/c/[id]/page.tsx +19 -0
  23. package/templates/ai-chat/app/(chat)/chat-client.tsx +657 -622
  24. package/templates/ai-chat/app/(chat)/layout.tsx +6 -43
  25. package/templates/ai-chat/app/(chat)/not-found.tsx +6 -10
  26. package/templates/ai-chat/app/(chat)/page.tsx +7 -11
  27. package/templates/ai-chat/app/auth-form.tsx +45 -38
  28. package/templates/ai-chat/app/error.tsx +9 -12
  29. package/templates/ai-chat/app/globals.css +464 -102
  30. package/templates/ai-chat/app/layout.tsx +11 -19
  31. package/templates/ai-chat/app/login/page.tsx +39 -24
  32. package/templates/ai-chat/app.ts +118 -43
  33. package/templates/ai-chat/components/chat/composer.tsx +193 -0
  34. package/templates/ai-chat/components/chat/empty-state.tsx +65 -0
  35. package/templates/ai-chat/components/chat/icons.tsx +198 -0
  36. package/templates/ai-chat/components/chat/markdown.tsx +164 -0
  37. package/templates/ai-chat/components/chat/message.tsx +183 -0
  38. package/templates/ai-chat/components/chat/setup.tsx +71 -0
  39. package/templates/ai-chat/components/chat/sidebar.tsx +288 -0
  40. package/templates/ai-chat/functions/_beginTurn.ts +145 -0
  41. package/templates/ai-chat/functions/_finishTurn.ts +55 -0
  42. package/templates/ai-chat/functions/assistantStatus.ts +13 -0
  43. package/templates/ai-chat/functions/claimGuestHistory.ts +39 -0
  44. package/templates/ai-chat/functions/createConversation.ts +28 -0
  45. package/templates/ai-chat/functions/deleteConversation.ts +15 -20
  46. package/templates/ai-chat/functions/prepareGuestClaim.ts +32 -0
  47. package/templates/ai-chat/functions/renameConversation.ts +19 -0
  48. package/templates/ai-chat/functions/respond.ts +88 -0
  49. package/templates/ai-chat/functions/seedExamples.ts +46 -0
  50. package/templates/ai-chat/functions/stopResponse.ts +30 -0
  51. package/templates/ai-chat/lib/brand-icon.ts +7 -0
  52. package/templates/ai-chat/lib/chat.ts +277 -0
  53. package/templates/ai-chat/lib/claim.ts +15 -0
  54. package/templates/ai-chat/lib/examples.ts +179 -0
  55. package/templates/ai-chat/lib/highlight.ts +107 -0
  56. package/templates/ai-chat/lib/markdown.ts +317 -0
  57. package/templates/ai-chat/lib/provider.ts +38 -0
  58. package/templates/ai-chat/lib/site.config.ts +55 -39
  59. package/templates/ai-chat/tests/chat.test.ts +174 -0
  60. package/templates/ai-chat/tests/components.test.tsx +57 -0
  61. package/templates/ai-chat/tests/markdown.test.ts +88 -0
  62. package/templates/ai-chat/tests/provider.test.ts +32 -0
  63. package/templates/backend/consumer/apps/api/app/layout.tsx +33 -0
  64. package/templates/backend/consumer/apps/api/app/page.tsx +54 -0
  65. package/templates/backend/consumer/apps/api/app.ts +166 -0
  66. package/templates/backend/consumer/apps/api/functions/addComment.ts +24 -0
  67. package/templates/backend/consumer/apps/api/functions/createPost.ts +19 -36
  68. package/templates/backend/consumer/apps/api/functions/deletePost.ts +15 -13
  69. package/templates/backend/consumer/apps/api/functions/myProfile.ts +5 -8
  70. package/templates/backend/consumer/apps/api/functions/seedDemo.ts +95 -0
  71. package/templates/backend/consumer/apps/api/functions/setFollow.ts +36 -0
  72. package/templates/backend/consumer/apps/api/functions/setLike.ts +36 -0
  73. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +43 -37
  74. package/templates/backend/consumer/apps/api/lib/seed.ts +89 -0
  75. package/templates/backend/consumer/apps/api/lib/social.ts +50 -0
  76. package/templates/backend/consumer/apps/api/package.json +8 -2
  77. package/templates/backend/consumer/apps/api/public/images/posts/arch.jpg +0 -0
  78. package/templates/backend/consumer/apps/api/public/images/posts/coffee.jpg +0 -0
  79. package/templates/backend/consumer/apps/api/public/images/posts/dog.jpg +0 -0
  80. package/templates/backend/consumer/apps/api/public/images/posts/lisbon.jpg +0 -0
  81. package/templates/backend/consumer/apps/api/public/images/posts/ramen.jpg +0 -0
  82. package/templates/backend/consumer/apps/api/public/images/posts/run.jpg +0 -0
  83. package/templates/backend/consumer/apps/api/public/images/posts/sunset.jpg +0 -0
  84. package/templates/backend/consumer/apps/api/tsconfig.json +12 -2
  85. package/templates/chat/README.md +43 -21
  86. package/templates/chat/app/chat-client.tsx +495 -85
  87. package/templates/chat/app/error.tsx +1 -1
  88. package/templates/chat/app/globals.css +170 -54
  89. package/templates/chat/app/layout.tsx +12 -9
  90. package/templates/chat/app/not-found.tsx +4 -2
  91. package/templates/chat/app/page.tsx +9 -19
  92. package/templates/chat/app.ts +96 -17
  93. package/templates/chat/components/chat/avatar.tsx +45 -0
  94. package/templates/chat/components/chat/composer.tsx +110 -0
  95. package/templates/chat/components/chat/message-list.tsx +285 -0
  96. package/templates/chat/components/chat/sidebar.tsx +190 -0
  97. package/templates/chat/functions/joinChat.ts +83 -0
  98. package/templates/chat/functions/renameMember.ts +25 -0
  99. package/templates/chat/lib/chat.ts +188 -0
  100. package/templates/chat/lib/seed.ts +78 -0
  101. package/templates/chat/tests/chat.test.ts +159 -0
  102. package/templates/chat/tests/composer.test.tsx +54 -0
  103. package/templates/consumer/README.md +3 -3
  104. package/templates/consumer/app/globals.css +10 -3
  105. package/templates/consumer/app/icon.svg +1 -0
  106. package/templates/consumer/components/social/avatar.tsx +25 -11
  107. package/templates/consumer/functions/seedFeed.ts +3 -3
  108. package/templates/consumer/lib/seed.ts +43 -85
  109. package/templates/consumer/public/favicon.ico +0 -0
  110. package/templates/consumer/public/images/posts/arch.jpg +0 -0
  111. package/templates/consumer/public/images/posts/coffee.jpg +0 -0
  112. package/templates/consumer/public/images/posts/dog.jpg +0 -0
  113. package/templates/consumer/public/images/posts/lisbon.jpg +0 -0
  114. package/templates/consumer/public/images/posts/ramen.jpg +0 -0
  115. package/templates/consumer/public/images/posts/run.jpg +0 -0
  116. package/templates/consumer/public/images/posts/sunset.jpg +0 -0
  117. package/templates/consumer/tests/seed.test.ts +39 -0
  118. package/templates/creator/.env.example +7 -0
  119. package/templates/creator/README.md +24 -5
  120. package/templates/creator/app/(marketing)/newsletter-signup.tsx +33 -12
  121. package/templates/creator/app/auth-form.tsx +7 -7
  122. package/templates/creator/app/dashboard/dashboard-client.tsx +137 -100
  123. package/templates/creator/app/dashboard/page.tsx +18 -18
  124. package/templates/creator/app/icon.svg +2 -0
  125. package/templates/creator/app/login/page.tsx +14 -14
  126. package/templates/creator/app.ts +4 -4
  127. package/templates/creator/functions/seedDemo.ts +32 -0
  128. package/templates/creator/functions/subscribe.ts +4 -15
  129. package/templates/creator/lib/demo.ts +94 -0
  130. package/templates/creator/lib/site.config.ts +15 -10
  131. package/templates/creator/lib/stats.ts +35 -0
  132. package/templates/creator/public/favicon.ico +0 -0
  133. package/templates/creator/tests/counter.test.ts +92 -0
  134. package/templates/creator/tests/live-count.test.tsx +45 -0
  135. package/templates/crm/README.md +22 -1
  136. package/templates/crm/app/apple-icon.png +0 -0
  137. package/templates/crm/app/companies/companies-view.tsx +32 -14
  138. package/templates/crm/app/companies/page.tsx +2 -1
  139. package/templates/crm/app/contacts/contacts-view.tsx +34 -10
  140. package/templates/crm/app/contacts/page.tsx +2 -1
  141. package/templates/crm/app/deals/[id]/deal-view.tsx +137 -98
  142. package/templates/crm/app/deals/[id]/page.tsx +2 -1
  143. package/templates/crm/app/globals.css +29 -14
  144. package/templates/crm/app/icon.svg +1 -0
  145. package/templates/crm/app/layout.tsx +7 -5
  146. package/templates/crm/app/page.tsx +2 -1
  147. package/templates/crm/app/pipeline-view.tsx +5 -5
  148. package/templates/crm/app/workspace.tsx +29 -17
  149. package/templates/crm/app.ts +5 -5
  150. package/templates/crm/components/activity-timeline.tsx +14 -4
  151. package/templates/crm/components/app-shell.tsx +94 -0
  152. package/templates/crm/components/auth-form.tsx +35 -5
  153. package/templates/crm/components/avatar.tsx +5 -1
  154. package/templates/crm/components/board-skeleton.tsx +5 -5
  155. package/templates/crm/components/brand-mark.tsx +25 -0
  156. package/templates/crm/components/command-palette.tsx +17 -17
  157. package/templates/crm/components/data-table.tsx +97 -36
  158. package/templates/crm/components/deal-card.tsx +23 -27
  159. package/templates/crm/components/filter-tabs.tsx +101 -0
  160. package/templates/crm/components/metrics-bar.tsx +6 -6
  161. package/templates/crm/components/nav.tsx +15 -0
  162. package/templates/crm/components/page-header.tsx +43 -7
  163. package/templates/crm/components/pipeline-board.tsx +16 -10
  164. package/templates/crm/components/require-auth.tsx +24 -26
  165. package/templates/crm/components/sidebar.tsx +54 -49
  166. package/templates/crm/components/stage-badge.tsx +4 -2
  167. package/templates/crm/components/ui/button.tsx +5 -4
  168. package/templates/crm/components/ui/dialog.tsx +1 -1
  169. package/templates/crm/components/ui/input.tsx +1 -1
  170. package/templates/crm/components/ui/select.tsx +1 -1
  171. package/templates/crm/components/ui/textarea.tsx +1 -1
  172. package/templates/crm/functions/seedWorkspace.ts +32 -17
  173. package/templates/crm/lib/brand.ts +9 -0
  174. package/templates/crm/lib/demo-team.ts +51 -0
  175. package/templates/crm/lib/pipeline.ts +37 -0
  176. package/templates/crm/lib/search.ts +4 -3
  177. package/templates/crm/lib/seed.ts +53 -8
  178. package/templates/crm/public/favicon.ico +0 -0
  179. package/templates/crm/tests/components.test.tsx +5 -5
  180. package/templates/crm/tests/demo-team.test.ts +18 -0
  181. package/templates/crm/tests/pipeline.test.ts +59 -3
  182. package/templates/directory/.env.example +7 -0
  183. package/templates/directory/README.md +22 -3
  184. package/templates/directory/app/(marketing)/directory-browse.tsx +30 -12
  185. package/templates/directory/app/(marketing)/page.tsx +6 -4
  186. package/templates/directory/app/auth-form.tsx +4 -4
  187. package/templates/directory/app/dashboard/dashboard-client.tsx +177 -90
  188. package/templates/directory/app/dashboard/page.tsx +16 -14
  189. package/templates/directory/app/icon.svg +4 -0
  190. package/templates/directory/app/login/page.tsx +16 -15
  191. package/templates/directory/functions/seedDemo.ts +43 -0
  192. package/templates/directory/lib/demo.ts +145 -0
  193. package/templates/directory/lib/directory.ts +27 -0
  194. package/templates/directory/lib/site.config.ts +21 -16
  195. package/templates/directory/public/favicon.ico +0 -0
  196. package/templates/directory/tests/directory.test.ts +46 -0
  197. package/templates/expo/consumer/apps/expo/App.tsx +360 -288
  198. package/templates/expo/consumer/apps/expo/app.json +9 -1
  199. package/templates/expo/consumer/apps/expo/package.json +2 -1
  200. package/templates/helpdesk/README.md +21 -0
  201. package/templates/helpdesk/app/apple-icon.png +0 -0
  202. package/templates/helpdesk/app/customers/customers-view.tsx +23 -7
  203. package/templates/helpdesk/app/customers/page.tsx +2 -1
  204. package/templates/helpdesk/app/globals.css +29 -14
  205. package/templates/helpdesk/app/icon.svg +1 -0
  206. package/templates/helpdesk/app/inbox-view.tsx +66 -47
  207. package/templates/helpdesk/app/layout.tsx +7 -5
  208. package/templates/helpdesk/app/page.tsx +4 -7
  209. package/templates/helpdesk/app/tickets/[id]/page.tsx +2 -1
  210. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +134 -97
  211. package/templates/helpdesk/app/workspace.tsx +33 -22
  212. package/templates/helpdesk/app.ts +2 -2
  213. package/templates/helpdesk/components/app-shell.tsx +94 -0
  214. package/templates/helpdesk/components/auth-form.tsx +35 -5
  215. package/templates/helpdesk/components/avatar.tsx +5 -1
  216. package/templates/helpdesk/components/brand-mark.tsx +28 -0
  217. package/templates/helpdesk/components/command-palette.tsx +17 -17
  218. package/templates/helpdesk/components/data-table.tsx +97 -36
  219. package/templates/helpdesk/components/filter-tabs.tsx +101 -0
  220. package/templates/helpdesk/components/nav.tsx +14 -0
  221. package/templates/helpdesk/components/page-header.tsx +43 -7
  222. package/templates/helpdesk/components/priority-badge.tsx +49 -6
  223. package/templates/helpdesk/components/require-auth.tsx +24 -26
  224. package/templates/helpdesk/components/sidebar.tsx +54 -48
  225. package/templates/helpdesk/components/sla-indicator.tsx +5 -8
  226. package/templates/helpdesk/components/thread.tsx +7 -7
  227. package/templates/helpdesk/components/ticket-list.tsx +79 -19
  228. package/templates/helpdesk/components/ui/button.tsx +5 -4
  229. package/templates/helpdesk/components/ui/dialog.tsx +1 -1
  230. package/templates/helpdesk/components/ui/input.tsx +1 -1
  231. package/templates/helpdesk/components/ui/select.tsx +1 -1
  232. package/templates/helpdesk/components/ui/textarea.tsx +1 -1
  233. package/templates/helpdesk/functions/seedWorkspace.ts +25 -15
  234. package/templates/helpdesk/lib/brand.ts +9 -0
  235. package/templates/helpdesk/lib/demo-team.ts +51 -0
  236. package/templates/helpdesk/lib/search.ts +4 -3
  237. package/templates/helpdesk/lib/seed.ts +43 -2
  238. package/templates/helpdesk/lib/tickets.ts +68 -20
  239. package/templates/helpdesk/public/favicon.ico +0 -0
  240. package/templates/helpdesk/tests/components.test.tsx +10 -0
  241. package/templates/helpdesk/tests/demo-team.test.ts +18 -0
  242. package/templates/helpdesk/tests/tickets.test.ts +70 -0
  243. package/templates/imessage/.env.example +49 -0
  244. package/templates/imessage/AGENTS.md +153 -0
  245. package/templates/imessage/CLAUDE.md +7 -0
  246. package/templates/imessage/README.md +187 -0
  247. package/templates/imessage/app/console.tsx +382 -0
  248. package/templates/imessage/app/contacts/page.tsx +13 -0
  249. package/templates/imessage/app/error.tsx +16 -0
  250. package/templates/imessage/app/gate.tsx +31 -0
  251. package/templates/imessage/app/globals.css +183 -0
  252. package/templates/imessage/app/layout.tsx +17 -0
  253. package/templates/imessage/app/login/login-form.tsx +115 -0
  254. package/templates/imessage/app/login/page.tsx +30 -0
  255. package/templates/imessage/app/not-found.tsx +13 -0
  256. package/templates/imessage/app/page.tsx +14 -0
  257. package/templates/imessage/app/robots.ts +6 -0
  258. package/templates/imessage/app/setup/page.tsx +13 -0
  259. package/templates/imessage/app/sign-out.tsx +20 -0
  260. package/templates/imessage/app.ts +299 -0
  261. package/templates/imessage/bunfig +4 -0
  262. package/templates/imessage/components/avatar.tsx +46 -0
  263. package/templates/imessage/components/contact-panel.tsx +263 -0
  264. package/templates/imessage/components/contacts-view.tsx +172 -0
  265. package/templates/imessage/components/conversation-list.tsx +129 -0
  266. package/templates/imessage/components/nav-rail.tsx +67 -0
  267. package/templates/imessage/components/setup-view.tsx +408 -0
  268. package/templates/imessage/components/thread.tsx +229 -0
  269. package/templates/imessage/components/ui/badge.tsx +37 -0
  270. package/templates/imessage/components/ui/button.tsx +56 -0
  271. package/templates/imessage/components/ui/card.tsx +90 -0
  272. package/templates/imessage/components/ui/dialog.tsx +110 -0
  273. package/templates/imessage/components/ui/input.tsx +30 -0
  274. package/templates/imessage/components/ui/label.tsx +21 -0
  275. package/templates/imessage/components/ui/select.tsx +39 -0
  276. package/templates/imessage/components/ui/table.tsx +124 -0
  277. package/templates/imessage/components/ui/textarea.tsx +24 -0
  278. package/templates/imessage/components.json +20 -0
  279. package/templates/imessage/functions/answerNow.ts +34 -0
  280. package/templates/imessage/functions/assistant.ts +96 -0
  281. package/templates/imessage/functions/beginTurn.ts +101 -0
  282. package/templates/imessage/functions/cancelReminder.ts +13 -0
  283. package/templates/imessage/functions/claimOutbound.ts +44 -0
  284. package/templates/imessage/functions/clearDemo.ts +18 -0
  285. package/templates/imessage/functions/deleteNote.ts +11 -0
  286. package/templates/imessage/functions/deliverOutbound.ts +55 -0
  287. package/templates/imessage/functions/finishTurn.ts +84 -0
  288. package/templates/imessage/functions/fireReminder.ts +42 -0
  289. package/templates/imessage/functions/ingestInbound.ts +22 -0
  290. package/templates/imessage/functions/ownerAccount.ts +13 -0
  291. package/templates/imessage/functions/processTurn.ts +74 -0
  292. package/templates/imessage/functions/relayApply.ts +102 -0
  293. package/templates/imessage/functions/relaySync.ts +63 -0
  294. package/templates/imessage/functions/releaseOutbound.ts +18 -0
  295. package/templates/imessage/functions/resolveTurn.ts +25 -0
  296. package/templates/imessage/functions/saveContact.ts +27 -0
  297. package/templates/imessage/functions/seedDemo.ts +70 -0
  298. package/templates/imessage/functions/sendOwnerMessage.ts +28 -0
  299. package/templates/imessage/functions/sendblueWebhook.ts +49 -0
  300. package/templates/imessage/functions/setContactStatus.ts +17 -0
  301. package/templates/imessage/functions/settleOutbound.ts +35 -0
  302. package/templates/imessage/functions/setupStatus.ts +33 -0
  303. package/templates/imessage/functions/sweep.ts +58 -0
  304. package/templates/imessage/functions/toolCancelReminder.ts +17 -0
  305. package/templates/imessage/functions/toolForgetNote.ts +15 -0
  306. package/templates/imessage/functions/toolListNotes.ts +16 -0
  307. package/templates/imessage/functions/toolListReminders.ts +17 -0
  308. package/templates/imessage/functions/toolSaveNote.ts +22 -0
  309. package/templates/imessage/functions/toolSetReminder.ts +55 -0
  310. package/templates/imessage/functions/updateSettings.ts +48 -0
  311. package/templates/imessage/gitignore +11 -0
  312. package/templates/imessage/lib/agent-call.ts +251 -0
  313. package/templates/imessage/lib/assistant.ts +74 -0
  314. package/templates/imessage/lib/chunk.ts +135 -0
  315. package/templates/imessage/lib/demo.ts +68 -0
  316. package/templates/imessage/lib/format.ts +170 -0
  317. package/templates/imessage/lib/gate.ts +104 -0
  318. package/templates/imessage/lib/handles.ts +67 -0
  319. package/templates/imessage/lib/provider.ts +38 -0
  320. package/templates/imessage/lib/relay-protocol.ts +95 -0
  321. package/templates/imessage/lib/reminders.ts +41 -0
  322. package/templates/imessage/lib/secrets.ts +59 -0
  323. package/templates/imessage/lib/sendblue.ts +173 -0
  324. package/templates/imessage/lib/store.ts +407 -0
  325. package/templates/imessage/lib/transport.ts +135 -0
  326. package/templates/imessage/lib/utils.ts +10 -0
  327. package/templates/imessage/package.json +40 -0
  328. package/templates/imessage/relay/.env.example +14 -0
  329. package/templates/imessage/relay/applescript.ts +70 -0
  330. package/templates/imessage/relay/chatdb.ts +165 -0
  331. package/templates/imessage/relay/client.ts +41 -0
  332. package/templates/imessage/relay/main.ts +137 -0
  333. package/templates/imessage/relay/state.ts +35 -0
  334. package/templates/imessage/tests/agent-call.test.ts +112 -0
  335. package/templates/imessage/tests/applescript.test.ts +88 -0
  336. package/templates/imessage/tests/chatdb.test.ts +139 -0
  337. package/templates/imessage/tests/chunk.test.ts +61 -0
  338. package/templates/imessage/tests/fake-ctx.ts +88 -0
  339. package/templates/imessage/tests/ingest.test.ts +141 -0
  340. package/templates/imessage/tests/lib.test.ts +99 -0
  341. package/templates/imessage/tests/review-fixes.test.ts +138 -0
  342. package/templates/imessage/tests/secrets.test.ts +36 -0
  343. package/templates/imessage/tests/sendblue.test.ts +143 -0
  344. package/templates/imessage/tests/setup.ts +5 -0
  345. package/templates/imessage/tsconfig.json +18 -0
  346. package/templates/inventory/README.md +21 -0
  347. package/templates/inventory/app/apple-icon.png +0 -0
  348. package/templates/inventory/app/globals.css +29 -14
  349. package/templates/inventory/app/icon.svg +1 -0
  350. package/templates/inventory/app/layout.tsx +7 -5
  351. package/templates/inventory/app/movements/movements-view.tsx +32 -10
  352. package/templates/inventory/app/movements/page.tsx +2 -1
  353. package/templates/inventory/app/page.tsx +4 -6
  354. package/templates/inventory/app/products/[id]/page.tsx +2 -1
  355. package/templates/inventory/app/products/[id]/product-view.tsx +55 -25
  356. package/templates/inventory/app/products-view.tsx +74 -33
  357. package/templates/inventory/app/workspace.tsx +34 -16
  358. package/templates/inventory/app.ts +10 -2
  359. package/templates/inventory/components/app-shell.tsx +94 -0
  360. package/templates/inventory/components/auth-form.tsx +35 -5
  361. package/templates/inventory/components/avatar.tsx +5 -1
  362. package/templates/inventory/components/brand-mark.tsx +25 -0
  363. package/templates/inventory/components/category-tile.tsx +49 -0
  364. package/templates/inventory/components/command-palette.tsx +17 -17
  365. package/templates/inventory/components/data-table.tsx +97 -36
  366. package/templates/inventory/components/filter-tabs.tsx +101 -0
  367. package/templates/inventory/components/nav.tsx +14 -0
  368. package/templates/inventory/components/page-header.tsx +43 -7
  369. package/templates/inventory/components/require-auth.tsx +24 -26
  370. package/templates/inventory/components/sidebar.tsx +54 -48
  371. package/templates/inventory/components/stock-level.tsx +30 -14
  372. package/templates/inventory/components/summary-bar.tsx +8 -15
  373. package/templates/inventory/components/ui/button.tsx +5 -4
  374. package/templates/inventory/components/ui/dialog.tsx +1 -1
  375. package/templates/inventory/components/ui/input.tsx +1 -1
  376. package/templates/inventory/components/ui/select.tsx +1 -1
  377. package/templates/inventory/components/ui/textarea.tsx +1 -1
  378. package/templates/inventory/functions/seedWorkspace.ts +18 -8
  379. package/templates/inventory/lib/brand.ts +9 -0
  380. package/templates/inventory/lib/demo-team.ts +51 -0
  381. package/templates/inventory/lib/search.ts +4 -3
  382. package/templates/inventory/lib/seed.ts +29 -2
  383. package/templates/inventory/lib/stock.ts +32 -0
  384. package/templates/inventory/public/favicon.ico +0 -0
  385. package/templates/inventory/tests/demo-team.test.ts +18 -0
  386. package/templates/inventory/tests/stock.test.ts +29 -0
  387. package/templates/invoices/README.md +16 -0
  388. package/templates/invoices/app/apple-icon.png +0 -0
  389. package/templates/invoices/app/clients/clients-view.tsx +18 -6
  390. package/templates/invoices/app/clients/page.tsx +2 -1
  391. package/templates/invoices/app/globals.css +29 -14
  392. package/templates/invoices/app/icon.svg +1 -0
  393. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +72 -48
  394. package/templates/invoices/app/invoices/[id]/page.tsx +2 -1
  395. package/templates/invoices/app/invoices-view.tsx +81 -32
  396. package/templates/invoices/app/layout.tsx +7 -5
  397. package/templates/invoices/app/page.tsx +4 -6
  398. package/templates/invoices/app/workspace.tsx +26 -16
  399. package/templates/invoices/app.ts +2 -2
  400. package/templates/invoices/components/app-shell.tsx +94 -0
  401. package/templates/invoices/components/auth-form.tsx +35 -5
  402. package/templates/invoices/components/avatar.tsx +5 -1
  403. package/templates/invoices/components/brand-mark.tsx +26 -0
  404. package/templates/invoices/components/command-palette.tsx +17 -17
  405. package/templates/invoices/components/data-table.tsx +97 -36
  406. package/templates/invoices/components/filter-tabs.tsx +101 -0
  407. package/templates/invoices/components/line-items.tsx +19 -14
  408. package/templates/invoices/components/nav.tsx +14 -0
  409. package/templates/invoices/components/page-header.tsx +43 -7
  410. package/templates/invoices/components/require-auth.tsx +24 -26
  411. package/templates/invoices/components/sidebar.tsx +54 -48
  412. package/templates/invoices/components/status-badge.tsx +7 -7
  413. package/templates/invoices/components/summary-bar.tsx +5 -5
  414. package/templates/invoices/components/ui/button.tsx +5 -4
  415. package/templates/invoices/components/ui/dialog.tsx +1 -1
  416. package/templates/invoices/components/ui/input.tsx +1 -1
  417. package/templates/invoices/components/ui/select.tsx +1 -1
  418. package/templates/invoices/components/ui/textarea.tsx +1 -1
  419. package/templates/invoices/functions/recordPayment.ts +3 -0
  420. package/templates/invoices/lib/billing.ts +40 -0
  421. package/templates/invoices/lib/brand.ts +9 -0
  422. package/templates/invoices/lib/search.ts +4 -3
  423. package/templates/invoices/public/favicon.ico +0 -0
  424. package/templates/invoices/tests/billing.test.ts +27 -0
  425. package/templates/ios/consumer/apps/ios/Package.swift +4 -3
  426. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ActivityView.swift +107 -0
  427. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AppModel.swift +171 -0
  428. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/AuthView.swift +182 -0
  429. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/CommentsView.swift +136 -0
  430. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ComposeView.swift +152 -0
  431. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ExploreView.swift +117 -0
  432. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +175 -159
  433. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/MainTabView.swift +54 -0
  434. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Media.swift +271 -0
  435. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +101 -4
  436. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/PostCard.swift +245 -0
  437. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileEditorView.swift +208 -0
  438. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileView.swift +183 -0
  439. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -31
  440. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/SocialStore.swift +285 -0
  441. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/StoryViewer.swift +155 -0
  442. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Theme.swift +82 -0
  443. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +4 -47
  444. package/templates/ios/consumer/apps/ios/project.yml +13 -9
  445. package/templates/local-service/.env.example +7 -0
  446. package/templates/local-service/README.md +26 -2
  447. package/templates/local-service/app/(marketing)/booking-widget.tsx +48 -9
  448. package/templates/local-service/app/(marketing)/page.tsx +66 -42
  449. package/templates/local-service/app/auth-form.tsx +8 -8
  450. package/templates/local-service/app/dashboard/dashboard-client.tsx +119 -92
  451. package/templates/local-service/app/dashboard/page.tsx +14 -14
  452. package/templates/local-service/app/icon.svg +2 -0
  453. package/templates/local-service/app/login/page.tsx +15 -15
  454. package/templates/local-service/functions/seedDemo.ts +49 -0
  455. package/templates/local-service/lib/demo.ts +135 -0
  456. package/templates/local-service/lib/hours.ts +44 -0
  457. package/templates/local-service/lib/site.config.ts +8 -3
  458. package/templates/local-service/lib/slots.ts +3 -0
  459. package/templates/local-service/public/favicon.ico +0 -0
  460. package/templates/local-service/tests/booking.test.ts +104 -0
  461. package/templates/marketplace/README.md +23 -15
  462. package/templates/marketplace/app/globals.css +172 -114
  463. package/templates/marketplace/app/layout.tsx +75 -30
  464. package/templates/marketplace/app/listing/[id]/page.tsx +210 -123
  465. package/templates/marketplace/app/me/page.tsx +4 -4
  466. package/templates/marketplace/app/page.tsx +221 -298
  467. package/templates/marketplace/app/sell/page.tsx +8 -11
  468. package/templates/marketplace/app.ts +32 -7
  469. package/templates/marketplace/client/ActivityFeed.tsx +311 -0
  470. package/templates/marketplace/client/AuthNav.tsx +24 -16
  471. package/templates/marketplace/client/ListingCard.tsx +118 -0
  472. package/templates/marketplace/client/LiveGrid.tsx +92 -0
  473. package/templates/marketplace/client/LiveListingStatus.tsx +32 -24
  474. package/templates/marketplace/client/LoginCard.tsx +114 -136
  475. package/templates/marketplace/client/MarketProvider.tsx +3 -5
  476. package/templates/marketplace/client/MyMarket.tsx +371 -185
  477. package/templates/marketplace/client/OfferPanel.tsx +421 -287
  478. package/templates/marketplace/client/SellForm.tsx +271 -192
  479. package/templates/marketplace/client/ThemeToggle.tsx +1 -0
  480. package/templates/marketplace/client/WatchButton.tsx +16 -16
  481. package/templates/marketplace/client/market.ts +61 -14
  482. package/templates/marketplace/functions/seedMarket.ts +143 -36
  483. package/templates/marketplace/functions/seedOffers.ts +101 -0
  484. package/templates/marketplace/lib/catalog.ts +72 -1
  485. package/templates/marketplace/public/images/listings/ceramic-lamp-640.webp +0 -0
  486. package/templates/marketplace/public/images/listings/ceramic-lamp.webp +0 -0
  487. package/templates/marketplace/public/images/listings/duffel-640.webp +0 -0
  488. package/templates/marketplace/public/images/listings/duffel.webp +0 -0
  489. package/templates/marketplace/public/images/listings/film-camera-640.webp +0 -0
  490. package/templates/marketplace/public/images/listings/film-camera.webp +0 -0
  491. package/templates/marketplace/public/images/listings/lounge-chair-640.webp +0 -0
  492. package/templates/marketplace/public/images/listings/lounge-chair.webp +0 -0
  493. package/templates/marketplace/public/images/listings/road-bike-640.webp +0 -0
  494. package/templates/marketplace/public/images/listings/road-bike.webp +0 -0
  495. package/templates/marketplace/public/images/listings/slipper-chair-640.webp +0 -0
  496. package/templates/marketplace/public/images/listings/slipper-chair.webp +0 -0
  497. package/templates/marketplace/public/images/listings/turntable-640.webp +0 -0
  498. package/templates/marketplace/public/images/listings/turntable.webp +0 -0
  499. package/templates/marketplace/public/images/listings/wardrobe-640.webp +0 -0
  500. package/templates/marketplace/public/images/listings/wardrobe.webp +0 -0
  501. package/templates/marketplace/public/images/listings/watch-640.webp +0 -0
  502. package/templates/marketplace/public/images/listings/watch.webp +0 -0
  503. package/templates/marketplace/public/images/listings/weekender-bag-640.webp +0 -0
  504. package/templates/marketplace/public/images/listings/weekender-bag.webp +0 -0
  505. package/templates/marketplace/tests/example.test.ts +64 -2
  506. package/templates/marketplace/tests/listing-card.test.tsx +61 -0
  507. package/templates/projects/README.md +24 -3
  508. package/templates/projects/app/apple-icon.png +0 -0
  509. package/templates/projects/app/clients/clients-view.tsx +20 -6
  510. package/templates/projects/app/clients/page.tsx +2 -1
  511. package/templates/projects/app/globals.css +29 -14
  512. package/templates/projects/app/icon.svg +1 -0
  513. package/templates/projects/app/layout.tsx +7 -5
  514. package/templates/projects/app/page.tsx +3 -5
  515. package/templates/projects/app/projects/[id]/page.tsx +2 -1
  516. package/templates/projects/app/projects/[id]/project-view.tsx +45 -30
  517. package/templates/projects/app/projects-view.tsx +105 -52
  518. package/templates/projects/app/workspace.tsx +41 -20
  519. package/templates/projects/app.ts +2 -2
  520. package/templates/projects/components/app-shell.tsx +94 -0
  521. package/templates/projects/components/auth-form.tsx +35 -5
  522. package/templates/projects/components/avatar.tsx +5 -1
  523. package/templates/projects/components/board-skeleton.tsx +3 -3
  524. package/templates/projects/components/brand-mark.tsx +25 -0
  525. package/templates/projects/components/command-palette.tsx +17 -17
  526. package/templates/projects/components/data-table.tsx +97 -36
  527. package/templates/projects/components/filter-tabs.tsx +101 -0
  528. package/templates/projects/components/meter.tsx +48 -0
  529. package/templates/projects/components/nav.tsx +14 -0
  530. package/templates/projects/components/page-header.tsx +43 -7
  531. package/templates/projects/components/require-auth.tsx +24 -26
  532. package/templates/projects/components/sidebar.tsx +54 -48
  533. package/templates/projects/components/summary-bar.tsx +49 -0
  534. package/templates/projects/components/task-board.tsx +10 -9
  535. package/templates/projects/components/time-dialog.tsx +29 -5
  536. package/templates/projects/components/ui/button.tsx +5 -4
  537. package/templates/projects/components/ui/dialog.tsx +1 -1
  538. package/templates/projects/components/ui/input.tsx +1 -1
  539. package/templates/projects/components/ui/select.tsx +1 -1
  540. package/templates/projects/components/ui/textarea.tsx +1 -1
  541. package/templates/projects/functions/seedWorkspace.ts +26 -12
  542. package/templates/projects/lib/brand.ts +9 -0
  543. package/templates/projects/lib/demo-team.ts +51 -0
  544. package/templates/projects/lib/search.ts +4 -3
  545. package/templates/projects/lib/seed.ts +35 -1
  546. package/templates/projects/lib/work.ts +63 -0
  547. package/templates/projects/public/favicon.ico +0 -0
  548. package/templates/projects/tests/demo-team.test.ts +18 -0
  549. package/templates/projects/tests/work.test.ts +65 -0
  550. package/templates/restaurant/.env.example +7 -0
  551. package/templates/restaurant/README.md +29 -3
  552. package/templates/restaurant/app/(marketing)/layout.tsx +4 -2
  553. package/templates/restaurant/app/(marketing)/page.tsx +47 -23
  554. package/templates/restaurant/app/(marketing)/reservation-widget.tsx +37 -7
  555. package/templates/restaurant/app/auth-form.tsx +8 -8
  556. package/templates/restaurant/app/dashboard/dashboard-client.tsx +241 -109
  557. package/templates/restaurant/app/dashboard/page.tsx +14 -14
  558. package/templates/restaurant/app/icon.svg +4 -0
  559. package/templates/restaurant/app/login/page.tsx +15 -15
  560. package/templates/restaurant/functions/seedDemo.ts +44 -0
  561. package/templates/restaurant/lib/demo.ts +144 -0
  562. package/templates/restaurant/lib/hours.ts +45 -0
  563. package/templates/restaurant/lib/site.config.ts +3 -3
  564. package/templates/restaurant/public/favicon.ico +0 -0
  565. package/templates/restaurant/tests/reservations.test.ts +67 -0
  566. package/templates/saas/README.md +54 -35
  567. package/templates/saas/app/(auth)/auth-form.tsx +25 -23
  568. package/templates/saas/app/(auth)/forgot-password/page.tsx +1 -1
  569. package/templates/saas/app/(auth)/layout.tsx +27 -33
  570. package/templates/saas/app/(auth)/login/page.tsx +1 -1
  571. package/templates/saas/app/(auth)/reset-password/page.tsx +1 -1
  572. package/templates/saas/app/(auth)/signup/page.tsx +1 -1
  573. package/templates/saas/app/(marketing)/layout.tsx +127 -253
  574. package/templates/saas/app/(marketing)/page.tsx +133 -372
  575. package/templates/saas/app/(marketing)/pricing/page.tsx +21 -19
  576. package/templates/saas/app/(marketing)/products/[slug]/page.tsx +40 -95
  577. package/templates/saas/app/dashboard/board-client.tsx +416 -0
  578. package/templates/saas/app/dashboard/dashboard-client.tsx +30 -615
  579. package/templates/saas/app/dashboard/layout.tsx +27 -13
  580. package/templates/saas/app/dashboard/overview-client.tsx +332 -0
  581. package/templates/saas/app/dashboard/page.tsx +29 -35
  582. package/templates/saas/app/dashboard/projects/[id]/page.tsx +29 -0
  583. package/templates/saas/app/dashboard/projects/page.tsx +11 -7
  584. package/templates/saas/app/dashboard/projects-client.tsx +398 -0
  585. package/templates/saas/app/globals.css +5 -4
  586. package/templates/saas/app/icon.svg +1 -0
  587. package/templates/saas/app/onboarding/onboarding-client.tsx +93 -26
  588. package/templates/saas/app/onboarding/page.tsx +6 -2
  589. package/templates/saas/app.ts +68 -13
  590. package/templates/saas/components/dashboard-shell.tsx +212 -132
  591. package/templates/saas/components/marketing.tsx +137 -274
  592. package/templates/saas/components/pricing-table.tsx +12 -11
  593. package/templates/saas/components/setup-checklist.tsx +31 -29
  594. package/templates/saas/components/task-ui.tsx +199 -0
  595. package/templates/saas/functions/createProject.ts +7 -13
  596. package/templates/saas/functions/createTask.ts +67 -0
  597. package/templates/saas/functions/deleteProject.ts +17 -0
  598. package/templates/saas/functions/deleteTask.ts +14 -0
  599. package/templates/saas/functions/seedWorkspace.ts +46 -0
  600. package/templates/saas/functions/setProjectStatus.ts +41 -0
  601. package/templates/saas/functions/updateProfile.ts +13 -0
  602. package/templates/saas/functions/updateProject.ts +4 -5
  603. package/templates/saas/functions/updateTask.ts +88 -0
  604. package/templates/saas/lib/members.ts +53 -0
  605. package/templates/saas/lib/names.ts +53 -0
  606. package/templates/saas/lib/plans.ts +11 -0
  607. package/templates/saas/lib/sample-workspace.ts +117 -0
  608. package/templates/saas/lib/site.config.ts +200 -463
  609. package/templates/saas/lib/tasks.ts +101 -0
  610. package/templates/saas/lib/use-live.ts +25 -0
  611. package/templates/saas/public/favicon.ico +0 -0
  612. package/templates/saas/public/screenshots/board.png +0 -0
  613. package/templates/saas/public/screenshots/members.png +0 -0
  614. package/templates/saas/public/screenshots/overview.png +0 -0
  615. package/templates/saas/public/screenshots/projects.png +0 -0
  616. package/templates/saas/scripts/capture-screenshots.mjs +156 -0
  617. package/templates/saas/tests/names.test.ts +35 -0
  618. package/templates/saas/tests/plans.test.ts +12 -0
  619. package/templates/saas/tests/sample-workspace.test.ts +46 -0
  620. package/templates/saas/tests/tasks.test.ts +81 -0
  621. package/templates/shop/README.md +25 -13
  622. package/templates/shop/app/(marketing)/layout.tsx +94 -67
  623. package/templates/shop/app/(marketing)/not-found.tsx +13 -12
  624. package/templates/shop/app/(marketing)/page.tsx +120 -59
  625. package/templates/shop/app/(marketing)/shop-client.tsx +364 -237
  626. package/templates/shop/app/(marketing)/success/page.tsx +75 -22
  627. package/templates/shop/app/globals.css +94 -1
  628. package/templates/shop/app/layout.tsx +4 -4
  629. package/templates/shop/app/login/page.tsx +4 -4
  630. package/templates/shop/app.ts +18 -6
  631. package/templates/shop/components/cart-button.tsx +33 -0
  632. package/templates/shop/components/marketing.tsx +17 -87
  633. package/templates/shop/components/shelf-count.tsx +36 -0
  634. package/templates/shop/functions/checkout.ts +17 -1
  635. package/templates/shop/lib/cart-store.ts +57 -0
  636. package/templates/shop/lib/shelf-store.ts +35 -0
  637. package/templates/shop/lib/shop.ts +27 -0
  638. package/templates/shop/lib/site.config.ts +108 -98
  639. package/templates/shop/public/images/hero.jpg +0 -0
  640. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  641. package/templates/shop/public/images/products/fig-leaf.jpg +0 -0
  642. package/templates/shop/public/images/products/linen.jpg +0 -0
  643. package/templates/shop/public/images/products/sea-salt.jpg +0 -0
  644. package/templates/shop/public/images/products/tobacco-oak.jpg +0 -0
  645. package/templates/shop/tests/shop.test.ts +64 -0
  646. package/templates/waitlist/.env.example +7 -0
  647. package/templates/waitlist/README.md +29 -13
  648. package/templates/waitlist/app/(marketing)/page.tsx +9 -7
  649. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +64 -31
  650. package/templates/waitlist/app/auth-form.tsx +8 -8
  651. package/templates/waitlist/app/dashboard/dashboard-client.tsx +132 -95
  652. package/templates/waitlist/app/dashboard/page.tsx +17 -16
  653. package/templates/waitlist/app/icon.svg +2 -0
  654. package/templates/waitlist/app/login/page.tsx +14 -14
  655. package/templates/waitlist/app.ts +6 -6
  656. package/templates/waitlist/functions/joinWaitlist.ts +6 -18
  657. package/templates/waitlist/functions/seedDemo.ts +32 -0
  658. package/templates/waitlist/lib/demo.ts +94 -0
  659. package/templates/waitlist/lib/launch.ts +19 -0
  660. package/templates/waitlist/lib/site.config.ts +14 -9
  661. package/templates/waitlist/lib/stats.ts +35 -0
  662. package/templates/waitlist/public/favicon.ico +0 -0
  663. package/templates/waitlist/tests/counter.test.ts +107 -0
  664. package/templates/waitlist/tests/live-count.test.tsx +48 -0
  665. package/templates/ai-chat/tests/example.test.ts +0 -12
  666. package/templates/backend/consumer/apps/api/functions/feed.ts +0 -57
  667. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +0 -17
  668. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +0 -48
  669. package/templates/backend/consumer/apps/api/schema.ts +0 -143
  670. package/templates/chat/functions/_keep.ts +0 -13
  671. package/templates/chat/tests/example.test.ts +0 -12
  672. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +0 -68
  673. package/templates/marketplace/client/LiveTicker.tsx +0 -116
  674. package/templates/marketplace/client/ScrollToListingsLink.tsx +0 -29
  675. package/templates/marketplace/public/images/marketplace-hero.webp +0 -0
@@ -1,727 +1,762 @@
1
1
  "use client";
2
2
 
3
- import React, { useEffect, useRef, useState } from "react";
4
- import { db, callFn } from "@pylonsync/react";
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ callFn,
6
+ db,
7
+ getSync,
8
+ resumeStream,
9
+ storageKey,
10
+ streamFn,
11
+ useSyncStatus,
12
+ } from "@pylonsync/react";
13
+ import { EnsureGuest } from "@pylonsync/client";
5
14
  import { siteConfig } from "@/lib/site.config";
15
+ import {
16
+ cleanTitle,
17
+ describeError,
18
+ modelAllowedFor,
19
+ isStaleStream,
20
+ modelLabel,
21
+ sortMessages,
22
+ type ConversationRow,
23
+ type MessageRow,
24
+ } from "@/lib/chat";
25
+ import type { ProviderStatus } from "@/lib/provider";
26
+ import { Sidebar, type Account } from "@/components/chat/sidebar";
27
+ import { Composer } from "@/components/chat/composer";
28
+ import { AssistantMessage, UserMessage } from "@/components/chat/message";
29
+ import { EmptyState } from "@/components/chat/empty-state";
30
+ import { ProviderSetup } from "@/components/chat/setup";
31
+ import { AlertIcon, ArrowDownIcon, CloseIcon, ComposeIcon, MenuIcon } from "@/components/chat/icons";
32
+
33
+ // The chat app. This file is the only one that talks to Pylon: live queries
34
+ // for conversations and messages, `streamFn("respond")` to send, and
35
+ // `resumeStream` to follow a reply that another tab started. Everything under
36
+ // components/chat receives plain props.
37
+
38
+ export interface ChatAppProps {
39
+ initialConversationId: string | null;
40
+ // From the SSR session: a signed-in account (not a guest) and its id.
41
+ signedInUserId: string | null;
42
+ }
6
43
 
7
- // The chat app — a client island, rendered only for a SIGNED-IN user (the page
8
- // redirects anyone else to /login). Conversations + messages are sync-backed
9
- // owner-scoped entities (`db.useQuery`), private to your account and in lockstep
10
- // across your tabs + devices. Sending streams tokens from the built-in
11
- // `POST /api/ai/stream` (SSE) — your PYLON_AI_API_KEY never reaches the browser.
12
- //
13
- // All state lives in <ChatInner> (which owns `currentId`) — the thread is a
14
- // presentational child. That's deliberate: creating a conversation mid-send
15
- // changes `currentId`, and if the thread remounted on that change it would kill
16
- // the in-flight stream. One owner, no remounts.
17
-
18
- interface ConversationRow {
19
- id: string;
20
- userId: string;
21
- title: string;
22
- createdAt: string;
44
+ export function ChatApp(props: ChatAppProps) {
45
+ // A visitor without a session gets a guest session before the app renders.
46
+ return (
47
+ <EnsureGuest fallback={<ShellSkeleton />}>
48
+ <ChatShell {...props} />
49
+ </EnsureGuest>
50
+ );
23
51
  }
24
- interface MessageRow {
25
- id: string;
52
+
53
+ // The reply this tab is streaming. `knownIds` holds the message ids that
54
+ // existed when it started, so the new rows can be told apart as they sync in.
55
+ interface LocalTurn {
26
56
  conversationId: string;
27
- userId: string;
28
- role: string;
29
- content: string;
30
- createdAt: string;
57
+ userText: string | null;
58
+ text: string;
59
+ knownIds: Set<string>;
60
+ stopped: boolean;
61
+ }
62
+
63
+ const MODEL_KEY = "lumen:model";
64
+
65
+ function readStoredModel(isGuest: boolean): string {
66
+ try {
67
+ const v = window.localStorage.getItem(MODEL_KEY);
68
+ if (modelAllowedFor(v, isGuest)) return v as string;
69
+ } catch {
70
+ /* storage blocked */
71
+ }
72
+ return siteConfig.chat.defaultModel;
73
+ }
74
+
75
+ function pathFor(id: string | null): string {
76
+ return id ? `/c/${encodeURIComponent(id)}` : "/";
31
77
  }
32
78
 
33
- export function ChatApp() {
34
- return <ChatInner />;
79
+ function idFromPath(pathname: string): string | null {
80
+ const m = pathname.match(/^\/c\/([^/]+)\/?$/);
81
+ if (!m) return null;
82
+ try {
83
+ return decodeURIComponent(m[1]);
84
+ } catch {
85
+ return null;
86
+ }
35
87
  }
36
88
 
37
- function ChatInner() {
89
+ function ChatShell({ initialConversationId, signedInUserId }: ChatAppProps) {
38
90
  const { chat } = siteConfig;
39
- const { data: conversations } = db.useQuery<ConversationRow>("Conversation", {
40
- orderBy: { createdAt: "desc" },
41
- });
42
- const [currentId, setCurrentId] = useState<string | null>(null);
43
- const { data: messages } = db.useQuery<MessageRow>("Message", {
91
+
92
+ const { data: conversationRows, loading: conversationsLoading } =
93
+ db.useQuery<ConversationRow>("Conversation");
94
+ const [currentId, setCurrentId] = useState<string | null>(initialConversationId);
95
+ const { data: messageRows } = db.useQuery<MessageRow>("Message", {
44
96
  where: { conversationId: currentId ?? "__none__" },
45
- orderBy: { createdAt: "asc" },
46
97
  });
98
+ const messages = useMemo(() => sortMessages(messageRows), [messageRows]);
99
+ const conversations = conversationRows;
100
+ const current = conversations.find((c) => c.id === currentId) ?? null;
47
101
 
48
- const [streaming, setStreaming] = useState<string | null>(null);
49
- const [sending, setSending] = useState(false);
50
- const [notice, setNotice] = useState<string | null>(null);
102
+ const [provider, setProvider] = useState<ProviderStatus | null>(null);
51
103
  const [input, setInput] = useState("");
52
104
  const [model, setModel] = useState(chat.defaultModel);
53
- const scrollRef = useRef<HTMLDivElement>(null);
54
- const initialized = useRef(false);
105
+ const [local, setLocal] = useState<LocalTurn | null>(null);
106
+ // `turnRef` holds the live turn object the stream loop writes into;
107
+ // `local` is a copy for rendering.
108
+ const turnRef = useRef<LocalTurn | null>(null);
109
+ const [notice, setNotice] = useState<string | null>(null);
110
+ const [drawerOpen, setDrawerOpen] = useState(false);
111
+ const [setupOpen, setSetupOpen] = useState(false);
112
+ const [now, setNow] = useState(() => Date.now());
113
+ // The new-chat screen shows the composer mid-page on wide screens and docked
114
+ // at the bottom on narrow ones; each placement has its own textarea.
115
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
116
+ const dockTextareaRef = useRef<HTMLTextAreaElement>(null);
117
+ const syncStatus = useSyncStatus(getSync());
118
+
119
+ const startLocal = useCallback((turn: LocalTurn) => {
120
+ turnRef.current = turn;
121
+ setLocal({ ...turn });
122
+ }, []);
123
+ const renderLocal = useCallback((turn: LocalTurn) => {
124
+ if (turnRef.current === turn) setLocal({ ...turn });
125
+ }, []);
126
+ const endLocal = useCallback((turn: LocalTurn) => {
127
+ if (turnRef.current !== turn) return;
128
+ turnRef.current = null;
129
+ setLocal(null);
130
+ }, []);
131
+
132
+ /* ------------------------------ bootstrap ------------------------------ */
55
133
 
56
134
  useEffect(() => {
57
- if (!initialized.current && conversations.length > 0) {
58
- initialized.current = true;
59
- setCurrentId(conversations[0].id);
60
- }
61
- }, [conversations]);
135
+ setModel(readStoredModel(!signedInUserId));
136
+ let cancelled = false;
137
+ callFn<ProviderStatus>("assistantStatus")
138
+ .then((s) => !cancelled && setProvider(s))
139
+ .catch(() => !cancelled && setProvider({ configured: false, provider: null }));
140
+ const t = setInterval(() => setNow(Date.now()), 60_000);
141
+ return () => {
142
+ cancelled = true;
143
+ clearInterval(t);
144
+ };
145
+ }, []);
146
+
147
+ // With no provider key, a visitor with an empty history gets the example
148
+ // conversations from lib/examples.ts. The server re-checks both conditions.
149
+ const seeded = useRef(false);
150
+ useEffect(() => {
151
+ if (seeded.current || provider?.configured !== false) return;
152
+ if (conversationsLoading || conversations.length > 0) return;
153
+ seeded.current = true;
154
+ callFn("seedExamples").catch(() => {});
155
+ }, [provider, conversationsLoading, conversations.length]);
62
156
 
157
+ // Keep the URL and the open conversation in step, including Back/Forward.
63
158
  useEffect(() => {
64
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
65
- }, [messages.length, streaming]);
159
+ const onPop = () => {
160
+ setCurrentId(idFromPath(window.location.pathname));
161
+ setNotice(null);
162
+ };
163
+ window.addEventListener("popstate", onPop);
164
+ return () => window.removeEventListener("popstate", onPop);
165
+ }, []);
166
+
167
+ // A conversation deleted in another tab closes here too.
168
+ useEffect(() => {
169
+ if (currentId && !conversationsLoading && conversations.length > 0 && !current && !local) {
170
+ openConversation(null, "replace");
171
+ }
172
+ // eslint-disable-next-line react-hooks/exhaustive-deps
173
+ }, [currentId, current, conversationsLoading, conversations.length, local]);
66
174
 
67
- function selectConversation(id: string) {
175
+ function openConversation(id: string | null, mode: "push" | "replace" = "push") {
68
176
  setCurrentId(id);
69
- setStreaming(null);
70
177
  setNotice(null);
178
+ setDrawerOpen(false);
179
+ const path = pathFor(id);
180
+ if (window.location.pathname !== path) {
181
+ if (mode === "push") window.history.pushState(null, "", path);
182
+ else window.history.replaceState(null, "", path);
183
+ }
71
184
  }
185
+
72
186
  function newChat() {
73
- setCurrentId(null);
74
- setStreaming(null);
75
- setNotice(null);
76
- }
77
- async function renameConversation(id: string, title: string) {
78
- const t = title.trim().slice(0, 80);
79
- if (!t) return;
80
- await db.update("Conversation", id, { title: t });
187
+ openConversation(null);
188
+ setInput("");
189
+ requestAnimationFrame(() => {
190
+ const visible = [textareaRef.current, dockTextareaRef.current].find((el) => el && el.offsetParent !== null);
191
+ visible?.focus();
192
+ });
81
193
  }
82
- async function deleteConversation(id: string) {
83
- if (currentId === id) setCurrentId(null);
194
+
195
+ useEffect(() => {
196
+ function onKey(e: KeyboardEvent) {
197
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "o") {
198
+ e.preventDefault();
199
+ newChat();
200
+ }
201
+ }
202
+ window.addEventListener("keydown", onKey);
203
+ return () => window.removeEventListener("keydown", onKey);
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ }, []);
206
+
207
+ function changeModel(id: string) {
208
+ setModel(id);
84
209
  try {
85
- await callFn("deleteConversation", { conversationId: id });
210
+ window.localStorage.setItem(MODEL_KEY, id);
86
211
  } catch {
87
- /* ignore */
212
+ /* storage blocked */
88
213
  }
89
214
  }
90
215
 
91
- async function send(text: string) {
92
- const trimmed = text.trim();
93
- if (!trimmed || sending) return;
94
- setSending(true);
216
+ /* -------------------------------- send -------------------------------- */
217
+
218
+ async function runTurn(
219
+ conversationId: string,
220
+ args: { content?: string; regenerate?: boolean },
221
+ onStarted?: () => void,
222
+ ): Promise<"done" | "rejected" | "failed"> {
223
+ const turn: LocalTurn = {
224
+ conversationId,
225
+ userText: args.content ?? null,
226
+ text: "",
227
+ knownIds: new Set(messages.map((m) => m.id)),
228
+ stopped: false,
229
+ };
230
+ startLocal(turn);
231
+ onStarted?.();
95
232
  setNotice(null);
96
- setInput("");
97
233
 
98
- const history = messages.map((m) => ({ role: m.role, content: m.content }));
99
- let convId = currentId;
100
- if (!convId) {
101
- convId = await db.insert("Conversation", { title: trimmed.slice(0, 48) });
102
- setCurrentId(convId);
234
+ try {
235
+ const gen = streamFn("respond", { conversationId, model, ...args });
236
+ let result: unknown;
237
+ for (;;) {
238
+ const step = await gen.next();
239
+ if (step.done) {
240
+ result = step.value;
241
+ break;
242
+ }
243
+ if (turnRef.current !== turn || turn.stopped) {
244
+ await gen.return(undefined);
245
+ break;
246
+ }
247
+ turn.text += step.value;
248
+ renderLocal(turn);
249
+ }
250
+ const r = result as { status?: string; errorCode?: string; message?: string } | undefined;
251
+ if (r?.status === "rejected") {
252
+ setNotice(r.message || describeError(r.errorCode).title);
253
+ if (args.content) setInput((cur) => cur || args.content || "");
254
+ return "rejected";
255
+ }
256
+ return "done";
257
+ } catch {
258
+ if (turnRef.current === turn && !turn.stopped) {
259
+ setNotice("The connection dropped before the reply finished. It may still be completing; reload to check.");
260
+ }
261
+ return "failed";
262
+ } finally {
263
+ endLocal(turn);
103
264
  }
104
- await db.insert("Message", { conversationId: convId, role: "user", content: trimmed });
265
+ }
105
266
 
106
- const payload = [
107
- { role: "system", content: chat.systemPrompt },
108
- ...history,
109
- { role: "user", content: trimmed },
110
- ];
267
+ // Guards the gap between pressing Enter and the turn starting, so a double
268
+ // submit cannot create two conversations. Once the turn starts, `turnRef`
269
+ // takes over.
270
+ const sendingRef = useRef(false);
271
+
272
+ async function send(text: string) {
273
+ const content = text.trim();
274
+ if (!content || sendingRef.current) return;
275
+ if (content.length > chat.maxInputChars) return;
276
+ if (turnRef.current) {
277
+ setNotice(describeError("BUSY").body);
278
+ return;
279
+ }
280
+ sendingRef.current = true;
281
+ setInput("");
111
282
 
112
- let acc = "";
113
283
  try {
114
- await streamCompletion(payload, model, (delta) => {
115
- acc += delta;
116
- setStreaming(acc);
117
- });
118
- if (acc.trim()) {
119
- await db.insert("Message", { conversationId: convId, role: "assistant", content: acc });
284
+ let conversationId = currentId;
285
+ let created = false;
286
+ if (!conversationId) {
287
+ try {
288
+ const { id } = await callFn<{ id: string }>("createConversation", { firstMessage: content });
289
+ conversationId = id;
290
+ created = true;
291
+ openConversation(id, "push");
292
+ } catch (err) {
293
+ setInput(content);
294
+ setNotice((err as { message?: string })?.message || "Could not start a new chat. Try again.");
295
+ return;
296
+ }
120
297
  }
121
- } catch (err) {
122
- const code = (err as { code?: string })?.code;
123
- if (code === "AI_NOT_CONFIGURED") {
124
- setNotice(
125
- "AI isn't configured yet. Set PYLON_AI_PROVIDER and PYLON_AI_API_KEY in .env, then restart — see the README.",
126
- );
127
- } else if (code === "MODEL_OVERRIDE_FORBIDDEN" || code === "MODEL_NOT_ALLOWED") {
128
- setNotice(
129
- "That model isn't enabled. Add it to PYLON_AI_MODELS_ALLOWED in .env (comma-separated), then restart.",
130
- );
131
- } else if (code === "RATE_LIMITED") {
132
- setNotice("You've hit the AI rate limit — try again in a little while.");
133
- } else {
134
- setNotice("Something went wrong reaching the model. Try again.");
298
+ const outcome = await runTurn(conversationId, { content }, () => {
299
+ sendingRef.current = false;
300
+ });
301
+ // A refused first message leaves nothing worth keeping in a new chat.
302
+ if (outcome === "rejected" && created) {
303
+ void callFn("deleteConversation", { conversationId }).catch(() => {});
304
+ openConversation(null, "replace");
135
305
  }
136
306
  } finally {
137
- setStreaming(null);
138
- setSending(false);
307
+ sendingRef.current = false;
139
308
  }
140
309
  }
141
310
 
142
- const empty = messages.length === 0 && streaming === null;
311
+ function regenerate() {
312
+ if (!currentId || turnRef.current) return;
313
+ void runTurn(currentId, { regenerate: true });
314
+ }
143
315
 
144
- return (
145
- <div className="flex h-[calc(100vh-3.5rem)]">
146
- <Sidebar
147
- conversations={conversations}
148
- currentId={currentId}
149
- onSelect={selectConversation}
150
- onNew={newChat}
151
- onRename={renameConversation}
152
- onDelete={deleteConversation}
153
- />
154
- <div className="flex flex-1 flex-col bg-white">
155
- <div ref={scrollRef} className="flex-1 overflow-y-auto">
156
- <div className="mx-auto max-w-3xl px-4 py-6">
157
- {empty ? (
158
- <EmptyState onPick={send} />
159
- ) : (
160
- <div className="space-y-5">
161
- {messages.map((m) => (
162
- <Bubble key={m.id} role={m.role} content={m.content} at={m.createdAt} />
163
- ))}
164
- {streaming !== null ? <Bubble role="assistant" content={streaming || "…"} streaming /> : null}
165
- </div>
166
- )}
167
- {notice ? (
168
- <div className="mt-5 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-800">
169
- {notice}
170
- </div>
171
- ) : null}
172
- </div>
173
- </div>
316
+ // Stop whichever reply is streaming in the open conversation: this tab's,
317
+ // or one started in another tab. The server finds the streaming row by
318
+ // conversation, so Stop works before the row has synced to this tab.
319
+ async function stop() {
320
+ if (!currentId) return;
321
+ const conversationId = currentId;
322
+ const row = messages.find((m) => m.role === "assistant" && m.status === "streaming");
323
+ const turn = turnRef.current;
324
+ let content = "";
325
+ if (turn && turn.conversationId === conversationId) {
326
+ turn.stopped = true;
327
+ content = turn.text;
328
+ endLocal(turn);
329
+ } else if (row) {
330
+ content = remoteText.current.get(row.id) ?? row.content;
331
+ }
332
+ for (let attempt = 0; attempt < 3; attempt++) {
333
+ try {
334
+ const r = await callFn<{ stopped: boolean }>("stopResponse", { conversationId, content });
335
+ if (r.stopped || row) return;
336
+ } catch {
337
+ return;
338
+ }
339
+ // The server may not have inserted the reply row yet.
340
+ await new Promise((res) => setTimeout(res, 700));
341
+ }
342
+ }
174
343
 
175
- <Composer
176
- value={input}
177
- onChange={setInput}
178
- onSend={() => send(input)}
179
- disabled={sending}
180
- placeholder={chat.inputPlaceholder}
181
- model={model}
182
- onModelChange={setModel}
183
- />
184
- </div>
185
- </div>
186
- );
187
- }
344
+ // Text of replies being followed from another tab, for Stop.
345
+ const remoteText = useRef(new Map<string, string>());
188
346
 
189
- function Sidebar({
190
- conversations,
191
- currentId,
192
- onSelect,
193
- onNew,
194
- onRename,
195
- onDelete,
196
- }: {
197
- conversations: ConversationRow[];
198
- currentId: string | null;
199
- onSelect: (id: string) => void;
200
- onNew: () => void;
201
- onRename: (id: string, title: string) => void;
202
- onDelete: (id: string) => void;
203
- }) {
204
- return (
205
- <aside className="hidden w-64 shrink-0 flex-col border-r border-zinc-200 bg-paper sm:flex">
206
- <div className="p-3">
207
- <button
208
- type="button"
209
- onClick={onNew}
210
- className="flex w-full items-center justify-center gap-2 rounded-lg bg-brand px-3 py-2 text-[13.5px] font-medium text-white transition-opacity hover:opacity-90"
211
- >
212
- <PlusIcon /> New chat
213
- </button>
214
- </div>
215
- <nav className="flex-1 overflow-y-auto px-2 pb-3">
216
- {conversations.length === 0 ? (
217
- <p className="px-2 py-4 text-[12.5px] text-zinc-400">No conversations yet.</p>
218
- ) : (
219
- <ul className="space-y-0.5">
220
- {conversations.map((c) => (
221
- <ConversationRow
222
- key={c.id}
223
- convo={c}
224
- active={c.id === currentId}
225
- onSelect={() => onSelect(c.id)}
226
- onRename={(t) => onRename(c.id, t)}
227
- onDelete={() => onDelete(c.id)}
228
- />
229
- ))}
230
- </ul>
231
- )}
232
- </nav>
233
- </aside>
347
+ /* ------------------------------ rendering ------------------------------ */
348
+
349
+ const localHere = local && local.conversationId === currentId ? local : null;
350
+ const streamingRow = messages.find(
351
+ (m) => m.role === "assistant" && m.status === "streaming" && !isStaleStream(m, now),
234
352
  );
235
- }
353
+ const busy = Boolean(localHere) || Boolean(streamingRow);
236
354
 
237
- function ConversationRow({
238
- convo,
239
- active,
240
- onSelect,
241
- onRename,
242
- onDelete,
243
- }: {
244
- convo: ConversationRow;
245
- active: boolean;
246
- onSelect: () => void;
247
- onRename: (title: string) => void;
248
- onDelete: () => void;
249
- }) {
250
- const [editing, setEditing] = useState(false);
251
- const [draft, setDraft] = useState(convo.title);
252
- const [confirming, setConfirming] = useState(false);
253
-
254
- function commit() {
255
- setEditing(false);
256
- if (draft.trim() && draft.trim() !== convo.title) onRename(draft);
257
- else setDraft(convo.title);
355
+ const newUserRowSynced =
356
+ localHere?.userText != null &&
357
+ messages.some((m) => m.role === "user" && !localHere.knownIds.has(m.id));
358
+ const newAssistantRowSynced = localHere != null && messages.some((m) => m.role === "assistant" && !localHere.knownIds.has(m.id));
359
+
360
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
361
+
362
+ // The synced row is the source of truth for when a reply ends: finished,
363
+ // failed, or stopped from another tab. This tab's turn ends when the row
364
+ // leaves "streaming", without waiting for the stream connection to close.
365
+ const localRow = localHere
366
+ ? messages.find((m) => m.role === "assistant" && !localHere.knownIds.has(m.id))
367
+ : undefined;
368
+ useEffect(() => {
369
+ const turn = turnRef.current;
370
+ if (turn && localRow && localRow.status !== "streaming") {
371
+ turn.stopped = true;
372
+ endLocal(turn);
373
+ }
374
+ }, [localRow?.status, localRow, endLocal]);
375
+ const empty = !currentId || (messages.length === 0 && !localHere);
376
+ const isExample = Boolean(current?.example);
377
+ const examples = conversations.filter((c) => c.example);
378
+
379
+ const { data: me } = db.useQueryOne<{ email?: string }>("User", signedInUserId ?? "__none__");
380
+ const account: Account = signedInUserId ? { kind: "user", email: me?.email ?? null } : { kind: "guest" };
381
+
382
+ const scrollRef = useRef<HTMLDivElement>(null);
383
+ const pinned = useRef(true);
384
+ const [showJump, setShowJump] = useState(false);
385
+
386
+ function onScroll() {
387
+ const el = scrollRef.current;
388
+ if (!el) return;
389
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
390
+ pinned.current = distance < 120;
391
+ setShowJump(distance > 400);
258
392
  }
259
393
 
260
- if (editing) {
261
- return (
262
- <li>
263
- <input
264
- autoFocus
265
- value={draft}
266
- onChange={(e) => setDraft(e.target.value)}
267
- onBlur={commit}
268
- onKeyDown={(e) => {
269
- if (e.key === "Enter") commit();
270
- if (e.key === "Escape") {
271
- setDraft(convo.title);
272
- setEditing(false);
273
- }
274
- }}
275
- className="w-full rounded-lg border border-brand bg-white px-2.5 py-2 text-[13.5px] outline-none ring-2 ring-brand/20"
276
- />
277
- </li>
278
- );
394
+ // Opening a conversation starts at its newest message; the new-chat screen
395
+ // starts at the top.
396
+ useEffect(() => {
397
+ pinned.current = !empty;
398
+ requestAnimationFrame(() => {
399
+ const el = scrollRef.current;
400
+ if (el) el.scrollTop = empty ? 0 : el.scrollHeight;
401
+ });
402
+ // eslint-disable-next-line react-hooks/exhaustive-deps
403
+ }, [currentId, empty]);
404
+
405
+ const contentKey = `${messages.length}:${messages[messages.length - 1]?.content.length ?? 0}:${localHere?.text.length ?? 0}`;
406
+ useEffect(() => {
407
+ const el = scrollRef.current;
408
+ if (el && pinned.current) el.scrollTop = el.scrollHeight;
409
+ }, [contentKey]);
410
+
411
+ function jumpToBottom() {
412
+ const el = scrollRef.current;
413
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
279
414
  }
280
415
 
281
- return (
282
- <li className="group relative">
283
- <button
284
- type="button"
285
- onClick={onSelect}
286
- className={
287
- "flex w-full items-center rounded-lg py-2 pl-2.5 pr-14 text-left text-[13.5px] transition-colors " +
288
- (active ? "bg-brand-soft font-medium text-brand" : "text-zinc-600 hover:bg-zinc-100")
289
- }
290
- title={convo.title}
291
- >
292
- <span className="truncate">{convo.title || "New chat"}</span>
293
- </button>
294
- {/* hover actions */}
295
- <div className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 group-hover:flex">
296
- {confirming ? (
297
- <button
298
- type="button"
299
- onClick={onDelete}
300
- className="rounded px-1.5 py-0.5 text-[11px] font-medium text-red-600 hover:bg-red-50"
301
- >
302
- Sure?
303
- </button>
304
- ) : (
305
- <>
306
- <button
307
- type="button"
308
- onClick={() => {
309
- setDraft(convo.title);
310
- setEditing(true);
311
- }}
312
- aria-label="Rename"
313
- className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-700"
314
- >
315
- <PencilIcon />
316
- </button>
317
- <button
318
- type="button"
319
- onClick={() => {
320
- setConfirming(true);
321
- setTimeout(() => setConfirming(false), 2500);
322
- }}
323
- aria-label="Delete"
324
- className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-red-600"
325
- >
326
- <TrashIcon />
327
- </button>
328
- </>
329
- )}
330
- </div>
331
- </li>
416
+ const renderComposer = (ref: React.RefObject<HTMLTextAreaElement | null>, autoFocus = false) => (
417
+ <Composer
418
+ value={input}
419
+ onChange={setInput}
420
+ onSubmit={() => void send(input)}
421
+ onStop={() => void stop()}
422
+ busy={busy}
423
+ placeholder={chat.inputPlaceholder}
424
+ model={model}
425
+ onModelChange={changeModel}
426
+ isGuest={!signedInUserId}
427
+ autoFocus={autoFocus}
428
+ textareaRef={ref}
429
+ />
332
430
  );
333
- }
334
431
 
335
- function EmptyState({ onPick }: { onPick: (text: string) => void }) {
336
- const { chat, brand } = siteConfig;
337
- return (
338
- <div className="flex flex-col items-center pt-[12vh] text-center">
339
- <span className="flex size-12 items-center justify-center rounded-2xl bg-brand text-xl font-bold text-white">
340
- {brand.letter}
341
- </span>
342
- <h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900">{chat.emptyHeadline}</h1>
343
- <p className="mt-2 max-w-md text-[15px] leading-relaxed text-zinc-500">{chat.emptySubcopy}</p>
344
- <div className="mt-7 grid w-full max-w-xl gap-2 sm:grid-cols-2">
345
- {chat.suggestions.map((s) => (
346
- <button
347
- key={s}
348
- type="button"
349
- onClick={() => onPick(s)}
350
- className="rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left text-[13.5px] text-zinc-600 transition-colors hover:border-brand hover:text-zinc-900"
351
- >
352
- {s}
353
- </button>
354
- ))}
355
- </div>
356
- </div>
432
+ const sidebar = (onClose?: () => void) => (
433
+ <Sidebar
434
+ conversations={conversations}
435
+ currentId={currentId}
436
+ now={now}
437
+ account={account}
438
+ onSelect={(id) => openConversation(id)}
439
+ onNew={newChat}
440
+ onRename={(id, title) => {
441
+ const t = cleanTitle(title);
442
+ if (t) void callFn("renameConversation", { conversationId: id, title: t }).catch(() => {});
443
+ }}
444
+ onDelete={(id) => {
445
+ if (id === currentId) openConversation(null, "replace");
446
+ void callFn("deleteConversation", { conversationId: id }).catch(() => {});
447
+ }}
448
+ onSignIn={() => window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname)}`)}
449
+ onSignOut={() => void signOut()}
450
+ onClose={onClose}
451
+ />
357
452
  );
358
- }
359
-
360
- function Bubble({
361
- role,
362
- content,
363
- streaming,
364
- at,
365
- }: {
366
- role: string;
367
- content: string;
368
- streaming?: boolean;
369
- at?: string;
370
- }) {
371
- const { brand } = siteConfig;
372
- const isUser = role === "user";
373
- const [copied, setCopied] = useState(false);
374
-
375
- async function copy() {
376
- try {
377
- await navigator.clipboard.writeText(content);
378
- setCopied(true);
379
- setTimeout(() => setCopied(false), 1500);
380
- } catch {
381
- /* clipboard unavailable */
382
- }
383
- }
384
453
 
385
454
  return (
386
- <div className={"group flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
387
- <span
388
- className={
389
- "mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold " +
390
- (isUser ? "bg-zinc-200 text-zinc-600" : "bg-brand text-white")
391
- }
455
+ <div className="flex h-[100dvh] overflow-hidden bg-bg">
456
+ {/* Desktop sidebar */}
457
+ <aside className="hidden w-[272px] shrink-0 border-r border-line bg-paper md:block">
458
+ {sidebar()}
459
+ </aside>
460
+
461
+ {/* Mobile drawer */}
462
+ <div
463
+ className={"fixed inset-0 z-40 md:hidden " + (drawerOpen ? "" : "pointer-events-none")}
464
+ aria-hidden={!drawerOpen}
392
465
  >
393
- {isUser ? "You" : brand.letter}
394
- </span>
395
- <div className={"flex max-w-[80%] flex-col " + (isUser ? "items-end" : "items-start")}>
396
466
  <div
397
467
  className={
398
- "rounded-2xl px-4 py-2.5 text-[14.5px] leading-relaxed " +
399
- (isUser ? "whitespace-pre-wrap bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-800")
468
+ "absolute inset-0 bg-ink/25 backdrop-blur-[2px] transition-opacity duration-300 " +
469
+ (drawerOpen ? "opacity-100" : "opacity-0")
470
+ }
471
+ onClick={() => setDrawerOpen(false)}
472
+ />
473
+ <aside
474
+ className={
475
+ "drawer absolute inset-y-0 left-0 w-[86%] max-w-[320px] bg-paper shadow-float " +
476
+ (drawerOpen ? "translate-x-0" : "-translate-x-full")
400
477
  }
401
478
  >
402
- {isUser || streaming ? (
403
- <span className="whitespace-pre-wrap">{content}</span>
479
+ {sidebar(() => setDrawerOpen(false))}
480
+ </aside>
481
+ </div>
482
+
483
+ <main className="relative flex min-w-0 flex-1 flex-col">
484
+ <header className="z-10 flex h-14 shrink-0 items-center gap-2 px-3 sm:px-5">
485
+ <button
486
+ type="button"
487
+ onClick={() => setDrawerOpen(true)}
488
+ aria-label="Open sidebar"
489
+ className="press flex size-9 items-center justify-center rounded-xl text-ink-2 hover:bg-hover md:hidden"
490
+ >
491
+ <MenuIcon size={20} />
492
+ </button>
493
+ <div className="min-w-0 flex-1">
494
+ {current ? (
495
+ <p className="truncate text-[14.5px] font-medium text-ink">
496
+ {current.title}
497
+ {isExample ? <span className="ml-2 text-[12.5px] font-normal text-ink-3">Example</span> : null}
498
+ </p>
499
+ ) : null}
500
+ </div>
501
+ {syncStatus !== "connected" && syncStatus !== "connecting" ? (
502
+ <span className="rounded-full bg-hover px-2.5 py-1 text-[12px] text-ink-2">
503
+ {syncStatus === "reconnecting" ? "Reconnecting" : "Offline"}
504
+ </span>
505
+ ) : null}
506
+ <StatusChip provider={provider} model={model} onOpenSetup={() => setSetupOpen(true)} />
507
+ <button
508
+ type="button"
509
+ onClick={newChat}
510
+ aria-label="New chat"
511
+ className="press flex size-9 items-center justify-center rounded-xl text-ink-2 hover:bg-hover md:hidden"
512
+ >
513
+ <ComposeIcon size={19} />
514
+ </button>
515
+ </header>
516
+
517
+ <div ref={scrollRef} onScroll={onScroll} className="thread-scroll min-h-0 flex-1 overflow-y-auto">
518
+ {empty ? (
519
+ <EmptyState
520
+ composer={renderComposer(textareaRef, true)}
521
+ onPick={(p) => void send(p)}
522
+ providerConfigured={provider ? provider.configured : null}
523
+ exampleCount={examples.length}
524
+ onOpenExample={examples[0] ? () => openConversation(examples[0].id) : undefined}
525
+ />
404
526
  ) : (
405
- <Markdown text={content} />
527
+ <div className="mx-auto w-full max-w-[46rem] px-4 pb-10 pt-4 sm:px-6 sm:pt-8">
528
+ <div className="space-y-9">
529
+ {messages.map((m) => {
530
+ if (m.role === "user") return <UserMessage key={m.id} content={m.content} />;
531
+ const isLast = m.id === lastAssistant?.id;
532
+ const canRegenerate = isLast && !busy && !isExample;
533
+ if (m.status === "streaming") {
534
+ if (isStaleStream(m, now)) {
535
+ return (
536
+ <AssistantMessage
537
+ key={m.id}
538
+ content={m.content}
539
+ status="interrupted"
540
+ model={m.model}
541
+ canRegenerate={canRegenerate}
542
+ onRegenerate={regenerate}
543
+ />
544
+ );
545
+ }
546
+ if (localHere) {
547
+ return (
548
+ <AssistantMessage
549
+ key={m.id}
550
+ content={localHere.text}
551
+ status="streaming"
552
+ model={m.model}
553
+ canRegenerate={false}
554
+ />
555
+ );
556
+ }
557
+ return <RemoteReply key={m.id} row={m} textStore={remoteText.current} />;
558
+ }
559
+ return (
560
+ <AssistantMessage
561
+ key={m.id}
562
+ content={m.content}
563
+ status={m.status ?? "done"}
564
+ errorCode={m.errorCode}
565
+ model={m.model}
566
+ canRegenerate={canRegenerate}
567
+ onRegenerate={regenerate}
568
+ providerConfigured={provider?.configured}
569
+ />
570
+ );
571
+ })}
572
+ {localHere && localHere.userText != null && !newUserRowSynced ? (
573
+ <UserMessage content={localHere.userText} pending />
574
+ ) : null}
575
+ {localHere && !newAssistantRowSynced ? (
576
+ <AssistantMessage content={localHere.text} status="streaming" canRegenerate={false} />
577
+ ) : null}
578
+ </div>
579
+ </div>
406
580
  )}
407
- {streaming ? <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-400 align-middle" /> : null}
408
581
  </div>
409
- {!streaming ? (
410
- <div className={"mt-1 flex items-center gap-2 px-1 " + (isUser ? "flex-row-reverse" : "")}>
411
- {at ? <span className="text-[10.5px] text-zinc-300">{relativeTime(at)}</span> : null}
412
- {!isUser ? (
582
+
583
+ <div className={"composer-dock relative shrink-0 px-3 pb-3 sm:px-6 sm:pb-5 " + (empty ? "md:hidden" : "")}>
584
+ {showJump ? (
413
585
  <button
414
586
  type="button"
415
- onClick={copy}
416
- className="text-[10.5px] text-zinc-300 opacity-0 transition-opacity hover:text-zinc-600 group-hover:opacity-100"
587
+ onClick={jumpToBottom}
588
+ aria-label="Scroll to latest"
589
+ className="press absolute -top-12 left-1/2 flex size-9 -translate-x-1/2 items-center justify-center rounded-full bg-surface text-ink-2 shadow-float ring-1 ring-line"
417
590
  >
418
- {copied ? "Copied" : "Copy"}
591
+ <ArrowDownIcon size={17} />
419
592
  </button>
420
593
  ) : null}
594
+ <div className="mx-auto w-full max-w-[46rem]">
595
+ {notice ? <Notice text={notice} onDismiss={() => setNotice(null)} /> : null}
596
+ {isExample && !empty ? (
597
+ <ExampleBar onNew={newChat} />
598
+ ) : (
599
+ <>
600
+ {renderComposer(empty ? dockTextareaRef : textareaRef)}
601
+ <p className="mt-2 text-center text-[12px] text-ink-3">
602
+ {siteConfig.brand.name} can make mistakes. Check important information.
603
+ </p>
604
+ </>
605
+ )}
606
+ </div>
607
+ </div>
608
+ {empty && notice ? (
609
+ <div className="hidden shrink-0 px-3 pb-4 md:block">
610
+ <div className="mx-auto max-w-[46rem]">
611
+ <Notice text={notice} onDismiss={() => setNotice(null)} />
612
+ </div>
421
613
  </div>
422
614
  ) : null}
423
- </div>
615
+ </main>
616
+
617
+ {setupOpen ? <SetupDialog onClose={() => setSetupOpen(false)} /> : null}
424
618
  </div>
425
619
  );
426
620
  }
427
621
 
428
- function Composer({
429
- value,
430
- onChange,
431
- onSend,
432
- disabled,
433
- placeholder,
622
+ // A reply that another tab (or this tab before a reload) is streaming.
623
+ // Attaches to its resumable stream and shows tokens as they arrive; the row
624
+ // itself switches to "done" when the reply is stored.
625
+ function RemoteReply({ row, textStore }: { row: MessageRow; textStore: Map<string, string> }) {
626
+ const [text, setText] = useState(row.content);
627
+
628
+ useEffect(() => {
629
+ if (!row.streamId) return;
630
+ let cancelled = false;
631
+ let acc = "";
632
+ const gen = resumeStream(row.streamId);
633
+ (async () => {
634
+ try {
635
+ for await (const chunk of gen) {
636
+ if (cancelled) break;
637
+ acc += chunk;
638
+ textStore.set(row.id, acc);
639
+ setText(acc);
640
+ }
641
+ } catch {
642
+ /* the stream expired or the reply finished; the synced row takes over */
643
+ }
644
+ })();
645
+ return () => {
646
+ cancelled = true;
647
+ textStore.delete(row.id);
648
+ void gen.return(undefined);
649
+ };
650
+ }, [row.id, row.streamId, textStore]);
651
+
652
+ return <AssistantMessage content={text} status="streaming" model={row.model} canRegenerate={false} />;
653
+ }
654
+
655
+ function StatusChip({
656
+ provider,
434
657
  model,
435
- onModelChange,
658
+ onOpenSetup,
436
659
  }: {
437
- value: string;
438
- onChange: (v: string) => void;
439
- onSend: () => void;
440
- disabled: boolean;
441
- placeholder: string;
660
+ provider: ProviderStatus | null;
442
661
  model: string;
443
- onModelChange: (m: string) => void;
662
+ onOpenSetup: () => void;
444
663
  }) {
445
- const { models } = siteConfig.chat;
446
- function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
447
- if (e.key === "Enter" && !e.shiftKey) {
448
- e.preventDefault();
449
- onSend();
450
- }
664
+ if (!provider) return null;
665
+ if (!provider.configured) {
666
+ return (
667
+ <button
668
+ type="button"
669
+ onClick={onOpenSetup}
670
+ className="press inline-flex h-8 items-center gap-2 rounded-full bg-warn-soft px-3 text-[12.5px] font-medium text-warn ring-1 ring-warn/15 transition-colors duration-200 hover:bg-warn/10"
671
+ >
672
+ <span className="size-1.5 rounded-full bg-warn" />
673
+ No API key
674
+ </button>
675
+ );
451
676
  }
452
677
  return (
453
- <div className="border-t border-zinc-200 bg-white px-4 py-3">
454
- <div className="mx-auto flex max-w-3xl items-end gap-2">
455
- <textarea
456
- value={value}
457
- onChange={(e) => onChange(e.target.value)}
458
- onKeyDown={onKeyDown}
459
- rows={1}
460
- placeholder={placeholder}
461
- aria-label="Message"
462
- className="max-h-40 flex-1 resize-none rounded-2xl border border-zinc-300 bg-white px-4 py-2.5 text-[14.5px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
463
- />
678
+ <span className="hidden h-8 items-center gap-2 rounded-full px-3 text-[12.5px] text-ink-2 ring-1 ring-line sm:inline-flex">
679
+ <span className="size-1.5 rounded-full bg-ok" />
680
+ {modelLabel(model)}
681
+ </span>
682
+ );
683
+ }
684
+
685
+ function Notice({ text, onDismiss }: { text: string; onDismiss: () => void }) {
686
+ return (
687
+ <div className="msg-in mb-2 flex items-start gap-3 rounded-2xl bg-warn-soft px-4 py-2.5 ring-1 ring-warn/15" role="alert">
688
+ <AlertIcon size={17} className="mt-0.5 shrink-0 text-warn" />
689
+ <p className="flex-1 text-[13.5px] leading-relaxed text-ink">{text}</p>
690
+ <button type="button" onClick={onDismiss} aria-label="Dismiss" className="text-ink-3 hover:text-ink">
691
+ <CloseIcon size={16} />
692
+ </button>
693
+ </div>
694
+ );
695
+ }
696
+
697
+ function ExampleBar({ onNew }: { onNew: () => void }) {
698
+ return (
699
+ <div className="bezel">
700
+ <div className="bezel-core flex flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center">
701
+ <p className="flex-1 text-[13.5px] leading-relaxed text-ink-2">
702
+ This is an example conversation written for the template. No model generated it, and it is read-only.
703
+ </p>
464
704
  <button
465
705
  type="button"
466
- onClick={onSend}
467
- disabled={disabled || !value.trim()}
468
- aria-label="Send"
469
- className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-brand text-white transition-opacity hover:opacity-90 disabled:opacity-40"
706
+ onClick={onNew}
707
+ className="press inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-full bg-ink px-4 text-[13.5px] font-medium text-bg transition-opacity duration-200 hover:opacity-90"
470
708
  >
471
- <SendIcon />
709
+ <ComposeIcon size={16} />
710
+ New chat
472
711
  </button>
473
712
  </div>
474
- <div className="mx-auto mt-1.5 flex max-w-3xl items-center justify-between gap-3">
475
- <label className="flex items-center gap-1.5 text-[11px] text-zinc-400">
476
- <span className="hidden sm:inline">Model</span>
477
- <select
478
- value={model}
479
- onChange={(e) => onModelChange(e.target.value)}
480
- aria-label="Model"
481
- className="rounded-md border border-zinc-200 bg-white px-1.5 py-1 text-[11.5px] text-zinc-600 outline-none focus:border-brand"
482
- >
483
- {models.map((m) => (
484
- <option key={m.id} value={m.id}>
485
- {m.label} · {m.provider}
486
- </option>
487
- ))}
488
- </select>
489
- </label>
490
- <span className="text-[11px] text-zinc-400">Enter to send · Shift+Enter for a new line</span>
491
- </div>
492
713
  </div>
493
714
  );
494
715
  }
495
716
 
496
- /* --------------------------- markdown rendering --------------------------- */
497
- // A small, dependency-free, XSS-safe Markdown renderer for assistant replies. It
498
- // builds React elements directly — raw HTML is never injected into the DOM, so
499
- // model output can't smuggle scripts. Covers what LLMs emit most: fenced code
500
- // blocks, inline `code`, **bold**, *italic*, links, bullet + numbered lists,
501
- // headings, and paragraphs. Swap in `react-markdown` for full CommonMark/GFM.
502
- function Markdown({ text }: { text: string }) {
503
- return <div className="space-y-2">{renderBlocks(text)}</div>;
504
- }
505
-
506
- function renderBlocks(text: string): React.ReactNode[] {
507
- const lines = text.replace(/\r\n/g, "\n").split("\n");
508
- const out: React.ReactNode[] = [];
509
- let i = 0;
510
- let key = 0;
511
- while (i < lines.length) {
512
- const line = lines[i];
513
- if (line.trimStart().startsWith("```")) {
514
- const lang = line.trim().slice(3).trim();
515
- const code: string[] = [];
516
- i++;
517
- while (i < lines.length && !lines[i].trimStart().startsWith("```")) code.push(lines[i++]);
518
- i++; // closing fence
519
- out.push(<CodeBlock key={key++} code={code.join("\n")} lang={lang} />);
520
- continue;
521
- }
522
- const h = line.match(/^(#{1,3})\s+(.*)$/);
523
- if (h) {
524
- const size = h[1].length === 1 ? "text-[17px]" : h[1].length === 2 ? "text-[15.5px]" : "text-[14.5px]";
525
- out.push(
526
- <p key={key++} className={`font-semibold text-zinc-900 ${size}`}>
527
- {renderInline(h[2])}
528
- </p>,
529
- );
530
- i++;
531
- continue;
532
- }
533
- if (/^\s*[-*]\s+/.test(line)) {
534
- const items: string[] = [];
535
- while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*]\s+/, ""));
536
- out.push(
537
- <ul key={key++} className="ml-4 list-disc space-y-1">
538
- {items.map((it, j) => (
539
- <li key={j}>{renderInline(it)}</li>
540
- ))}
541
- </ul>,
542
- );
543
- continue;
544
- }
545
- if (/^\s*\d+\.\s+/.test(line)) {
546
- const items: string[] = [];
547
- while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, ""));
548
- out.push(
549
- <ol key={key++} className="ml-4 list-decimal space-y-1">
550
- {items.map((it, j) => (
551
- <li key={j}>{renderInline(it)}</li>
552
- ))}
553
- </ol>,
554
- );
555
- continue;
556
- }
557
- if (line.trim() === "") {
558
- i++;
559
- continue;
560
- }
561
- const para: string[] = [];
562
- while (
563
- i < lines.length &&
564
- lines[i].trim() !== "" &&
565
- !lines[i].trimStart().startsWith("```") &&
566
- !/^\s*[-*]\s+/.test(lines[i]) &&
567
- !/^\s*\d+\.\s+/.test(lines[i]) &&
568
- !/^#{1,3}\s+/.test(lines[i])
569
- ) {
570
- para.push(lines[i++]);
571
- }
572
- out.push(
573
- <p key={key++} className="leading-relaxed">
574
- {para.flatMap((ln, j) => (j === 0 ? renderInline(ln) : [<br key={`b${j}`} />, ...renderInline(ln)]))}
575
- </p>,
576
- );
577
- }
578
- return out;
579
- }
580
-
581
- function renderInline(text: string): React.ReactNode[] {
582
- const nodes: React.ReactNode[] = [];
583
- const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
584
- let last = 0;
585
- let key = 0;
586
- for (const m of text.matchAll(re)) {
587
- const idx = m.index ?? 0;
588
- if (idx > last) nodes.push(text.slice(last, idx));
589
- const tok = m[0];
590
- if (tok.startsWith("`")) {
591
- nodes.push(
592
- <code key={key++} className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[12.5px]">
593
- {tok.slice(1, -1)}
594
- </code>,
595
- );
596
- } else if (tok.startsWith("**")) {
597
- nodes.push(<strong key={key++}>{tok.slice(2, -2)}</strong>);
598
- } else if (tok.startsWith("*")) {
599
- nodes.push(<em key={key++}>{tok.slice(1, -1)}</em>);
600
- } else {
601
- const lm = tok.match(/\[([^\]]+)\]\(([^)]+)\)/);
602
- if (lm) {
603
- nodes.push(
604
- <a key={key++} href={lm[2]} target="_blank" rel="noopener noreferrer" className="text-brand underline">
605
- {lm[1]}
606
- </a>,
607
- );
608
- }
609
- }
610
- last = idx + tok.length;
611
- }
612
- if (last < text.length) nodes.push(text.slice(last));
613
- return nodes;
614
- }
615
-
616
- function CodeBlock({ code, lang }: { code: string; lang?: string }) {
617
- const [copied, setCopied] = useState(false);
618
- async function copy() {
619
- try {
620
- await navigator.clipboard.writeText(code);
621
- setCopied(true);
622
- setTimeout(() => setCopied(false), 1500);
623
- } catch {
624
- /* ignore */
625
- }
626
- }
717
+ function SetupDialog({ onClose }: { onClose: () => void }) {
718
+ useEffect(() => {
719
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
720
+ window.addEventListener("keydown", onKey);
721
+ return () => window.removeEventListener("keydown", onKey);
722
+ }, [onClose]);
627
723
  return (
628
- <div className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-950">
629
- <div className="flex items-center justify-between border-b border-white/10 px-3 py-1.5">
630
- <span className="font-mono text-[10.5px] uppercase tracking-wide text-zinc-400">{lang || "code"}</span>
631
- <button type="button" onClick={copy} className="text-[10.5px] text-zinc-400 hover:text-white">
632
- {copied ? "Copied" : "Copy"}
724
+ <div className="fixed inset-0 z-50 flex items-end justify-center p-3 sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Add a model provider key">
725
+ <div className="absolute inset-0 bg-ink/30 backdrop-blur-sm" onClick={onClose} />
726
+ <div className="dialog-in relative w-full max-w-lg">
727
+ <button
728
+ type="button"
729
+ onClick={onClose}
730
+ aria-label="Close"
731
+ className="absolute right-4 top-4 z-10 flex size-8 items-center justify-center rounded-lg text-ink-3 hover:bg-hover hover:text-ink"
732
+ >
733
+ <CloseIcon size={17} />
633
734
  </button>
735
+ <ProviderSetup />
634
736
  </div>
635
- <pre className="overflow-x-auto p-3 text-[12.5px] leading-relaxed text-zinc-100">
636
- <code className="font-mono">{code}</code>
637
- </pre>
638
737
  </div>
639
738
  );
640
739
  }
641
740
 
642
- async function streamCompletion(
643
- messages: { role: string; content: string }[],
644
- model: string,
645
- onDelta: (delta: string) => void,
646
- ): Promise<void> {
647
- const res = await fetch("/api/ai/stream", {
648
- method: "POST",
649
- headers: { "content-type": "application/json" },
650
- body: JSON.stringify({ messages, model }),
651
- });
652
- if (!res.ok || !res.body) {
653
- let code = `HTTP_${res.status}`;
654
- try {
655
- code = (await res.json())?.error?.code ?? code;
656
- } catch {
657
- /* ignore */
658
- }
659
- throw { code };
741
+ async function signOut() {
742
+ try {
743
+ await getSync().signOut();
744
+ } catch {
745
+ /* the session may already be gone */
660
746
  }
661
- const reader = res.body.getReader();
662
- const decoder = new TextDecoder();
663
- let buf = "";
664
- for (;;) {
665
- const { done, value } = await reader.read();
666
- if (done) break;
667
- buf += decoder.decode(value, { stream: true });
668
- const lines = buf.split("\n");
669
- buf = lines.pop() ?? "";
670
- for (const raw of lines) {
671
- const line = raw.trim();
672
- if (!line.startsWith("data:")) continue;
673
- const data = line.slice(5).trim();
674
- if (data === "[DONE]") return;
675
- try {
676
- const j = JSON.parse(data);
677
- if (j.error) throw { code: j.error.code ?? "STREAM_ERROR" };
678
- const delta = j.choices?.[0]?.delta?.content;
679
- if (delta) onDelta(delta);
680
- } catch (e) {
681
- if ((e as { code?: string })?.code) throw e;
682
- /* ignore keep-alive / partial lines */
683
- }
684
- }
747
+ try {
748
+ window.localStorage.removeItem(storageKey("token"));
749
+ } catch {
750
+ /* storage blocked */
685
751
  }
752
+ window.location.assign("/");
686
753
  }
687
754
 
688
- function relativeTime(iso: string): string {
689
- const t = new Date(iso).getTime();
690
- if (Number.isNaN(t)) return "";
691
- const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
692
- if (s < 45) return "just now";
693
- const m = Math.floor(s / 60);
694
- if (m < 60) return `${m}m ago`;
695
- const h = Math.floor(m / 60);
696
- if (h < 24) return `${h}h ago`;
697
- return `${Math.floor(h / 24)}d ago`;
698
- }
699
-
700
- function PlusIcon() {
755
+ function ShellSkeleton() {
701
756
  return (
702
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden>
703
- <path d="M12 5v14M5 12h14" />
704
- </svg>
705
- );
706
- }
707
- function SendIcon() {
708
- return (
709
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
710
- <path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z" />
711
- </svg>
712
- );
713
- }
714
- function PencilIcon() {
715
- return (
716
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
717
- <path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
718
- </svg>
719
- );
720
- }
721
- function TrashIcon() {
722
- return (
723
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
724
- <path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
725
- </svg>
757
+ <div className="flex h-[100dvh] bg-bg">
758
+ <aside className="hidden w-[272px] shrink-0 border-r border-line bg-paper md:block" />
759
+ <main className="flex-1" />
760
+ </div>
726
761
  );
727
762
  }