thursday-agent 0.8.0 → 0.9.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 (264) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/server/app/_not-found.html +1 -1
  13. package/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/keep-working/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  23. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  24. package/.next/server/app/api/config/route.js.nft.json +1 -1
  25. package/.next/server/app/api/events/route.js.nft.json +1 -1
  26. package/.next/server/app/api/favicon/[host]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  28. package/.next/server/app/api/llm-model/catalog/route.js.nft.json +1 -1
  29. package/.next/server/app/api/llm-model/credits/route.js.nft.json +1 -1
  30. package/.next/server/app/api/llm-model/route.js.nft.json +1 -1
  31. package/.next/server/app/api/llm-model/subscription/route.js.nft.json +1 -1
  32. package/.next/server/app/api/mcp/[name]/route.js.nft.json +1 -1
  33. package/.next/server/app/api/mcp/oauth/callback/route.js.nft.json +1 -1
  34. package/.next/server/app/api/mcp/route.js.nft.json +1 -1
  35. package/.next/server/app/api/mcp/tools/route.js.nft.json +1 -1
  36. package/.next/server/app/api/memory/edit/route.js +1 -1
  37. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  38. package/.next/server/app/api/memory/route.js.nft.json +1 -1
  39. package/.next/server/app/api/probe/route.js.nft.json +1 -1
  40. package/.next/server/app/api/reach/route.js +11 -11
  41. package/.next/server/app/api/reach/route.js.nft.json +1 -1
  42. package/.next/server/app/api/routine/route.js +6 -6
  43. package/.next/server/app/api/routine/route.js.nft.json +1 -1
  44. package/.next/server/app/api/signins/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  46. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  47. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  48. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  49. package/.next/server/app/api/thursday/text/route.js +11 -11
  50. package/.next/server/app/api/thursday/text/route.js.nft.json +1 -1
  51. package/.next/server/app/api/thursday/tool-call/route.js +6 -6
  52. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  53. package/.next/server/app/api/workspace/files/route.js.nft.json +1 -1
  54. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  55. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  56. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  57. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  59. package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -1
  60. package/.next/server/app/page/server-reference-manifest.json +56 -56
  61. package/.next/server/app/page.js +3 -3
  62. package/.next/server/app/page.js.nft.json +1 -1
  63. package/.next/server/app/page_client-reference-manifest.js +1 -1
  64. package/.next/server/chunks/[root-of-the-server]__012s2o8._.js +1 -1
  65. package/.next/server/chunks/[root-of-the-server]__04r4h-2._.js +1 -1
  66. package/.next/server/chunks/[root-of-the-server]__0mrk2it._.js +1 -1
  67. package/.next/server/chunks/[root-of-the-server]__0nmhgeo._.js +3 -0
  68. package/.next/server/chunks/[root-of-the-server]__1fy2g82._.js +3 -0
  69. package/.next/server/chunks/[root-of-the-server]__1ndi14y._.js +1 -1
  70. package/.next/server/chunks/[root-of-the-server]__1nmflqk._.js +3 -0
  71. package/.next/server/chunks/[root-of-the-server]__1v7-zke._.js +1 -1
  72. package/.next/server/chunks/[root-of-the-server]__1wnzmx9._.js +3 -0
  73. package/.next/server/chunks/[root-of-the-server]__1xv-5cq._.js +1 -1
  74. package/.next/server/chunks/[root-of-the-server]__1ygplx1._.js +1 -1
  75. package/.next/server/chunks/_05v8k21._.js +1 -1
  76. package/.next/server/chunks/_0awtznl._.js +4 -4
  77. package/.next/server/chunks/_0k6qjcf._.js +1 -1
  78. package/.next/server/chunks/_0na2fj1._.js +5 -5
  79. package/.next/server/chunks/_0x-ej3f._.js +21 -21
  80. package/.next/server/chunks/_1dc_d1q._.js +22 -20
  81. package/.next/server/chunks/_1jc5g4p._.js +2 -2
  82. package/.next/server/chunks/_1jn-aep._.js +50 -48
  83. package/.next/server/chunks/{_1u8ygbz._.js → _1m88lkh._.js} +7 -7
  84. package/.next/server/chunks/_1uuucy-._.js +4 -4
  85. package/.next/server/chunks/_1v9r2ke._.js +1 -1
  86. package/.next/server/chunks/ssr/{[root-of-the-server]__1h4bnrf._.js → [root-of-the-server]__0oo-wlv._.js} +2 -2
  87. package/.next/server/chunks/ssr/{[root-of-the-server]__1kyi568._.js → [root-of-the-server]__0tvavy9._.js} +1 -1
  88. package/.next/server/chunks/ssr/[root-of-the-server]__0tzekbl._.js +1 -1
  89. package/.next/server/chunks/ssr/[root-of-the-server]__0zuns7e._.js +3 -0
  90. package/.next/server/chunks/ssr/[root-of-the-server]__1se25x0._.js +1 -1
  91. package/.next/server/chunks/ssr/{_1i3ddn7._.js → _05pl_u8._.js} +1 -1
  92. package/.next/server/chunks/ssr/_070nv1_._.js +3 -0
  93. package/.next/server/chunks/ssr/_097-ljd._.js +1 -1
  94. package/.next/server/chunks/ssr/_0dshsh4._.js +3 -0
  95. package/.next/server/chunks/ssr/_0fqhhwi._.js +1 -1
  96. package/.next/server/chunks/ssr/{_0z8jpch._.js → _0ital-2._.js} +2 -2
  97. package/.next/server/chunks/ssr/{_19g570d._.js → _0kjj5ts._.js} +3 -3
  98. package/.next/server/chunks/ssr/{_1nod5hh._.js → _0l_mcfz._.js} +2 -2
  99. package/.next/server/chunks/ssr/_0ld9_qq._.js +1 -1
  100. package/.next/server/chunks/ssr/_0ltz2q5._.js +3 -0
  101. package/.next/server/chunks/ssr/_0scf7xy._.js +3 -0
  102. package/.next/server/chunks/ssr/_0u5xv6g._.js +3 -0
  103. package/.next/server/chunks/ssr/{_0ewory0._.js → _0urj3mc._.js} +2 -2
  104. package/.next/server/chunks/ssr/{_0pm-2p-._.js → _1-zyvdb._.js} +2 -2
  105. package/.next/server/chunks/ssr/{_1_rqu8x._.js → _12mijol._.js} +7 -7
  106. package/.next/server/chunks/ssr/_15zqt-t._.js +1 -1
  107. package/.next/server/chunks/ssr/_184327j._.js +1 -1
  108. package/.next/server/chunks/ssr/{_1y-ea28._.js → _1a-nuxx._.js} +2 -2
  109. package/.next/server/chunks/ssr/_1alxym6._.js +3 -0
  110. package/.next/server/chunks/ssr/{_0hzz82u._.js → _1d423aj._.js} +25 -23
  111. package/.next/server/chunks/ssr/{_1u1mxu9._.js → _1flbs77._.js} +2 -2
  112. package/.next/server/chunks/ssr/_1mqn9mc._.js +1 -1
  113. package/.next/server/chunks/ssr/_1neoj22._.js +1 -1
  114. package/.next/server/chunks/ssr/{_1oijqjw._.js → _1tmu8z-._.js} +2 -2
  115. package/.next/server/chunks/ssr/{_10y6r1n._.js → _1uy0xom._.js} +3 -3
  116. package/.next/server/chunks/ssr/_1x-kojb._.js +20 -0
  117. package/.next/server/chunks/ssr/_1xoch6f._.js +1 -1
  118. package/.next/server/chunks/ssr/_1z74dmq._.js +164 -0
  119. package/.next/server/chunks/ssr/features_bot_1nr94ks._.js +3 -0
  120. package/.next/server/chunks/ssr/features_bot_1s_-2vj._.js +1 -1
  121. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  122. package/.next/server/chunks/ssr/features_memory_components_memory-setting_tsx_0oa43sw._.js +1 -1
  123. package/.next/server/chunks/ssr/features_routine_components_routine-setting_tsx_055fknz._.js +1 -1
  124. package/.next/server/chunks/ssr/{node_modules_111sh9o._.js → node_modules_03d0rfz._.js} +1 -1
  125. package/.next/server/chunks/ssr/{node_modules_0lg75qx._.js → node_modules_04_sbbc._.js} +1 -1
  126. package/.next/server/chunks/ssr/{node_modules_10gbm2y._.js → node_modules_05ybmta._.js} +1 -1
  127. package/.next/server/chunks/ssr/{node_modules_1v9tc1c._.js → node_modules_068ws5v._.js} +1 -1
  128. package/.next/server/chunks/ssr/{node_modules_1_8mv4v._.js → node_modules_06bb4q8._.js} +1 -1
  129. package/.next/server/chunks/ssr/{node_modules_01f8_se._.js → node_modules_07rs_p2._.js} +1 -1
  130. package/.next/server/chunks/ssr/{node_modules_0ii1l89._.js → node_modules_0dzm4um._.js} +1 -1
  131. package/.next/server/chunks/ssr/{node_modules_20xmwtu._.js → node_modules_0jljel9._.js} +1 -1
  132. package/.next/server/chunks/ssr/{node_modules_1ix-g49._.js → node_modules_0sgy-dc._.js} +1 -1
  133. package/.next/server/chunks/ssr/{node_modules_0woscox._.js → node_modules_0t_ez6-._.js} +1 -1
  134. package/.next/server/chunks/ssr/{node_modules_059vtf2._.js → node_modules_0th357h._.js} +1 -1
  135. package/.next/server/chunks/ssr/{node_modules_06w645b._.js → node_modules_0ubdtk-._.js} +1 -1
  136. package/.next/server/chunks/ssr/{node_modules_0b8df3s._.js → node_modules_0uyh_vb._.js} +1 -1
  137. package/.next/server/chunks/ssr/{node_modules_07ih6wb._.js → node_modules_0wnlddw._.js} +1 -1
  138. package/.next/server/chunks/ssr/{node_modules_0gt-uw9._.js → node_modules_0x630an._.js} +1 -1
  139. package/.next/server/chunks/ssr/{node_modules_1x825hz._.js → node_modules_0yvz9e0._.js} +1 -1
  140. package/.next/server/chunks/ssr/{node_modules_0t-44lj._.js → node_modules_12et8d5._.js} +1 -1
  141. package/.next/server/chunks/ssr/{node_modules_1m3gb16._.js → node_modules_13kwejm._.js} +1 -1
  142. package/.next/server/chunks/ssr/{node_modules_1p9rdsy._.js → node_modules_1af3w91._.js} +1 -1
  143. package/.next/server/chunks/ssr/{node_modules_0sz5yg1._.js → node_modules_1bucjlk._.js} +1 -1
  144. package/.next/server/chunks/ssr/{node_modules_1teyzfa._.js → node_modules_1lkix61._.js} +1 -1
  145. package/.next/server/chunks/ssr/{node_modules_1lsyy7k._.js → node_modules_1ogczzz._.js} +1 -1
  146. package/.next/server/chunks/ssr/{node_modules_1-7qawg._.js → node_modules_1s-gb-1._.js} +1 -1
  147. package/.next/server/chunks/ssr/{node_modules_0yv-re6._.js → node_modules_1uwg-gz._.js} +1 -1
  148. package/.next/server/chunks/ssr/{node_modules_03ptrl9._.js → node_modules_1wbkzl9._.js} +1 -1
  149. package/.next/server/chunks/ssr/{node_modules_00jv9_3._.js → node_modules_1z1lrbk._.js} +1 -1
  150. package/.next/server/chunks/ssr/{node_modules_@base-ui_react_0jne69c._.js → node_modules_@base-ui_react_0buz1-j._.js} +1 -1
  151. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_00q1oa3._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0r443fh._.js} +1 -1
  152. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_1-2_5z7._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_10wnlbk._.js} +1 -1
  153. package/.next/server/middleware-build-manifest.js +3 -3
  154. package/.next/server/pages/404.html +1 -1
  155. package/.next/server/pages/500.html +1 -1
  156. package/.next/server/server-reference-manifest.js +1 -1
  157. package/.next/server/server-reference-manifest.json +57 -57
  158. package/.next/static/chunks/06nt9tatr-dug.js +1 -0
  159. package/.next/static/chunks/0_awn8tucznxc.js +1 -0
  160. package/.next/static/chunks/{34s52v8f841zo.js → 0ge-hx0ed83a1.js} +1 -1
  161. package/.next/static/chunks/0pda_vf-abwvx.js +1 -0
  162. package/.next/static/chunks/{2u9yjkpkoij75.js → 0qmd8n52hp-6v.js} +1 -1
  163. package/.next/static/chunks/0rowp9rx3qbxu.js +18 -0
  164. package/.next/static/chunks/{1ocy4w0v20a3v.js → 13ixzheqw7o-w.js} +2 -2
  165. package/.next/static/chunks/1grkt5gr8pbg1.js +17 -0
  166. package/.next/static/chunks/{21gf2t_whhurc.js → 1yugpvvi8qrbu.js} +2 -2
  167. package/.next/static/chunks/{1e4yrzs5evq_r.js → 245hb85nsy6j4.js} +1 -1
  168. package/.next/static/chunks/27lkanxvtdn09.js +1 -0
  169. package/.next/static/chunks/{0oz7a68qanm99.js → 27tvv1i-smrck.js} +1 -1
  170. package/.next/static/chunks/{2zhfdbq50ee5i.js → 2c64drdevdla3.js} +1 -1
  171. package/.next/static/chunks/{0k5tkt_qi52kb.js → 2ndw4w4u2x51r.js} +1 -1
  172. package/.next/static/chunks/37j27b3e1gt7r.js +1 -0
  173. package/.next/static/chunks/{2om4lkg86feb4.js → 38301ji-85i30.js} +1 -1
  174. package/.next/static/chunks/{09c13vnqy7al3.js → 3bbzsitgiltnt.js} +2 -2
  175. package/.next/static/chunks/3fec-aeteddr-.js +1 -0
  176. package/.next/static/chunks/3go3hwp1z2_h2.css +1 -0
  177. package/.next/static/chunks/3h2bun3hcggg8.js +1 -0
  178. package/.next/static/chunks/{0g1p98s2pfx2w.js → 3h2e-xv1zxh_f.js} +2 -2
  179. package/.next/static/chunks/3j1y335o95mj7.js +1 -0
  180. package/.next/static/chunks/3obf9n3cxj99k.js +1 -0
  181. package/.next/static/chunks/{2upc9mkcc3nlq.js → 427qsc6kahxyo.js} +1 -1
  182. package/README.md +31 -14
  183. package/bin/port.mjs +11 -7
  184. package/bin/thursday.mjs +1 -1
  185. package/components/ui/radio-group.tsx +2 -2
  186. package/components/ui/shiny-text.tsx +3 -22
  187. package/docs/how-it-works.md +11 -3
  188. package/docs/images/first-run.png +0 -0
  189. package/docs/images/keeps-going.png +0 -0
  190. package/docs/images/two-minds.png +0 -0
  191. package/features/ai/components/model-browser.tsx +1 -1
  192. package/features/ai/components/model-picker.tsx +1 -1
  193. package/features/ai/guide.ts +9 -4
  194. package/features/ai/prompts/bot.prompt.ts +4 -1
  195. package/features/ai/tools/routine.tool.ts +28 -16
  196. package/features/bot/bot.runner.ts +3 -1
  197. package/features/bot/components/bot-mark.tsx +6 -1
  198. package/features/bot/components/bot-setting.tsx +2 -4
  199. package/features/bot/components/bot-tool.tsx +2 -0
  200. package/features/bot/components/room-list.tsx +12 -3
  201. package/features/bot/components/room-pill.tsx +26 -19
  202. package/features/config/components/config-setting.tsx +17 -5
  203. package/features/config/components/voice-key.tsx +38 -18
  204. package/features/connectors/mcp.const.ts +90 -59
  205. package/features/intro/components/intro.tsx +50 -27
  206. package/features/routine/components/routine-mark.tsx +4 -6
  207. package/features/routine/components/routine-setting.tsx +332 -143
  208. package/features/routine/routine.query.ts +24 -3
  209. package/features/routine/routine.schema.ts +55 -4
  210. package/features/settings/components/install-app.tsx +1 -4
  211. package/features/settings/components/setting-ui.tsx +1 -1
  212. package/features/skills/skills.discover.ts +2 -1
  213. package/features/skills/skills.query.ts +5 -1
  214. package/features/skills/skills.schema.ts +2 -0
  215. package/features/thursday/components/ascii-orb.tsx +39 -10
  216. package/features/thursday/components/connect-wave.tsx +284 -0
  217. package/features/thursday/components/side-captions.tsx +29 -17
  218. package/features/thursday/components/thursday-setting.tsx +2 -2
  219. package/features/thursday/components/thursday.tsx +130 -58
  220. package/features/thursday/components/voice-picker.tsx +1 -1
  221. package/features/thursday/components/write-line.tsx +2 -2
  222. package/features/thursday/open-work.ts +11 -3
  223. package/features/thursday/tool-line.ts +94 -12
  224. package/features/thursday/use-text-call.ts +3 -2
  225. package/features/thursday/use-thursday.ts +5 -2
  226. package/features/workspace/components/artifact-view.tsx +129 -7
  227. package/guide/bots.md +17 -5
  228. package/guide/calls.md +15 -7
  229. package/guide/setup.md +10 -2
  230. package/lib/desktop-notify.ts +3 -2
  231. package/package.json +1 -1
  232. package/scripts/bot-context.test.mts +1 -1
  233. package/skills/computer/SKILL.md +1 -0
  234. package/skills/interactive-page/SKILL.md +4 -0
  235. package/.next/server/chunks/[root-of-the-server]__0n-l_n_._.js +0 -3
  236. package/.next/server/chunks/[root-of-the-server]__0wvo2of._.js +0 -3
  237. package/.next/server/chunks/[root-of-the-server]__1e6aut3._.js +0 -3
  238. package/.next/server/chunks/[root-of-the-server]__1ziojdq._.js +0 -3
  239. package/.next/server/chunks/ssr/[root-of-the-server]__1srp8l5._.js +0 -3
  240. package/.next/server/chunks/ssr/_0mes4s0._.js +0 -3
  241. package/.next/server/chunks/ssr/_0ya0cb0._.js +0 -3
  242. package/.next/server/chunks/ssr/_0yto5s7._.js +0 -20
  243. package/.next/server/chunks/ssr/_10dw2nv._.js +0 -3
  244. package/.next/server/chunks/ssr/_1p0gxeo._.js +0 -164
  245. package/.next/server/chunks/ssr/_1r41yvh._.js +0 -3
  246. package/.next/server/chunks/ssr/_1r79ksv._.js +0 -3
  247. package/.next/server/chunks/ssr/features_bot_1ogjt52._.js +0 -3
  248. package/.next/server/chunks/ssr/node_modules_@base-ui_react_0b3d4m9._.js +0 -3
  249. package/.next/static/chunks/03omwz_vi2ndg.js +0 -1
  250. package/.next/static/chunks/0_1bqgd6wt_og.js +0 -1
  251. package/.next/static/chunks/0abgkzikaznxc.js +0 -1
  252. package/.next/static/chunks/0bddkpash59-o.js +0 -17
  253. package/.next/static/chunks/0c2-bzhy3a0ib.js +0 -1
  254. package/.next/static/chunks/0iilfb1ocevnx.js +0 -1
  255. package/.next/static/chunks/0imbhx-j830a_.css +0 -1
  256. package/.next/static/chunks/24h1y9dhy3m_2.js +0 -1
  257. package/.next/static/chunks/2be08eqxsfc1y.js +0 -1
  258. package/.next/static/chunks/2d3yhjtafgmtw.js +0 -1
  259. package/.next/static/chunks/2f_5saocsko_b.js +0 -1
  260. package/.next/static/chunks/2lwbr5vx0ixaj.js +0 -18
  261. package/.next/static/chunks/44kjmi5h_p8px.js +0 -1
  262. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_buildManifest.js +0 -0
  263. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_clientMiddlewareManifest.js +0 -0
  264. /package/.next/static/{UiOMPYpQ4fqGyu5PzS-7j → u8kJqShUAa49vY7BxAWsw}/_ssgManifest.js +0 -0
@@ -15,11 +15,39 @@ const TimeSchema = z
15
15
  .string()
16
16
  .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "A time is HH:MM, 00:00 to 23:59");
17
17
 
18
+ /** "YYYY-MM-DD HH:MM" in this machine's own time, read as a date. */
19
+ export function momentOf(at: string): Date {
20
+ const [day, time] = at.split(" ");
21
+ const [year, month, date] = day.split("-").map(Number);
22
+ const [hours, minutes] = time.split(":").map(Number);
23
+ return new Date(year, month - 1, date, hours, minutes);
24
+ }
25
+
26
+ const MOMENT = /^\d{4}-\d\d-\d\d ([01]\d|2[0-3]):[0-5]\d$/;
27
+
28
+ const MomentSchema = z
29
+ .string()
30
+ .regex(MOMENT, 'A moment is "YYYY-MM-DD HH:MM"')
31
+ // a month or a day the calendar does not have rolls over into another when read
32
+ .refine((at) => {
33
+ if (!MOMENT.test(at)) return true;
34
+ const read = momentOf(at);
35
+ return (
36
+ read.getMonth() === Number(at.slice(5, 7)) - 1 &&
37
+ read.getDate() === Number(at.slice(8, 10))
38
+ );
39
+ }, "There is no such day");
40
+
18
41
  /**
19
- * When it starts, in this machine's own time. Two kinds and no cron string: a model fills
20
- * either without a syntax to get wrong, and the screen draws either without parsing.
42
+ * When it starts, in this machine's own time. Three kinds and no cron string: a model fills
43
+ * any of them without a syntax to get wrong, and the screen draws each without parsing.
44
+ * One that starts `once` switches itself off as it starts (routine.query claimRoutine).
21
45
  */
22
46
  export const RoutineScheduleSchema = z.discriminatedUnion("kind", [
47
+ z.object({
48
+ kind: z.literal("once"),
49
+ at: MomentSchema,
50
+ }),
23
51
  z.object({
24
52
  kind: z.literal("daily"),
25
53
  time: TimeSchema,
@@ -66,9 +94,31 @@ const RoutineSchema = RoutineInputSchema.extend({
66
94
  export type Routine = z.infer<typeof RoutineSchema>;
67
95
 
68
96
  const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
97
+ const MONTH_NAMES = [
98
+ "Jan",
99
+ "Feb",
100
+ "Mar",
101
+ "Apr",
102
+ "May",
103
+ "Jun",
104
+ "Jul",
105
+ "Aug",
106
+ "Sep",
107
+ "Oct",
108
+ "Nov",
109
+ "Dec",
110
+ ];
69
111
 
70
- /** "Daily 09:00 · Mon–Fri", "Mon 10:00", "Every 6 hours": the one spelling, on screen and to a model. */
112
+ /**
113
+ * "Once · Sep 21 20:00", "Daily 09:00 · Mon–Fri", "Mon 10:00", "Every 6 hours": the one
114
+ * spelling, on screen and to a model.
115
+ */
71
116
  export function scheduleText(schedule: RoutineSchedule): string {
117
+ if (schedule.kind === "once") {
118
+ const [day, time] = schedule.at.split(" ");
119
+ const [, month, date] = day.split("-").map(Number);
120
+ return `Once · ${MONTH_NAMES[month - 1]} ${date} ${time}`;
121
+ }
72
122
  if (schedule.kind === "every")
73
123
  return schedule.hours === 1
74
124
  ? "Every hour"
@@ -86,9 +136,10 @@ export function scheduleText(schedule: RoutineSchedule): string {
86
136
  /**
87
137
  * The first start after `from`. A daily one is the next listed day at its time; an `every`
88
138
  * one counts from `from` itself, so a machine that slept through several starts owes one,
89
- * not all of them.
139
+ * not all of them. A `once` one has its moment, passed or not.
90
140
  */
91
141
  export function nextRun(schedule: RoutineSchedule, from: Date): Date {
142
+ if (schedule.kind === "once") return momentOf(schedule.at);
92
143
  if (schedule.kind === "every")
93
144
  return new Date(from.getTime() + schedule.hours * 3_600_000);
94
145
  const [hours, minutes] = schedule.time.split(":").map(Number);
@@ -139,13 +139,10 @@ export function InstallButton() {
139
139
  onClick={() => void install()}
140
140
  className="group/install flex w-full items-start gap-2.5 rounded-xl px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
141
141
  >
142
- <span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-background ring-1 ring-border">
143
- <AppWindowMac className="size-3.5" />
144
- </span>
145
142
  <span className="min-w-0 flex-1">
146
143
  <span className="flex items-center gap-1 text-sm font-medium">
147
144
  Install app
148
- <ArrowUpRight className="size-3.5 text-muted-foreground transition-transform group-hover/install:translate-x-px group-hover/install:-translate-y-px" />
145
+ <ArrowUpRight className="ml-auto size-3.5 text-muted-foreground transition-transform group-hover/install:translate-x-px group-hover/install:-translate-y-px" />
149
146
  </span>
150
147
  <span className="block text-xs leading-snug text-muted-foreground">
151
148
  Its own window, apart from your tabs
@@ -457,7 +457,7 @@ export function SettingChoiceRows<T extends string>({
457
457
  </span>
458
458
  )}
459
459
  </span>
460
- {picked && <Check className="size-4 shrink-0 text-brand" />}
460
+ {picked && <Check className="size-4 shrink-0" />}
461
461
  </button>
462
462
  );
463
463
  })}
@@ -5,7 +5,7 @@ import { botFolder, WORKSPACE } from "@/features/workspace/workspace";
5
5
  import { logger } from "@/lib/logger";
6
6
  import type { Sandbox } from "@/lib/sandbox";
7
7
  import { errorToString } from "@/lib/utils";
8
- import { parseFrontmatter } from "./skills.query";
8
+ import { parseFrontmatter, runsHere } from "./skills.query";
9
9
  import type { SkillFrontmatter } from "./skills.schema";
10
10
 
11
11
  /** Skills for the prompt, every skill at once, without failing the session over one bad file. */
@@ -89,6 +89,7 @@ export async function discoverSkills(
89
89
  continue;
90
90
  }
91
91
 
92
+ if (!runsHere(frontmatter)) continue;
92
93
  if (seenNames.has(frontmatter.name)) continue;
93
94
  seenNames.add(frontmatter.name);
94
95
 
@@ -69,6 +69,10 @@ export function splitFrontmatter(content: string): {
69
69
  return { head: match[1] ?? null, body: rest.trim(), rest, fence: match[0] };
70
70
  }
71
71
 
72
+ /** Whether a skill runs on this machine's OS; the list and the prompt both leave out one that does not. */
73
+ export const runsHere = ({ platforms }: SkillFrontmatter) =>
74
+ !platforms || platforms.includes(process.platform);
75
+
72
76
  export function parseFrontmatter(content: string): SkillFrontmatter {
73
77
  const { head } = splitFrontmatter(content);
74
78
  if (!head) {
@@ -172,7 +176,7 @@ export async function findAllSkills(): Promise<SkillSummary[]> {
172
176
  }
173
177
  for (const dir of names) {
174
178
  const summary = await readSummary(source, dir);
175
- if (summary) out.push(summary);
179
+ if (summary && runsHere(summary)) out.push(summary);
176
180
  }
177
181
  }
178
182
  return out;
@@ -22,6 +22,8 @@ export const SkillFrontmatterSchema = z.object({
22
22
  description: z.string().trim().min(1, "Description is required"),
23
23
  /** true hides the skill from the list and the prompt; absent means enabled. */
24
24
  disabled: z.boolean().optional(),
25
+ /** `process.platform` values it runs on (`darwin`); elsewhere it is not listed at all. Absent means everywhere. */
26
+ platforms: z.array(z.string()).optional(),
25
27
  });
26
28
  export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
27
29
 
@@ -226,6 +226,16 @@ const WORD_FADE_IN = 0.3;
226
226
  const WORD_HOLD = 3.4;
227
227
  const WORD_STEP_OUT = 0.08;
228
228
  const WORD_FADE_OUT = 0.35;
229
+ /**
230
+ * A word arrives and leaves rough rather than as clean strokes: each cell of a letter keeps
231
+ * its own time within SCATTER (s), a cell still coming in or going out crackles on and off
232
+ * STATIC_RATE times a second, and what goes out drops to RESIDUE and lingers as an
233
+ * afterimage for LINGER (s) before it is gone.
234
+ */
235
+ const WORD_SCATTER = 0.45;
236
+ const WORD_STATIC_RATE = 14;
237
+ const WORD_RESIDUE = 0.2;
238
+ const WORD_LINGER = 0.9;
229
239
  /** A word given this soon after she mounts came with her (ms). */
230
240
  const BORN_WITH_MS = 600;
231
241
  /**
@@ -517,22 +527,33 @@ function onTwoLines(text: string): number[][] | null {
517
527
  ];
518
528
  }
519
529
 
520
- /** A word's letters `age` seconds into its showing: each lights in turn, holds, and goes out in turn. */
530
+ /**
531
+ * A word's letters `age` seconds into its showing: each lights in turn, holds, and goes out in
532
+ * turn — every cell on its own beat, crackling while it changes, and leaving an afterimage.
533
+ */
521
534
  function wordValue(cell: Cell, t: number, age: number, hold: number) {
522
535
  if (cell.word < 0) return 0;
523
- const jitter = cell.seed * 0.08;
536
+ const jitter = cell.seed * WORD_SCATTER;
524
537
  const appear = smoothstep(
525
538
  0,
526
539
  1,
527
540
  (age - cell.word * WORD_STEP_IN - jitter) / WORD_FADE_IN,
528
541
  );
529
- const vanish = smoothstep(
530
- 0,
531
- 1,
532
- (age - hold - cell.word * WORD_STEP_OUT - jitter) / WORD_FADE_OUT,
533
- );
542
+ const out = age - hold - cell.word * WORD_STEP_OUT - jitter;
543
+ const vanish = smoothstep(0, 1, out / WORD_FADE_OUT);
544
+ // what the letter drops to, then the afterimage fading out of that
545
+ const residue =
546
+ WORD_RESIDUE * (1 - smoothstep(0, 1, (out - WORD_FADE_OUT) / WORD_LINGER));
547
+ const lit = Math.max(appear - vanish, appear > 0.5 ? residue * vanish : 0);
548
+ const changing = (appear > 0 && appear < 1) || (vanish > 0 && residue > 0);
549
+ const crackle =
550
+ changing &&
551
+ hash(Math.floor(t * WORD_STATIC_RATE) + cell.seed * 97, cell.grain * 31) <
552
+ 0.35
553
+ ? 0.25
554
+ : 1;
534
555
  const flick = 0.86 + Math.sin(t * 3.5 + cell.seed * 6) * 0.14;
535
- return Math.max(0, appear - vanish) * flick;
556
+ return lit * crackle * flick;
536
557
  }
537
558
 
538
559
  /**
@@ -753,11 +774,14 @@ export function AsciiOrb({
753
774
  // lighting in a moment after an empty face read as a late start
754
775
  useEffect(() => {
755
776
  if (!word) return;
777
+ // A word is said as it comes. One handed back later — the goodbye still held when a
778
+ // ring's CALL steps aside — would already be over, so it is not said again
779
+ if (Date.now() - word.at > (word.hold ?? WORD_HOLD) * 1000) return;
756
780
  const { cw, ch } = pitchRef.current;
757
781
  const now = performance.now();
758
782
  const lit =
759
783
  now - bornAt.current < BORN_WITH_MS
760
- ? word.text.length * WORD_STEP_IN + WORD_FADE_IN + 0.08
784
+ ? word.text.length * WORD_STEP_IN + WORD_FADE_IN + WORD_SCATTER
761
785
  : 0;
762
786
  wordRef.current = {
763
787
  text: word.text,
@@ -857,7 +881,12 @@ export function AsciiOrb({
857
881
  const wording =
858
882
  shown !== null &&
859
883
  cur.mode !== "error" &&
860
- wordAge < shown.hold + shown.letters * WORD_STEP_OUT + WORD_FADE_OUT;
884
+ wordAge <
885
+ shown.hold +
886
+ shown.letters * WORD_STEP_OUT +
887
+ WORD_SCATTER +
888
+ WORD_FADE_OUT +
889
+ WORD_LINGER;
861
890
  const want: Field = wording
862
891
  ? {
863
892
  scale: 0,
@@ -0,0 +1,284 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import {
6
+ ALPHA_TOP,
7
+ CHAR_RATE,
8
+ EMOJI_CHAR_RATE,
9
+ EMOJI_MIN_LEVEL,
10
+ EMOJI_POOL,
11
+ EMOJI_RATIO,
12
+ hash,
13
+ RAMP,
14
+ smoothstep,
15
+ } from "../ascii.const";
16
+ import { useCallHeld } from "../call-signal";
17
+ import type { AsciiCharset } from "../face.const";
18
+ import type { CallStatus } from "../thursday.schema";
19
+
20
+ /** Grid step, px: her face's own. */
21
+ const CELL = 11;
22
+ /** Glyph size, px: under the step, so the wave reads as specks with air between them. */
23
+ const GLYPH = 9;
24
+ /** Share of cells that carry a glyph at all: fewer, not larger, keeps the wave light to draw. */
25
+ const KEEP = 0.4;
26
+ /** How far the front travels per second, as a share of the way to the farthest corner. */
27
+ const SPEED = 0.95;
28
+ /** Width of the lit band behind the front, in the same units. */
29
+ const CREST = 0.2;
30
+ /** Share of cells that keep a crumb for a moment after the band has passed. */
31
+ const CRUMBS = 0.06;
32
+ /** Seconds the longest crumb outlives the band. */
33
+ const CRUMB_S = 0.5;
34
+ /** The front starts this far out, so the wave leaves her face rather than covering it. */
35
+ const HOLE = 0.1;
36
+ /** Past the farthest corner, the band and the longest crumb, the wave is over. */
37
+ const DONE_S = (1.2 + CREST) / SPEED + CRUMB_S;
38
+
39
+ type Cell = {
40
+ x: number;
41
+ y: number;
42
+ dd: number;
43
+ seed: number;
44
+ grain: number;
45
+ speck: number;
46
+ roll: number;
47
+ };
48
+
49
+ const FONT = `700 ${GLYPH}px ui-monospace, SFMono-Regular, Menlo, monospace`;
50
+
51
+ /** An emoji's square on the sheet: emoji reach past their font size. */
52
+ const SLOT = Math.ceil(GLYPH * 1.5);
53
+
54
+ /** The emoji drawn once side by side, so a frame copies squares rather than drawing emoji. */
55
+ function sheet(glyphs: readonly string[]) {
56
+ const canvas = document.createElement("canvas");
57
+ canvas.width = SLOT * glyphs.length;
58
+ canvas.height = SLOT;
59
+ const ctx = canvas.getContext("2d");
60
+ if (!ctx) return null;
61
+ ctx.textAlign = "center";
62
+ ctx.textBaseline = "middle";
63
+ ctx.font = FONT;
64
+ glyphs.forEach((glyph, at) => {
65
+ ctx.fillText(glyph, at * SLOT + SLOT / 2, SLOT / 2);
66
+ });
67
+ return canvas;
68
+ }
69
+
70
+ /** Her face fades in over this long as the screen loads (face.tsx `duration-700`); the wave leaves it then. */
71
+ const FACE_IN_MS = 700;
72
+
73
+ const still = () =>
74
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
75
+
76
+ /**
77
+ * When the screen loads and when a call connects, one wave of her glyphs leaves her face and rolls out to every corner
78
+ * of the window: a round front made ragged, and crumbs left where it passed. It is drawn
79
+ * over the whole screen, never takes a click, and does not play when the system asks
80
+ * for less motion. Placed inside the box her face is laid out in; the wave starts at its centre.
81
+ */
82
+ export function ConnectWave({
83
+ status,
84
+ charset,
85
+ }: {
86
+ status: CallStatus;
87
+ charset: AsciiCharset;
88
+ }) {
89
+ const anchor = useRef<HTMLSpanElement>(null);
90
+ const canvas = useRef<HTMLCanvasElement>(null);
91
+ const was = useRef(status);
92
+ const [playing, setPlaying] = useState(false);
93
+ // the first-run intro lies over the call screen, and a wave under it is never seen
94
+ const held = useCallHeld();
95
+ const heldNow = useRef(held);
96
+ heldNow.current = held;
97
+ // Emoji are drawn as the screen loads: the first emoji a page draws is slow, and the
98
+ // moment a call picks up is the wrong moment for it
99
+ const emoji = useRef<HTMLCanvasElement | null>(null);
100
+ useEffect(() => {
101
+ emoji.current = charset === "ascii" ? null : sheet(EMOJI_POOL);
102
+ }, [charset]);
103
+
104
+ useEffect(() => {
105
+ const from = was.current;
106
+ was.current = status;
107
+ // from connecting to anything but connecting, hanging up or failing is a call that picked up
108
+ if (
109
+ from !== "connecting" ||
110
+ status === "connecting" ||
111
+ status === "idle" ||
112
+ status === "ending"
113
+ )
114
+ return;
115
+ if (still()) return;
116
+ setPlaying(true);
117
+ }, [status]);
118
+
119
+ // Once per load, as her face arrives: after the fonts, so the emoji are drawn ready
120
+ useEffect(() => {
121
+ let timer: ReturnType<typeof setTimeout> | undefined;
122
+ let gone = false;
123
+ void document.fonts.ready.then(() => {
124
+ if (gone) return;
125
+ timer = setTimeout(() => {
126
+ if (heldNow.current || still()) return;
127
+ setPlaying(true);
128
+ }, FACE_IN_MS);
129
+ });
130
+ return () => {
131
+ gone = true;
132
+ clearTimeout(timer);
133
+ };
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ const element = canvas.current;
138
+ const box = anchor.current?.getBoundingClientRect();
139
+ if (!playing || !element || !box) return;
140
+ const w = window.innerWidth;
141
+ const h = window.innerHeight;
142
+ const cx = box.left + box.width / 2;
143
+ const cy = box.top + box.height / 2;
144
+ const far = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy));
145
+ const cols = Math.ceil(w / CELL);
146
+ const rows = Math.ceil(h / CELL);
147
+ const cells: Cell[] = [];
148
+ for (let r = 0; r < rows; r++)
149
+ for (let c = 0; c < cols; c++) {
150
+ if (hash(c + 53, r + 5) > KEEP) continue;
151
+ const x = c * CELL + CELL / 2;
152
+ const y = r * CELL + CELL / 2;
153
+ const angle = Math.atan2(y - cy, x - cx);
154
+ const grain = hash(c + 31, r + 17);
155
+ // a few slow lobes and some grain, so the front is round but never a clean circle
156
+ const warp =
157
+ 1 +
158
+ 0.1 * Math.sin(3 * angle + 1.3) +
159
+ 0.05 * Math.sin(7 * angle - 0.7) +
160
+ (grain - 0.5) * 0.1;
161
+ const dd = (Math.hypot(x - cx, y - cy) / far) * warp;
162
+ if (dd < HOLE) continue;
163
+ cells.push({
164
+ x,
165
+ y,
166
+ dd,
167
+ seed: hash(c, r),
168
+ grain,
169
+ speck: hash(c + 7, r + 101),
170
+ roll: hash(c + 211, r + 89),
171
+ });
172
+ }
173
+ // nearest first, so a frame visits only the cells between the crumbs and the front
174
+ cells.sort((a, b) => a.dd - b.dd);
175
+ const from = (dd: number) => {
176
+ let lo = 0;
177
+ let hi = cells.length;
178
+ while (lo < hi) {
179
+ const mid = (lo + hi) >> 1;
180
+ if (cells[mid].dd < dd) lo = mid + 1;
181
+ else hi = mid;
182
+ }
183
+ return lo;
184
+ };
185
+
186
+ // One pixel per CSS pixel: the wave is gone in two seconds, and a retina-sized
187
+ // canvas over the whole window is four times the pixels to put on screen every frame
188
+ element.width = w;
189
+ element.height = h;
190
+ const ctx = element.getContext("2d");
191
+ const emojis = emoji.current ?? sheet(EMOJI_POOL);
192
+ if (!ctx || !emojis) return;
193
+ ctx.textAlign = "center";
194
+ ctx.textBaseline = "middle";
195
+ ctx.font = FONT;
196
+ // her ink is the page's foreground: black on light, white on dark
197
+ ctx.fillStyle = getComputedStyle(element).color;
198
+ const top = RAMP.length - 1;
199
+ // the same glyphs as her face: all emoji, emoji over the bright ascii, or ascii alone
200
+ const emojiAt = (c: Cell, level: number) =>
201
+ charset === "emojiOnly" ||
202
+ (charset === "emoji" && c.roll < EMOJI_RATIO && level >= EMOJI_MIN_LEVEL);
203
+ const rate = charset === "emojiOnly" ? EMOJI_CHAR_RATE : CHAR_RATE;
204
+ const half = SLOT / 2;
205
+
206
+ const t0 = performance.now();
207
+ let frame = 0;
208
+ let raf = 0;
209
+ const tick = () => {
210
+ const t = (performance.now() - t0) / 1000;
211
+ if (t > DONE_S) return setPlaying(false);
212
+ raf = requestAnimationFrame(tick);
213
+ if (frame++ % 2) return;
214
+ const R = t * SPEED;
215
+ ctx.clearRect(0, 0, w, h);
216
+ for (let i = from(R - CREST - CRUMB_S * SPEED); i < cells.length; i++) {
217
+ const c = cells[i];
218
+ const behind = R - c.dd;
219
+ if (behind < -0.03) break;
220
+ let v =
221
+ smoothstep(-0.03, 0.02, behind) *
222
+ (1 - smoothstep(0.02, CREST, behind)) *
223
+ (0.72 + 0.28 * Math.sin(c.dd * 46 - t * 7 + c.seed * 6.283));
224
+ if (c.speck < CRUMBS) {
225
+ const life = 0.18 + c.grain * (CRUMB_S - 0.18);
226
+ const since = (behind - CREST) / SPEED;
227
+ v = Math.max(
228
+ v,
229
+ (0.3 + 0.4 * c.grain) * (1 - smoothstep(life * 0.4, life, since)),
230
+ );
231
+ }
232
+ const level = Math.round(v * top);
233
+ if (level < 1) continue;
234
+ const turn = Math.floor(t * rate + c.seed * 10);
235
+ if (emojiAt(c, level)) {
236
+ // emoji keep their own colour, so only alpha varies, as on her face
237
+ ctx.globalAlpha = 0.35 + (level / top) * 0.65;
238
+ const at = Math.floor(hash(c.seed * 97, turn) * EMOJI_POOL.length);
239
+ ctx.drawImage(
240
+ emojis,
241
+ at * SLOT,
242
+ 0,
243
+ SLOT,
244
+ SLOT,
245
+ c.x - half,
246
+ c.y - half,
247
+ SLOT,
248
+ SLOT,
249
+ );
250
+ continue;
251
+ }
252
+ ctx.globalAlpha = ALPHA_TOP * (level / top);
253
+ const set = RAMP[level];
254
+ ctx.fillText(
255
+ set[Math.floor(hash(c.seed * 131, turn) * set.length)],
256
+ c.x,
257
+ c.y,
258
+ );
259
+ }
260
+ ctx.globalAlpha = 1;
261
+ };
262
+ raf = requestAnimationFrame(tick);
263
+ return () => cancelAnimationFrame(raf);
264
+ }, [playing, charset]);
265
+
266
+ return (
267
+ <>
268
+ <span
269
+ ref={anchor}
270
+ aria-hidden
271
+ className="pointer-events-none absolute inset-0"
272
+ />
273
+ {playing &&
274
+ createPortal(
275
+ <canvas
276
+ ref={canvas}
277
+ aria-hidden
278
+ className="pointer-events-none fixed inset-0 z-30 size-full text-foreground"
279
+ />,
280
+ document.body,
281
+ )}
282
+ </>
283
+ );
284
+ }
@@ -104,14 +104,16 @@ const HOVER_INK = 0.9;
104
104
  /** The words are 17px on 1.675; the level turn's first line sits here against the face's middle. */
105
105
  const LINE = 17 * 1.675;
106
106
  const LIFT = -40;
107
- /** Between her latest words and what stands under them. */
107
+ /** Between her level words and what stands under them. */
108
108
  const UNDER_GAP = 8;
109
+ /** The height key of what stands under the level turn, beside the turns' own ids. */
110
+ const UNDER = "(under)";
109
111
 
110
112
  /**
111
113
  * The conversation beside the face, no plates and no names: a filled dot at
112
114
  * the head of each side's level turn says whose it is, and pulses while that
113
- * turn is still being said. Anchored outside the face box so nothing covers
114
- * the face.
115
+ * turn is still being said. Anchored past her canvas (the face box's
116
+ * `--face-bleed` beyond its edge) so nothing covers the face.
115
117
  */
116
118
  export function SideCaptions({
117
119
  turns,
@@ -120,13 +122,14 @@ export function SideCaptions({
120
122
  onPick,
121
123
  under = null,
122
124
  ahead = false,
125
+ workOf,
123
126
  }: {
124
127
  turns: Turn[];
125
128
  pinned: Pinned;
126
129
  /** The last turn is still being said. */
127
130
  live: boolean;
128
131
  onPick: (role: Role, at: number) => void;
129
- /** What stands under her latest words, on her side: the work behind them. */
132
+ /** Her turn in the making: the work under way before she has said anything to it. */
130
133
  under?: ReactNode;
131
134
  /**
132
135
  * That work is her turn in the making — theirs came last and she has not spoken yet — so
@@ -134,6 +137,8 @@ export function SideCaptions({
134
137
  * new turn.
135
138
  */
136
139
  ahead?: boolean;
140
+ /** The work behind one of her turns, drawn under it while it is level; null for none. */
141
+ workOf?: (turn: string) => ReactNode;
137
142
  }) {
138
143
  const last = turns.at(-1);
139
144
  return (
@@ -148,6 +153,7 @@ export function SideCaptions({
148
153
  onPick={(at) => onPick(role, at)}
149
154
  under={role === "assistant" ? under : null}
150
155
  ahead={role === "assistant" && ahead}
156
+ workOf={role === "assistant" ? workOf : undefined}
151
157
  />
152
158
  ))}
153
159
  </>
@@ -162,6 +168,7 @@ function SideColumn({
162
168
  onPick,
163
169
  under,
164
170
  ahead,
171
+ workOf,
165
172
  }: {
166
173
  role: Role;
167
174
  /** This side's turns only. */
@@ -172,6 +179,7 @@ function SideColumn({
172
179
  onPick: (at: number) => void;
173
180
  under: ReactNode;
174
181
  ahead: boolean;
182
+ workOf: ((turn: string) => ReactNode) | undefined;
175
183
  }) {
176
184
  const mine = role === "user";
177
185
  // Heights as laid out, before any transform: text grows while it is said, and
@@ -213,18 +221,22 @@ function SideColumn({
213
221
  // Every turn is placed by its top edge and scales from it. Centring the box
214
222
  // instead jumps a turn half a line the moment its text wraps, then slides it
215
223
  // back once the taller box is measured a frame later
224
+ // Under the level line: her turn in the making, else the work behind the turn that is
225
+ // level — the latest, or one gone back to
226
+ const below = at === turns.length ? under : (workOf?.(turns[at].id) ?? null);
216
227
  const top: number[] = Array(turns.length + 1).fill(0);
217
228
  top[at] = -LINE / 2;
218
229
  for (let k = at - 1; k >= 0; k--) top[k] = top[k + 1] - GAP - tall(k);
230
+ // what stands under a turn gone back to pushes the turns after it down, not over them
231
+ const room =
232
+ below && at < turns.length ? (heights[UNDER] ?? 0) + UNDER_GAP : 0;
219
233
  for (let k = at + 1; k < turns.length; k++)
220
- top[k] = top[k - 1] + tall(k - 1) + GAP;
221
- // Under the latest words while they are level; a side that went back shows none of it
222
- const underTop =
223
- at === turns.length
234
+ top[k] = top[k - 1] + tall(k - 1) + GAP + (k - 1 === at ? room : 0);
235
+ const underTop = !below
236
+ ? null
237
+ : at === turns.length
224
238
  ? top[at]
225
- : at === latest
226
- ? top[at] + (heights[turns[at].id] ?? LINE) + UNDER_GAP
227
- : null;
239
+ : top[at] + (heights[turns[at].id] ?? LINE) + UNDER_GAP;
228
240
 
229
241
  return (
230
242
  <div
@@ -232,8 +244,8 @@ function SideColumn({
232
244
  "pointer-events-none absolute h-0 w-[min(21.25rem,24vw)]",
233
245
  // the vanishing point is the face's edge on the middle line: what recedes goes toward her
234
246
  mine
235
- ? "left-full ml-1.5 [perspective-origin:0_0]"
236
- : "right-full mr-1.5 [perspective-origin:100%_0]",
247
+ ? "left-full ml-[calc(var(--face-bleed,0px)+0.375rem)] [perspective-origin:0_0]"
248
+ : "right-full mr-[calc(var(--face-bleed,0px)+0.375rem)] [perspective-origin:100%_0]",
237
249
  )}
238
250
  style={{ top: `calc(50% + ${LIFT}px)`, perspective: PERSPECTIVE }}
239
251
  >
@@ -296,17 +308,17 @@ function SideColumn({
296
308
  </div>
297
309
  );
298
310
  })}
299
- {under && (
311
+ {below && (
300
312
  <div
301
- aria-hidden={underTop === null}
313
+ ref={measure}
314
+ data-turn={UNDER}
302
315
  style={{ transform: `translateY(${(underTop ?? 0).toFixed(1)}px)` }}
303
316
  className={cn(
304
317
  "absolute top-0 flex w-full transition-transform duration-[520ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
305
318
  mine ? "left-0 justify-start" : "right-0 justify-end",
306
- underTop === null && "invisible",
307
319
  )}
308
320
  >
309
- {under}
321
+ {below}
310
322
  </div>
311
323
  )}
312
324
  </div>
@@ -413,7 +413,7 @@ function BackendModelPicker({
413
413
  <span className="min-w-0 flex-1 truncate text-sm font-medium">
414
414
  {model.label}
415
415
  </span>
416
- {picked && <Check className="size-3.5 shrink-0 text-brand" />}
416
+ {picked && <Check className="size-3.5 shrink-0" />}
417
417
  </span>
418
418
  <span className="block truncate font-mono text-[11px] text-muted-foreground">
419
419
  {model.id} · {model.tier}
@@ -776,7 +776,7 @@ function Captions({
776
776
  {CAPTION_LABEL[view].hint}
777
777
  </span>
778
778
  </span>
779
- {picked && <Check className="size-4 shrink-0 text-brand" />}
779
+ {picked && <Check className="size-4 shrink-0" />}
780
780
  </span>
781
781
  </button>
782
782
  );