thursday-agent 0.6.0 → 0.7.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 (193) 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.html +1 -1
  5. package/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  9. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  10. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_not-found.html +1 -1
  12. package/.next/server/app/_not-found.rsc +2 -2
  13. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  16. package/.next/server/app/api/artifact/route.js +3 -3
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  23. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  24. package/.next/server/app/api/memory/edit/route.js +1 -1
  25. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  26. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  28. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  29. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  30. package/.next/server/app/api/thursday/tool-call/route.js +1 -1
  31. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  32. package/.next/server/app/api/workspace/missing/route.js +2 -2
  33. package/.next/server/app/api/workspace/missing/route.js.nft.json +1 -1
  34. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  35. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  36. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  37. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/page/server-reference-manifest.json +42 -42
  39. package/.next/server/app/page.js +5 -4
  40. package/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/server/chunks/[root-of-the-server]__0-06t3f._.js +8 -0
  43. package/.next/server/chunks/{[root-of-the-server]__049f521._.js → [root-of-the-server]__06l05n_._.js} +1 -1
  44. package/.next/server/chunks/[root-of-the-server]__0_ue8oe._.js +1 -1
  45. package/.next/server/chunks/{[root-of-the-server]__0ye3da2._.js → [root-of-the-server]__0btqrct._.js} +1 -1
  46. package/.next/server/chunks/[root-of-the-server]__0c9fgca._.js +1 -1
  47. package/.next/server/chunks/[root-of-the-server]__0zp35ul._.js +1 -1
  48. package/.next/server/chunks/[root-of-the-server]__1kv8p3e._.js +1 -1
  49. package/.next/server/chunks/_02szwk8._.js +1 -1
  50. package/.next/server/chunks/_06c9kie._.js +24 -24
  51. package/.next/server/chunks/_0i55en5._.js +6 -6
  52. package/.next/server/chunks/_0p56fn0._.js +1 -1
  53. package/.next/server/chunks/_11ujpya._.js +1 -1
  54. package/.next/server/chunks/_1dbixxz._.js +1 -1
  55. package/.next/server/chunks/_1dc_d1q._.js +16 -16
  56. package/.next/server/chunks/_1gnjfli._.js +3 -0
  57. package/.next/server/chunks/_1klbcft._.js +1 -1
  58. package/.next/server/chunks/features_0-cnt0l._.js +1 -1
  59. package/.next/server/chunks/features_0hdaci0._.js +1 -1
  60. package/.next/server/chunks/ssr/[root-of-the-server]__0-e011f._.js +3 -0
  61. package/.next/server/chunks/ssr/[root-of-the-server]__0pd8yr7._.js +3 -0
  62. package/.next/server/chunks/ssr/[root-of-the-server]__1nygfik._.js +1 -1
  63. package/.next/server/chunks/ssr/[root-of-the-server]__1sdphb0._.js +1 -1
  64. package/.next/server/chunks/ssr/{[root-of-the-server]__1fll8dc._.js → [root-of-the-server]__1vdmfbe._.js} +2 -2
  65. package/.next/server/chunks/ssr/[root-of-the-server]__1waa3rc._.js +34 -49
  66. package/.next/server/chunks/ssr/{_1anqots._.js → _0-2qsxo._.js} +2 -2
  67. package/.next/server/chunks/ssr/_02oc_h5._.js +9 -9
  68. package/.next/server/chunks/ssr/_05yghx6._.js +1 -1
  69. package/.next/server/chunks/ssr/_07nbwap._.js +3 -0
  70. package/.next/server/chunks/ssr/_08c4z-9._.js +1 -1
  71. package/.next/server/chunks/ssr/_0_qfdlw._.js +1 -1
  72. package/.next/server/chunks/ssr/_0b6_jv-._.js +3 -0
  73. package/.next/server/chunks/ssr/_0hxt2ul._.js +1 -1
  74. package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
  75. package/.next/server/chunks/ssr/{_18xqmny._.js → _0no7i1o._.js} +2 -2
  76. package/.next/server/chunks/ssr/_0tzg--b._.js +3 -0
  77. package/.next/server/chunks/ssr/{_1ztk7p6._.js → _114ylv7._.js} +3 -3
  78. package/.next/server/chunks/ssr/{_1k0spz7._.js → _12c-iwh._.js} +2 -2
  79. package/.next/server/chunks/ssr/_12w42gi._.js +1 -1
  80. package/.next/server/chunks/ssr/{_0eaog6_._.js → _167hchk._.js} +2 -2
  81. package/.next/server/chunks/ssr/{_1x9pskj._.js → _1_x5spz._.js} +3 -3
  82. package/.next/server/chunks/ssr/{_0dr-ddg._.js → _1a61fc9._.js} +2 -2
  83. package/.next/server/chunks/ssr/_1jmz33w._.js +1 -1
  84. package/.next/server/chunks/ssr/{_1y6b5mr._.js → _1w_t7h7._.js} +2 -2
  85. package/.next/server/chunks/ssr/{_0dh2e1j._.js → _1wyfaq_._.js} +7 -7
  86. package/.next/server/chunks/ssr/{[root-of-the-server]__0bxsfyn._.js → _200gt-m._.js} +4 -3
  87. package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
  88. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  89. package/.next/server/chunks/ssr/{_0c9e5w6._.js → features_workspace_00jz2hu._.js} +3 -4
  90. package/.next/server/chunks/ssr/{node_modules_0746rzi._.js → node_modules_02ai7br._.js} +1 -1
  91. package/.next/server/chunks/ssr/{node_modules_0vsxbzp._.js → node_modules_02deexj._.js} +1 -1
  92. package/.next/server/chunks/ssr/{node_modules_1g1eihe._.js → node_modules_07za262._.js} +1 -1
  93. package/.next/server/chunks/ssr/{node_modules_0332gw4._.js → node_modules_0_s9get._.js} +1 -1
  94. package/.next/server/chunks/ssr/{node_modules_1gpednh._.js → node_modules_0n3c7h0._.js} +1 -1
  95. package/.next/server/chunks/ssr/{node_modules_0t-9nup._.js → node_modules_0od6jwr._.js} +1 -1
  96. package/.next/server/chunks/ssr/{node_modules_0mxvg8h._.js → node_modules_0rdt7pk._.js} +1 -1
  97. package/.next/server/chunks/ssr/{node_modules_0vv0-fg._.js → node_modules_0w1z7kz._.js} +1 -1
  98. package/.next/server/chunks/ssr/{node_modules_1d53yiq._.js → node_modules_0wdupfz._.js} +1 -1
  99. package/.next/server/chunks/ssr/{node_modules_1u6w1pb._.js → node_modules_1720w0a._.js} +1 -1
  100. package/.next/server/chunks/ssr/{node_modules_1gu1pph._.js → node_modules_18lsg7d._.js} +1 -1
  101. package/.next/server/chunks/ssr/{node_modules_1he1w8_._.js → node_modules_1b94fxf._.js} +1 -1
  102. package/.next/server/chunks/ssr/{node_modules_0qbfyjh._.js → node_modules_1ecdo1s._.js} +1 -1
  103. package/.next/server/chunks/ssr/{node_modules_0qapgjr._.js → node_modules_1fa9ag7._.js} +1 -1
  104. package/.next/server/chunks/ssr/{node_modules_0d5uzof._.js → node_modules_1fv-_si._.js} +1 -1
  105. package/.next/server/chunks/ssr/{node_modules_1lnjapm._.js → node_modules_1hf461g._.js} +1 -1
  106. package/.next/server/chunks/ssr/{node_modules_1-urho9._.js → node_modules_1m71ta-._.js} +1 -1
  107. package/.next/server/chunks/ssr/{node_modules_1lynj9m._.js → node_modules_1n93n-g._.js} +1 -1
  108. package/.next/server/chunks/ssr/{node_modules_0fr-ml8._.js → node_modules_1n9lqu9._.js} +1 -1
  109. package/.next/server/chunks/ssr/{node_modules_04vxchl._.js → node_modules_1npnsvd._.js} +1 -1
  110. package/.next/server/chunks/ssr/{node_modules_0exkwcm._.js → node_modules_1rn2r0n._.js} +1 -1
  111. package/.next/server/chunks/ssr/{node_modules_022sipm._.js → node_modules_1vq-mfk._.js} +1 -1
  112. package/.next/server/chunks/ssr/{node_modules_00_51_v._.js → node_modules_1vzyw1t._.js} +1 -1
  113. package/.next/server/chunks/ssr/{node_modules_0wsvp00._.js → node_modules_1w359w4._.js} +1 -1
  114. package/.next/server/chunks/ssr/{node_modules_1xn6saw._.js → node_modules_1yajbl6._.js} +1 -1
  115. package/.next/server/chunks/ssr/{node_modules_16pi_dd._.js → node_modules_20eqhy8._.js} +1 -1
  116. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_067_exa._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_019sbis._.js} +1 -1
  117. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0nrrdaq._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0s6piq3._.js} +1 -1
  118. package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_21a5haa._.js → node_modules_@vercel_oidc_dist_05itbb1._.js} +1 -1
  119. package/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/server/pages/404.html +1 -1
  121. package/.next/server/pages/500.html +1 -1
  122. package/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/server/server-reference-manifest.json +43 -43
  124. package/.next/static/chunks/{0dr16v67vj4n7.js → 07vqbk_9t4em4.js} +2 -2
  125. package/.next/static/chunks/{2nvepds1fu2ux.js → 08krja1zx7nqg.js} +1 -1
  126. package/.next/static/chunks/{42wcg3wfg_m_p.js → 0qcuwdk132jjt.js} +1 -1
  127. package/.next/static/chunks/{2_pnccjp6j1ca.js → 0z69gvysmave7.js} +1 -1
  128. package/.next/static/chunks/12fvz1w5ows75.js +1 -0
  129. package/.next/static/chunks/14t03ned286iu.js +1 -0
  130. package/.next/static/chunks/{2jlz8zp4jax_m.js → 156xxbkrz5lzu.js} +1 -1
  131. package/.next/static/chunks/1fz14reod4lft.js +1 -0
  132. package/.next/static/chunks/{0joan-69kbspd.js → 1g23dbabl-vkr.js} +1 -1
  133. package/.next/static/chunks/{18mkhstgiqcbt.js → 1qy_rc3oxfxok.js} +1 -1
  134. package/.next/static/chunks/1zea7qdl-7365.js +1 -0
  135. package/.next/static/chunks/1zvqumkg7kmo0.css +1 -0
  136. package/.next/static/chunks/26hpqt3y6bogl.js +12 -0
  137. package/.next/static/chunks/{3zk6q3-3f9swq.js → 2_j5mqm6d-z18.js} +1 -1
  138. package/.next/static/chunks/{1vj_5bnbxx84e.js → 2dt6tu7a22svs.js} +1 -1
  139. package/.next/static/chunks/2h5ls3d86kya6.js +1 -0
  140. package/.next/static/chunks/2wc6y1ht6dfuy.js +1 -0
  141. package/.next/static/chunks/{1i-u2wzub_to1.js → 2xr0zpll7thyi.js} +6 -6
  142. package/.next/static/chunks/{26ql4y-43xwhp.js → 31jztc-yq30m7.js} +1 -1
  143. package/.next/static/chunks/391af0i8eljzk.js +1 -0
  144. package/.next/static/chunks/{1kp5nfp06nj9h.js → 3p2s2cjokx1aa.js} +2 -2
  145. package/.next/static/chunks/{1wlo979mx-y3w.js → 41s3fml5oivsa.js} +1 -1
  146. package/README.md +60 -19
  147. package/docs/how-it-works.md +2 -0
  148. package/docs/live-calls.md +38 -33
  149. package/features/ai/load-tools.ts +4 -2
  150. package/features/ai/prompts/bot.prompt.ts +5 -5
  151. package/features/ai/prompts/live.prompt.ts +36 -132
  152. package/features/ai/prompts/prompt-helper.ts +5 -4
  153. package/features/ai/prompts/thursday.prompt.ts +75 -54
  154. package/features/ai/tools/call.tool.ts +32 -4
  155. package/features/ai/tools/tool-name.ts +1 -0
  156. package/features/bot/bot.runner.ts +5 -5
  157. package/features/thursday/ascii.const.ts +74 -0
  158. package/features/thursday/components/ascii-orb.tsx +173 -30
  159. package/features/thursday/components/face.tsx +6 -1
  160. package/features/thursday/components/side-captions.tsx +273 -0
  161. package/features/thursday/components/tab-state.tsx +11 -3
  162. package/features/thursday/components/thursday-setting.tsx +6 -5
  163. package/features/thursday/components/thursday.tsx +227 -251
  164. package/features/thursday/face.store.ts +5 -0
  165. package/features/thursday/thursday.action.ts +4 -6
  166. package/features/thursday/thursday.schema.ts +10 -5
  167. package/features/thursday/thursday.store.ts +3 -0
  168. package/features/thursday/use-thursday.ts +461 -392
  169. package/features/workspace/file-kind.ts +9 -0
  170. package/hooks/use-hotkey.ts +3 -2
  171. package/lib/live/live.schema.ts +0 -21
  172. package/lib/live/live.server.ts +1 -3
  173. package/package.json +1 -1
  174. package/scripts/artifact-paths.test.mts +13 -0
  175. package/scripts/live-session.test.mts +56 -66
  176. package/.next/server/chunks/[root-of-the-server]__0g-ueei._.js +0 -8
  177. package/.next/server/chunks/node_modules_consola_dist_chunks_prompt_mjs_1kipdeo._.js +0 -3
  178. package/.next/server/chunks/ssr/[root-of-the-server]__172cq4x._.js +0 -3
  179. package/.next/server/chunks/ssr/_03_928f._.js +0 -3
  180. package/.next/server/chunks/ssr/_0g_rlog._.js +0 -3
  181. package/.next/server/chunks/ssr/_123ttsf._.js +0 -3
  182. package/.next/static/chunks/10k2xvap03z20.css +0 -1
  183. package/.next/static/chunks/1t9qms5pwwa3n.js +0 -1
  184. package/.next/static/chunks/2a51-ywz9b2a3.js +0 -1
  185. package/.next/static/chunks/2fvdgxih7x7hr.js +0 -1
  186. package/.next/static/chunks/3addj3i4-izma.js +0 -1
  187. package/.next/static/chunks/3gfh7nxwusbt0.js +0 -1
  188. package/.next/static/chunks/3gwa44cvz4qoi.js +0 -1
  189. package/.next/static/chunks/3zkg5iup3v4a2.js +0 -12
  190. package/.next/static/chunks/405m0bqtoihev.js +0 -1
  191. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_buildManifest.js +0 -0
  192. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_clientMiddlewareManifest.js +0 -0
  193. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_ssgManifest.js +0 -0
@@ -4,13 +4,12 @@ import {
4
4
  ChevronDown,
5
5
  ChevronUp,
6
6
  Flag,
7
- Loader2,
8
7
  type LucideIcon,
9
8
  Mic,
10
9
  MicOff,
11
10
  Settings2,
12
11
  } from "lucide-react";
13
- import { type ReactNode, useEffect, useRef, useState } from "react";
12
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
14
13
  import { queryKey } from "@/app/api/query-key";
15
14
  import { Button } from "@/components/ui/button";
16
15
  import { ButtonGroup } from "@/components/ui/button-group";
@@ -22,6 +21,7 @@ import {
22
21
  TooltipProvider,
23
22
  TooltipTrigger,
24
23
  } from "@/components/ui/tooltip";
24
+ import { CALL_IDLE } from "@/config";
25
25
  import { LIVE_PROVIDER } from "@/features/ai/live.schema";
26
26
  import { type Bot, DEFAULT_BOT } from "@/features/bot/bot.schema";
27
27
  import { BotMark } from "@/features/bot/components/bot-mark";
@@ -43,11 +43,14 @@ import {
43
43
  type CallStatus,
44
44
  type CaptionView,
45
45
  FACE_DEFAULT,
46
+ type FaceWord,
46
47
  type ThursdayFace,
47
48
  } from "@/features/thursday/thursday.schema";
48
49
  import { useThursdayStore } from "@/features/thursday/thursday.store";
49
50
  import {
50
51
  type ActivityLine,
52
+ type CallEnd,
53
+ type Ringing,
51
54
  useThursday,
52
55
  } from "@/features/thursday/use-thursday";
53
56
  import { ArtifactView } from "@/features/workspace/components/artifact-view";
@@ -55,6 +58,7 @@ import { useHotkeyLabel } from "@/hooks/use-hotkey";
55
58
  import { useServerRoute } from "@/lib/protocol/use-server-route";
56
59
  import { cn } from "@/lib/utils";
57
60
  import { Face } from "./face";
61
+ import { SideCaptions, turnsOf, useTurnFocus } from "./side-captions";
58
62
  import { TabState } from "./tab-state";
59
63
 
60
64
  /**
@@ -74,7 +78,17 @@ export type CallScreenProps = {
74
78
  tool: ActivityLine | null;
75
79
  /** When the backend picked the turn up (ms); null when it is not working. */
76
80
  thinkingSince?: number | null;
81
+ /** What the backend's latest reasoning summary says it is doing. */
82
+ thinkingTitle?: string | null;
83
+ /** Why the last call ended, when the user did not end it. */
84
+ ended?: CallEnd | null;
85
+ /** The word `emote` last put on the face. */
86
+ faceWord?: FaceWord | null;
77
87
  onTap: () => void;
88
+ /** A call-back ringing; the tap answers it. */
89
+ ringing?: Ringing | null;
90
+ /** Stops the ringing without answering. */
91
+ onDecline?: () => void;
78
92
  /** Wake phrase; null when the tap is the only entry point. */
79
93
  wakePhrase?: string | null;
80
94
  /** Hotkey in readable form (use-hotkey); null if none. */
@@ -99,7 +113,12 @@ export function CallScreen({
99
113
  messages,
100
114
  tool,
101
115
  thinkingSince = null,
116
+ thinkingTitle = null,
117
+ ended = null,
118
+ faceWord = null,
102
119
  onTap,
120
+ ringing = null,
121
+ onDecline,
103
122
  wakePhrase = null,
104
123
  hotkeyLabel = null,
105
124
  idleLeft = null,
@@ -120,7 +139,13 @@ export function CallScreen({
120
139
  const hers =
121
140
  messages.findLast((turn) => turn.role === "assistant")?.text ?? "";
122
141
  const sided = captionView === "sides" && status !== "idle";
123
- const turns = useTurnPager(messages);
142
+ const talk = useMemo(() => turnsOf(messages), [messages]);
143
+ const turns = useTurnFocus(talk, sided);
144
+ const lastRole = talk.at(-1)?.role;
145
+ // the last turn is still being said: her voice is on, or yours came after hers and she has not answered
146
+ const saying =
147
+ (lastRole === "assistant" && status === "speaking") ||
148
+ (lastRole === "user" && status === "listening");
124
149
  return (
125
150
  <div className="relative flex h-full flex-col">
126
151
  <div className="absolute top-5 right-5 z-10">
@@ -147,7 +172,9 @@ export function CallScreen({
147
172
  ? "Add a speech key"
148
173
  : live
149
174
  ? "End the call"
150
- : "Call Thursday"
175
+ : ringing
176
+ ? "Answer Thursday"
177
+ : "Call Thursday"
151
178
  }
152
179
  // the face never moves under the cursor; only press gives a little
153
180
  className={cn(
@@ -156,17 +183,25 @@ export function CallScreen({
156
183
  asleep && "opacity-35",
157
184
  )}
158
185
  >
159
- <Face
160
- look={face}
161
- status={status}
162
- failed={failed}
163
- getSpectrum={getSpectrum}
164
- getMicSpectrum={getMicSpectrum}
165
- className="w-full"
166
- />
186
+ {/* Ringing, the face swells twice and rests, like a phone's ring */}
187
+ <span
188
+ className={cn("block", ringing && "motion-safe:animate-ringing")}
189
+ >
190
+ <Face
191
+ look={face}
192
+ status={status}
193
+ failed={failed}
194
+ word={faceWord}
195
+ getSpectrum={getSpectrum}
196
+ getMicSpectrum={getMicSpectrum}
197
+ className="w-full"
198
+ />
199
+ </span>
167
200
  </button>
168
201
 
169
- {sided && <SideCaptions shown={turns.shown} give={turns.give} />}
202
+ {sided && (
203
+ <SideCaptions turns={talk} focus={turns.focus} live={saying} />
204
+ )}
170
205
  </div>
171
206
 
172
207
  {/* The column is wider than the text (40rem); the side margins hold the caption chevrons (Flow) */}
@@ -179,7 +214,9 @@ export function CallScreen({
179
214
  <ActivityRow
180
215
  tool={tool}
181
216
  thinkingSince={thinkingSince}
217
+ thinkingTitle={thinkingTitle}
182
218
  listening={status === "listening" && thinkingSince === null}
219
+ ringing={ringing}
183
220
  getMicSpectrum={getMicSpectrum}
184
221
  />
185
222
 
@@ -187,8 +224,6 @@ export function CallScreen({
187
224
  `text-balance`: rebalancing changes the line count under the pager. */}
188
225
  <Flow
189
226
  text={sided || status === "idle" ? "" : hers}
190
- fixed
191
- paged
192
227
  fadeIn
193
228
  className="w-full max-w-160 text-center text-base"
194
229
  />
@@ -215,6 +250,10 @@ export function CallScreen({
215
250
  status={status}
216
251
  idleLeft={idleLeft}
217
252
  since={since}
253
+ ended={ended}
254
+ behind={sided && turns.back > 0}
255
+ ringing={ringing !== null}
256
+ onDecline={onDecline}
218
257
  wakePhrase={wakePhrase}
219
258
  hotkeyLabel={hotkeyLabel}
220
259
  />
@@ -322,46 +361,6 @@ function SettingsCorner() {
322
361
  /** Lines the caption box holds. */
323
362
  const CAPTION_LINES = 3;
324
363
 
325
- /**
326
- * Lines a side caption keeps, by age. The newest turn is the one being said, so
327
- * it gets room for a whole spoken sentence; what came before is context and
328
- * decays with the type size beside it.
329
- */
330
- const CAPTION_TURN_LINES = [5, 3, 2];
331
-
332
- /**
333
- * Type size by age. The step is a real one — 18 / 15 / 13 — because it is what
334
- * tells two turns apart once neither carries an edge.
335
- */
336
- const CAPTION_SIZES = ["text-lg", "text-[15px]", "text-[13px]"];
337
-
338
- /** Inner padding by age: a smaller turn sits on a smaller plate. */
339
- const CAPTION_PADS = ["px-4 py-3", "px-3.5 py-2.5", "px-3 py-2"];
340
-
341
- /**
342
- * The plate a turn sits on: fill, no edge. The call screen's ground is the
343
- * page's own — the ascii field is boot and intro only — so an outline covers
344
- * nothing and ends up being the whole design.
345
- *
346
- * Yours is the surface `Bubble` already gives your words in a thread
347
- * (primary, light ink); hers is the page's grey. Age recedes in the fill, not
348
- * in the block's opacity, which would take the ink down with it.
349
- */
350
- const CAPTION_HERS = ["bg-muted", "bg-muted/70", "bg-muted/45"];
351
- const CAPTION_YOURS = ["bg-primary", "bg-primary/75", "bg-primary/60"];
352
-
353
- /** A step off a ramp above. The ramps run out at VISIBLE; the last step is the floor. */
354
- function atAge(ramp: readonly string[], age: number) {
355
- return ramp[age] ?? ramp[ramp.length - 1];
356
- }
357
-
358
- /** Ink by age. Hers recedes with her plate; yours stays legible on every step of its own. */
359
- const CAPTION_HERS_INK = [
360
- "text-foreground",
361
- "text-muted-foreground",
362
- "text-muted-foreground/60",
363
- ];
364
-
365
364
  /**
366
365
  * Line height as a number, not a class: box height and page offset divide by
367
366
  * it, and a class could be overridden by a later `text-*` utility.
@@ -374,21 +373,12 @@ const CAPTION_LEADING = 1.625;
374
373
  */
375
374
  function Flow({
376
375
  text,
377
- lead,
378
376
  lines = CAPTION_LINES,
379
- fixed = false,
380
- paged = false,
381
377
  fadeIn = false,
382
378
  className,
383
379
  }: {
384
380
  text: string;
385
- /** Sits inline before the text. */
386
- lead?: ReactNode;
387
381
  lines?: number;
388
- /** Reserve the full height even with no text. */
389
- fixed?: boolean;
390
- /** Page overflow with chevrons; needs side margin. */
391
- paged?: boolean;
392
382
  /** Each new character fades in as it arrives; what is already drawn stays put. */
393
383
  fadeIn?: boolean;
394
384
  className?: string;
@@ -405,7 +395,7 @@ function Flow({
405
395
  // re-measure on width change and on every text change (streaming moves the last line)
406
396
  useEffect(() => {
407
397
  const node = box.current;
408
- if (!node || !paged) return;
398
+ if (!node) return;
409
399
  const measure = () => {
410
400
  const line = Number.parseFloat(getComputedStyle(node).lineHeight);
411
401
  if (!line) return;
@@ -415,7 +405,7 @@ function Flow({
415
405
  const watch = new ResizeObserver(measure);
416
406
  watch.observe(node);
417
407
  return () => watch.disconnect();
418
- }, [text, paged]);
408
+ }, [text]);
419
409
 
420
410
  const steps = Math.ceil(Math.max(0, rows - lines) / lines);
421
411
  const at = Math.max(0, rows - lines - Math.min(back, steps) * lines);
@@ -426,13 +416,10 @@ function Flow({
426
416
  ref={box}
427
417
  style={{
428
418
  lineHeight: CAPTION_LEADING,
429
- ...(paged
430
- ? { transform: `translateY(-${at * CAPTION_LEADING}em)` }
431
- : {}),
419
+ transform: `translateY(-${at * CAPTION_LEADING}em)`,
432
420
  }}
433
- className={cn("break-keep", paged && "transition-transform duration-200")}
421
+ className="break-keep transition-transform duration-200"
434
422
  >
435
- {lead}
436
423
  {fadeIn
437
424
  ? // char + index: a character already drawn keeps its key and never fades twice
438
425
  Array.from(text).map((char, index) => (
@@ -447,21 +434,6 @@ function Flow({
447
434
  </p>
448
435
  );
449
436
 
450
- // No room for chevrons here (side captions): overflow is clipped and the fading last line is the only cue
451
- if (!paged) {
452
- return (
453
- <div
454
- style={fixed ? { height } : { maxHeight: height }}
455
- className={cn(
456
- "overflow-hidden mask-[linear-gradient(to_bottom,black_calc(100%-0.7rem),transparent_100%)]",
457
- className,
458
- )}
459
- >
460
- {body}
461
- </div>
462
- );
463
- }
464
-
465
437
  return (
466
438
  <div className="relative flex w-full justify-center">
467
439
  <div
@@ -745,12 +717,17 @@ function Mark({
745
717
  function ActivityRow({
746
718
  tool,
747
719
  thinkingSince,
720
+ thinkingTitle,
748
721
  listening,
722
+ ringing,
749
723
  getMicSpectrum,
750
724
  }: {
751
725
  tool: ActivityLine | null;
752
726
  thinkingSince: number | null;
727
+ thinkingTitle: string | null;
753
728
  listening: boolean;
729
+ /** Only while idle, when nothing else takes the slot. */
730
+ ringing: Ringing | null;
754
731
  getMicSpectrum?: () => ArrayLike<number>;
755
732
  }) {
756
733
  // held past the tool so the pill has something to fade out with
@@ -760,13 +737,28 @@ function ActivityRow({
760
737
  }, [tool]);
761
738
  // and past the thinking, for the same reason
762
739
  const [since, setSince] = useState(thinkingSince);
740
+ const [title, setTitle] = useState(thinkingTitle);
763
741
  useEffect(() => {
764
- if (thinkingSince !== null) setSince(thinkingSince);
765
- }, [thinkingSince]);
742
+ if (thinkingSince === null) return;
743
+ setSince(thinkingSince);
744
+ setTitle(thinkingTitle);
745
+ }, [thinkingSince, thinkingTitle]);
766
746
 
767
747
  const hearing = listening && !tool;
768
748
  return (
769
- <div className="grid h-7 max-w-full items-center justify-items-center">
749
+ // Ringing, who calls sits right under the face, over the hint. It leaves at
750
+ // once rather than fading, so the hint does not slide under a fading line
751
+ <div
752
+ className={cn(
753
+ "grid h-7 max-w-full items-center justify-items-center",
754
+ ringing && "order-first",
755
+ )}
756
+ >
757
+ {ringing && (
758
+ <span className="col-start-1 row-start-1 flex max-w-full animate-in fade-in duration-500">
759
+ <Ringer ringing={ringing} />
760
+ </span>
761
+ )}
770
762
  {shown && (
771
763
  <Fade at="col-start-1 row-start-1 max-w-full" shown={tool !== null}>
772
764
  <Activity tool={shown} />
@@ -777,7 +769,11 @@ function ActivityRow({
777
769
  at="col-start-1 row-start-1"
778
770
  shown={thinkingSince !== null && !tool}
779
771
  >
780
- <Thinking since={since} running={thinkingSince !== null && !tool} />
772
+ <Thinking
773
+ since={since}
774
+ title={title}
775
+ running={thinkingSince !== null && !tool}
776
+ />
781
777
  </Fade>
782
778
  )}
783
779
  <Fade at="col-start-1 row-start-1" shown={hearing}>
@@ -787,6 +783,45 @@ function ActivityRow({
787
783
  );
788
784
  }
789
785
 
786
+ /**
787
+ * Who a ringing call-back is for: the bot's face and what it has, then the job,
788
+ * and how many more rang with it. The words pulse while it rings.
789
+ */
790
+ function Ringer({ ringing }: { ringing: Ringing }) {
791
+ const bots = useServerRoute<Bot[]>(queryKey.bot).data;
792
+ const bot = bots?.find((one) => one.name === ringing.bot) ?? null;
793
+ const says =
794
+ ringing.kind === "question"
795
+ ? `${ringing.bot} has a question`
796
+ : ringing.kind === "done"
797
+ ? `Answer from ${ringing.bot}`
798
+ : `${ringing.bot} stopped`;
799
+ return (
800
+ <span className="flex max-w-full items-center gap-1.5 text-[13px] leading-5 text-muted-foreground">
801
+ <span className="grid size-4.5 shrink-0 place-items-center">
802
+ <BotMark
803
+ size={16}
804
+ seed={ringing.bot}
805
+ color={bot?.icon?.color}
806
+ shape={bot?.icon?.shape}
807
+ outline={bot?.icon?.outline}
808
+ paint={bot?.icon?.paint}
809
+ // a question or a stop waits on the user; an answer only waits to be read
810
+ notify={ringing.kind !== "done"}
811
+ />
812
+ </span>
813
+ <ShinyText text={says} motion="pulse" className="shrink-0" />
814
+ <span className="text-muted-foreground/40">·</span>
815
+ <span className="min-w-0 truncate break-keep">{ringing.label}</span>
816
+ {ringing.more > 0 && (
817
+ <span className="shrink-0 rounded-full bg-muted px-1.5 font-mono text-[10px] leading-4.5 text-foreground/70">
818
+ +{ringing.more}
819
+ </span>
820
+ )}
821
+ </span>
822
+ );
823
+ }
824
+
790
825
  /** One face of a stacked slot. Hidden means invisible, untouchable and unread. */
791
826
  function Fade({
792
827
  at,
@@ -811,21 +846,39 @@ function Fade({
811
846
  );
812
847
  }
813
848
 
849
+ /** Why a call ended, as the hint says it. */
850
+ const ENDED: Record<CallEnd, string> = {
851
+ quiet: `${CALL_IDLE.hangUpMs / 1000}s of quiet`,
852
+ hungUp: "Thursday hung up",
853
+ closed: "Live closed the call",
854
+ dropped: "the connection dropped",
855
+ };
856
+
814
857
  /**
815
858
  * The only hint line on screen. Says one thing at a time; outside a call it
816
- * shows one way in (wake phrase or hotkey), inside a call how to end and the
817
- * elapsed time. Keyed so changes fade.
859
+ * shows why the last one ended when the user did not end it, else one way in
860
+ * (wake phrase or hotkey); inside a call how to end and the elapsed time. Keyed
861
+ * so changes fade.
818
862
  */
819
863
  function Hint({
820
864
  status,
821
865
  idleLeft,
822
866
  since,
867
+ ended,
868
+ behind,
869
+ ringing,
870
+ onDecline,
823
871
  wakePhrase,
824
872
  hotkeyLabel,
825
873
  }: {
826
874
  status: CallStatus;
827
875
  idleLeft: number | null;
828
876
  since: number | null;
877
+ ended: CallEnd | null;
878
+ /** The side captions are on an earlier turn. */
879
+ behind: boolean;
880
+ ringing: boolean;
881
+ onDecline?: () => void;
829
882
  wakePhrase: string | null;
830
883
  hotkeyLabel: string | null;
831
884
  }) {
@@ -848,12 +901,50 @@ function Hint({
848
901
  key = "quiet";
849
902
  body = `Quiet — ending in ${idleLeft}s. Say anything to stay.`;
850
903
  } else if (live) {
851
- key = "live";
904
+ key = behind ? "behind" : "live";
852
905
  body = (
853
906
  <>
854
907
  <span>Tap Thursday to end</span>
855
908
  <span className="text-muted-foreground/40">·</span>
856
- <Elapsed since={since} />
909
+ {behind ? (
910
+ <>
911
+ <kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-foreground/80 shadow-[0_1px_0_var(--border)]">
912
+ ↓
913
+ </kbd>
914
+ <span>back to now</span>
915
+ </>
916
+ ) : (
917
+ <Elapsed since={since} />
918
+ )}
919
+ </>
920
+ );
921
+ } else if (ringing) {
922
+ key = "ringing";
923
+ // Every way in answers, so the tap stands for them all; declining is the one other act
924
+ body = (
925
+ <>
926
+ <span>Tap Thursday to answer</span>
927
+ <span className="text-muted-foreground/40">·</span>
928
+ <button
929
+ type="button"
930
+ onClick={onDecline}
931
+ className="flex items-center gap-1.5 rounded-full bg-muted py-0.5 pr-0.5 pl-2 text-foreground/80 transition-colors hover:bg-accent"
932
+ >
933
+ Not now
934
+ <kbd className="rounded-md border border-border bg-background px-1.5 font-mono text-[10px] text-foreground/70 shadow-[0_1px_0_var(--border)]">
935
+ Esc
936
+ </kbd>
937
+ </button>
938
+ </>
939
+ );
940
+ } else if (ended) {
941
+ key = `ended:${ended}`;
942
+ // Where the quiet warning stood, until the next call
943
+ body = (
944
+ <>
945
+ <span>Ended — {ENDED[ended]}</span>
946
+ <span className="text-muted-foreground/40">·</span>
947
+ <span>Tap to call again</span>
857
948
  </>
858
949
  );
859
950
  } else if (wakePhrase) {
@@ -958,11 +1049,19 @@ function NeedsKey({
958
1049
  /**
959
1050
  * The backend has the turn: the activity line says so and counts, so the
960
1051
  * seconds before a tool and before her voice never read as a call that has
961
- * stopped. A spinner fits here as it did not on a tool: there is no glyph yet
962
- * for it to take off the screen. The seconds arrive after the first one, which
963
- * is most answers.
1052
+ * stopped. Once a reasoning summary names what the work is, the line says that
1053
+ * instead. It shines where the rest of this screen pulses (the user's pick). The
1054
+ * seconds arrive after the first one, which is most answers.
964
1055
  */
965
- function Thinking({ since, running }: { since: number; running: boolean }) {
1056
+ function Thinking({
1057
+ since,
1058
+ title,
1059
+ running,
1060
+ }: {
1061
+ since: number;
1062
+ title: string | null;
1063
+ running: boolean;
1064
+ }) {
966
1065
  const [now, setNow] = useState(() => Date.now());
967
1066
  // Stays mounted to fade out; the count stops with it rather than ticking unseen
968
1067
  useEffect(() => {
@@ -976,23 +1075,34 @@ function Thinking({ since, running }: { since: number; running: boolean }) {
976
1075
  };
977
1076
  }, [running]);
978
1077
  const seconds = Math.max(0, Math.floor((now - since) / 1000));
1078
+ const words = title ? `Thinking about ${midSentence(title)}` : "Thinking…";
979
1079
  return (
980
1080
  <span className="flex max-w-full items-center gap-1.5 text-[13px] leading-5">
981
- {/* the 18px slot a tool's glyph takes over when one starts */}
982
- <span className="grid size-[18px] shrink-0 place-items-center">
983
- <Loader2 className="size-3.5 animate-spin text-muted-foreground" />
984
- </span>
985
- <ShinyText text="Thinking…" motion="pulse" />
1081
+ {/* keyed so a new title fades in rather than replacing the words mid-sweep */}
1082
+ <ShinyText
1083
+ key={words}
1084
+ text={words}
1085
+ className="min-w-0 animate-in truncate fade-in duration-300"
1086
+ />
986
1087
  {seconds >= 1 && (
987
1088
  <>
988
- <span className="text-muted-foreground/40">·</span>
989
- <span className="text-muted-foreground tabular-nums">{seconds}s</span>
1089
+ <span className="shrink-0 text-muted-foreground/40">·</span>
1090
+ <span className="shrink-0 text-muted-foreground tabular-nums">
1091
+ {seconds}s
1092
+ </span>
990
1093
  </>
991
1094
  )}
992
1095
  </span>
993
1096
  );
994
1097
  }
995
1098
 
1099
+ /** A title read mid-sentence: "Checking thread status" becomes "checking thread status"; "API limits" stays. */
1100
+ function midSentence(title: string) {
1101
+ return /^\p{Lu}\p{Lu}/u.test(title)
1102
+ ? title
1103
+ : title.charAt(0).toLowerCase() + title.slice(1);
1104
+ }
1105
+
996
1106
  /** Time since the line opened, mm:ss; only this span re-renders each second. */
997
1107
  function Elapsed({ since }: { since: number | null }) {
998
1108
  const [now, setNow] = useState(() => Date.now());
@@ -1012,151 +1122,6 @@ function Elapsed({ since }: { since: number | null }) {
1012
1122
  );
1013
1123
  }
1014
1124
 
1015
- /** Turns shown at once. */
1016
- const VISIBLE = 3;
1017
-
1018
- /**
1019
- * Wheel delta per turn. Deliberately heavy — a turn is a paragraph, and a
1020
- * trackpad flick that pages three of them reads as losing the conversation
1021
- * rather than moving through it. The give is what says it is not stuck.
1022
- */
1023
- const STEP = 360;
1024
-
1025
- /** How far the plates lean while the wheel is short of a turn, px. */
1026
- const GIVE = 10;
1027
-
1028
- /** How long the wheel keeps what it has gathered. Past this the lean springs back and the count starts over, so half a turn never waits around to be completed by the next flick. */
1029
- const SETTLE_MS = 220;
1030
-
1031
- /**
1032
- * Where the wheel has wound the turns back to. Lives on the screen rather than
1033
- * in the plates: the three plates are small and sit off to the sides, so a
1034
- * wheel over the face — where the cursor is — used to reach nothing at all.
1035
- */
1036
- function useTurnPager(messages: CallMessage[]) {
1037
- const [back, setBack] = useState(0);
1038
- const [give, setGive] = useState(0);
1039
- const drag = useRef(0);
1040
- const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
1041
- const seen = useRef(messages.length);
1042
-
1043
- const furthest = Math.max(0, messages.length - VISIBLE);
1044
-
1045
- // stay on the turn being read when new turns arrive
1046
- useEffect(() => {
1047
- const grew = messages.length - seen.current;
1048
- seen.current = messages.length;
1049
- if (grew > 0) {
1050
- setBack((was) => (was > 0 ? Math.min(was + grew, furthest) : 0));
1051
- }
1052
- }, [messages.length, furthest]);
1053
-
1054
- useEffect(
1055
- () => () => {
1056
- if (settle.current) clearTimeout(settle.current);
1057
- },
1058
- [],
1059
- );
1060
-
1061
- const onWheel = (event: React.WheelEvent) => {
1062
- // nothing behind the three on screen: no travel, and no lean pretending there is
1063
- if (furthest === 0) return;
1064
- drag.current += event.deltaY;
1065
- const steps = Math.trunc(drag.current / STEP);
1066
- if (steps !== 0) {
1067
- drag.current -= steps * STEP;
1068
- setBack((was) => Math.min(furthest, Math.max(0, was - steps)));
1069
- }
1070
- setGive(drag.current / STEP);
1071
-
1072
- // spring back so the give reads as resistance, not position
1073
- if (settle.current) clearTimeout(settle.current);
1074
- settle.current = setTimeout(() => {
1075
- drag.current = 0;
1076
- setGive(0);
1077
- }, SETTLE_MS);
1078
- };
1079
-
1080
- const end = messages.length - back;
1081
- return {
1082
- shown: messages.slice(Math.max(0, end - VISIBLE), end),
1083
- give,
1084
- onWheel,
1085
- };
1086
- }
1087
-
1088
- /**
1089
- * Recent turns beside the face: yours on the right, hers on the left, each on
1090
- * a fill with no edge. Anchored outside the face box (`right-full` /
1091
- * `left-full`) so they never cover it. Older turns sit higher, smaller, and
1092
- * their plate recedes.
1093
- */
1094
- function SideCaptions({
1095
- shown,
1096
- give,
1097
- }: {
1098
- shown: CallMessage[];
1099
- /** How far into the next turn the wheel is, -1..1; the plates lean by it. */
1100
- give: number;
1101
- }) {
1102
- return (
1103
- <>
1104
- {shown.map((message, index) => {
1105
- // the speaker fixes the side, so a turn never switches sides as older ones stack up
1106
- const mine = message.role === "user";
1107
- // 18..68 rather than 20..75: a five-line newest turn has to clear the activity line
1108
- const top = 18 + (index * 50) / Math.max(1, shown.length - 1);
1109
- const age = shown.length - 1 - index;
1110
- const size = atAge(CAPTION_SIZES, age);
1111
- const pad = atAge(CAPTION_PADS, age);
1112
- const fill = atAge(mine ? CAPTION_YOURS : CAPTION_HERS, age);
1113
- const ink = atAge(CAPTION_HERS_INK, age);
1114
-
1115
- return (
1116
- <div
1117
- key={message.id}
1118
- style={{
1119
- top: `${top}%`,
1120
- transform: `translateY(calc(-50% + ${-give * GIVE}px))`,
1121
- }}
1122
- className={cn(
1123
- // fixed-width slot, natural-width plate: short lines stay short and hug the face
1124
- "pointer-events-auto absolute flex w-[min(23rem,24vw)] transition-transform duration-100",
1125
- // yours on the right, the side the settings corner already takes
1126
- mine ? "left-full ml-6" : "right-full mr-6 justify-end",
1127
- )}
1128
- >
1129
- {/* Not a `Bubble`: that sets its surface on the content from the
1130
- parent, at a specificity a caption cannot override. One shape,
1131
- two fills — and no edge on either, so the plate is the whole
1132
- surface. The squared corner is the one nearest the face. */}
1133
- <div
1134
- className={cn(
1135
- "w-fit max-w-full animate-in rounded-[18px] fade-in duration-500",
1136
- pad,
1137
- mine
1138
- ? cn(
1139
- "rounded-bl-md text-primary-foreground slide-in-from-left-2",
1140
- fill,
1141
- )
1142
- : cn("rounded-br-md slide-in-from-right-2", fill, ink),
1143
- )}
1144
- >
1145
- {/* One truncation idiom on both sides: speech that runs past the
1146
- frame fades out. An ellipsis mid-sentence reads as an error. */}
1147
- <Flow
1148
- text={message.text}
1149
- lines={CAPTION_TURN_LINES[age] ?? 2}
1150
- className={size}
1151
- />
1152
- </div>
1153
- </div>
1154
- );
1155
- })}
1156
- </>
1157
- );
1158
- }
1159
-
1160
1125
  export function Thursday() {
1161
1126
  const {
1162
1127
  status,
@@ -1164,9 +1129,14 @@ export function Thursday() {
1164
1129
  messages,
1165
1130
  tool,
1166
1131
  thinkingSince,
1132
+ thinkingTitle,
1133
+ ended,
1134
+ faceWord,
1167
1135
  idleLeft,
1168
1136
  since,
1169
1137
  call,
1138
+ ringing,
1139
+ decline,
1170
1140
  getSpectrum,
1171
1141
  getMicSpectrum,
1172
1142
  wakePhrase,
@@ -1188,14 +1158,20 @@ export function Thursday() {
1188
1158
  return (
1189
1159
  <>
1190
1160
  {/* the tab shows the call and what is owed while the app is off-screen */}
1191
- <TabState live={status !== "idle"} />
1161
+ <TabState live={status !== "idle"} ringing={ringing !== null} />
1192
1162
  <CallScreen
1193
1163
  status={status}
1194
1164
  failed={failed}
1195
1165
  messages={messages}
1196
1166
  tool={tool}
1197
1167
  thinkingSince={thinkingSince}
1168
+ thinkingTitle={thinkingTitle}
1169
+ ended={ended}
1170
+ faceWord={faceWord}
1198
1171
  onTap={call}
1172
+ // without a key the face asks for one, so nothing can answer
1173
+ ringing={callable ? ringing : null}
1174
+ onDecline={decline}
1199
1175
  wakePhrase={wakePhrase}
1200
1176
  hotkeyLabel={hotkeyLabel}
1201
1177
  idleLeft={idleLeft}