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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/artifact/route.js +3 -3
- package/.next/server/app/api/artifact/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
- package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
- package/.next/server/app/api/memory/edit/route.js +1 -1
- package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/tool-call/route.js +1 -1
- package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
- package/.next/server/app/api/workspace/missing/route.js +2 -2
- package/.next/server/app/api/workspace/missing/route.js.nft.json +1 -1
- package/.next/server/app/api/workspace/route.js.nft.json +1 -1
- package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
- package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
- package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page/server-reference-manifest.json +42 -42
- package/.next/server/app/page.js +5 -4
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0-06t3f._.js +8 -0
- package/.next/server/chunks/{[root-of-the-server]__049f521._.js → [root-of-the-server]__06l05n_._.js} +1 -1
- package/.next/server/chunks/[root-of-the-server]__0_ue8oe._.js +1 -1
- package/.next/server/chunks/{[root-of-the-server]__0ye3da2._.js → [root-of-the-server]__0btqrct._.js} +1 -1
- package/.next/server/chunks/[root-of-the-server]__0c9fgca._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0zp35ul._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__1kv8p3e._.js +1 -1
- package/.next/server/chunks/_02szwk8._.js +1 -1
- package/.next/server/chunks/_06c9kie._.js +24 -24
- package/.next/server/chunks/_0i55en5._.js +6 -6
- package/.next/server/chunks/_0p56fn0._.js +1 -1
- package/.next/server/chunks/_11ujpya._.js +1 -1
- package/.next/server/chunks/_1dbixxz._.js +1 -1
- package/.next/server/chunks/_1dc_d1q._.js +16 -16
- package/.next/server/chunks/_1gnjfli._.js +3 -0
- package/.next/server/chunks/_1klbcft._.js +1 -1
- package/.next/server/chunks/features_0-cnt0l._.js +1 -1
- package/.next/server/chunks/features_0hdaci0._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0-e011f._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0pd8yr7._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1nygfik._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__1sdphb0._.js +1 -1
- package/.next/server/chunks/ssr/{[root-of-the-server]__1fll8dc._.js → [root-of-the-server]__1vdmfbe._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__1waa3rc._.js +34 -49
- package/.next/server/chunks/ssr/{_1anqots._.js → _0-2qsxo._.js} +2 -2
- package/.next/server/chunks/ssr/_02oc_h5._.js +9 -9
- package/.next/server/chunks/ssr/_05yghx6._.js +1 -1
- package/.next/server/chunks/ssr/_07nbwap._.js +3 -0
- package/.next/server/chunks/ssr/_08c4z-9._.js +1 -1
- package/.next/server/chunks/ssr/_0_qfdlw._.js +1 -1
- package/.next/server/chunks/ssr/_0b6_jv-._.js +3 -0
- package/.next/server/chunks/ssr/_0hxt2ul._.js +1 -1
- package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
- package/.next/server/chunks/ssr/{_18xqmny._.js → _0no7i1o._.js} +2 -2
- package/.next/server/chunks/ssr/_0tzg--b._.js +3 -0
- package/.next/server/chunks/ssr/{_1ztk7p6._.js → _114ylv7._.js} +3 -3
- package/.next/server/chunks/ssr/{_1k0spz7._.js → _12c-iwh._.js} +2 -2
- package/.next/server/chunks/ssr/_12w42gi._.js +1 -1
- package/.next/server/chunks/ssr/{_0eaog6_._.js → _167hchk._.js} +2 -2
- package/.next/server/chunks/ssr/{_1x9pskj._.js → _1_x5spz._.js} +3 -3
- package/.next/server/chunks/ssr/{_0dr-ddg._.js → _1a61fc9._.js} +2 -2
- package/.next/server/chunks/ssr/_1jmz33w._.js +1 -1
- package/.next/server/chunks/ssr/{_1y6b5mr._.js → _1w_t7h7._.js} +2 -2
- package/.next/server/chunks/ssr/{_0dh2e1j._.js → _1wyfaq_._.js} +7 -7
- package/.next/server/chunks/ssr/{[root-of-the-server]__0bxsfyn._.js → _200gt-m._.js} +4 -3
- package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
- package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
- package/.next/server/chunks/ssr/{_0c9e5w6._.js → features_workspace_00jz2hu._.js} +3 -4
- package/.next/server/chunks/ssr/{node_modules_0746rzi._.js → node_modules_02ai7br._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0vsxbzp._.js → node_modules_02deexj._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1g1eihe._.js → node_modules_07za262._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0332gw4._.js → node_modules_0_s9get._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1gpednh._.js → node_modules_0n3c7h0._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0t-9nup._.js → node_modules_0od6jwr._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0mxvg8h._.js → node_modules_0rdt7pk._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0vv0-fg._.js → node_modules_0w1z7kz._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1d53yiq._.js → node_modules_0wdupfz._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1u6w1pb._.js → node_modules_1720w0a._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1gu1pph._.js → node_modules_18lsg7d._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1he1w8_._.js → node_modules_1b94fxf._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0qbfyjh._.js → node_modules_1ecdo1s._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0qapgjr._.js → node_modules_1fa9ag7._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0d5uzof._.js → node_modules_1fv-_si._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1lnjapm._.js → node_modules_1hf461g._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1-urho9._.js → node_modules_1m71ta-._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1lynj9m._.js → node_modules_1n93n-g._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0fr-ml8._.js → node_modules_1n9lqu9._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_04vxchl._.js → node_modules_1npnsvd._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0exkwcm._.js → node_modules_1rn2r0n._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_022sipm._.js → node_modules_1vq-mfk._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_00_51_v._.js → node_modules_1vzyw1t._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0wsvp00._.js → node_modules_1w359w4._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1xn6saw._.js → node_modules_1yajbl6._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_16pi_dd._.js → node_modules_20eqhy8._.js} +1 -1
- 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
- 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
- package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_21a5haa._.js → node_modules_@vercel_oidc_dist_05itbb1._.js} +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +43 -43
- package/.next/static/chunks/{0dr16v67vj4n7.js → 07vqbk_9t4em4.js} +2 -2
- package/.next/static/chunks/{2nvepds1fu2ux.js → 08krja1zx7nqg.js} +1 -1
- package/.next/static/chunks/{42wcg3wfg_m_p.js → 0qcuwdk132jjt.js} +1 -1
- package/.next/static/chunks/{2_pnccjp6j1ca.js → 0z69gvysmave7.js} +1 -1
- package/.next/static/chunks/12fvz1w5ows75.js +1 -0
- package/.next/static/chunks/14t03ned286iu.js +1 -0
- package/.next/static/chunks/{2jlz8zp4jax_m.js → 156xxbkrz5lzu.js} +1 -1
- package/.next/static/chunks/1fz14reod4lft.js +1 -0
- package/.next/static/chunks/{0joan-69kbspd.js → 1g23dbabl-vkr.js} +1 -1
- package/.next/static/chunks/{18mkhstgiqcbt.js → 1qy_rc3oxfxok.js} +1 -1
- package/.next/static/chunks/1zea7qdl-7365.js +1 -0
- package/.next/static/chunks/1zvqumkg7kmo0.css +1 -0
- package/.next/static/chunks/26hpqt3y6bogl.js +12 -0
- package/.next/static/chunks/{3zk6q3-3f9swq.js → 2_j5mqm6d-z18.js} +1 -1
- package/.next/static/chunks/{1vj_5bnbxx84e.js → 2dt6tu7a22svs.js} +1 -1
- package/.next/static/chunks/2h5ls3d86kya6.js +1 -0
- package/.next/static/chunks/2wc6y1ht6dfuy.js +1 -0
- package/.next/static/chunks/{1i-u2wzub_to1.js → 2xr0zpll7thyi.js} +6 -6
- package/.next/static/chunks/{26ql4y-43xwhp.js → 31jztc-yq30m7.js} +1 -1
- package/.next/static/chunks/391af0i8eljzk.js +1 -0
- package/.next/static/chunks/{1kp5nfp06nj9h.js → 3p2s2cjokx1aa.js} +2 -2
- package/.next/static/chunks/{1wlo979mx-y3w.js → 41s3fml5oivsa.js} +1 -1
- package/README.md +60 -19
- package/docs/how-it-works.md +2 -0
- package/docs/live-calls.md +38 -33
- package/features/ai/load-tools.ts +4 -2
- package/features/ai/prompts/bot.prompt.ts +5 -5
- package/features/ai/prompts/live.prompt.ts +36 -132
- package/features/ai/prompts/prompt-helper.ts +5 -4
- package/features/ai/prompts/thursday.prompt.ts +75 -54
- package/features/ai/tools/call.tool.ts +32 -4
- package/features/ai/tools/tool-name.ts +1 -0
- package/features/bot/bot.runner.ts +5 -5
- package/features/thursday/ascii.const.ts +74 -0
- package/features/thursday/components/ascii-orb.tsx +173 -30
- package/features/thursday/components/face.tsx +6 -1
- package/features/thursday/components/side-captions.tsx +273 -0
- package/features/thursday/components/tab-state.tsx +11 -3
- package/features/thursday/components/thursday-setting.tsx +6 -5
- package/features/thursday/components/thursday.tsx +227 -251
- package/features/thursday/face.store.ts +5 -0
- package/features/thursday/thursday.action.ts +4 -6
- package/features/thursday/thursday.schema.ts +10 -5
- package/features/thursday/thursday.store.ts +3 -0
- package/features/thursday/use-thursday.ts +461 -392
- package/features/workspace/file-kind.ts +9 -0
- package/hooks/use-hotkey.ts +3 -2
- package/lib/live/live.schema.ts +0 -21
- package/lib/live/live.server.ts +1 -3
- package/package.json +1 -1
- package/scripts/artifact-paths.test.mts +13 -0
- package/scripts/live-session.test.mts +56 -66
- package/.next/server/chunks/[root-of-the-server]__0g-ueei._.js +0 -8
- package/.next/server/chunks/node_modules_consola_dist_chunks_prompt_mjs_1kipdeo._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__172cq4x._.js +0 -3
- package/.next/server/chunks/ssr/_03_928f._.js +0 -3
- package/.next/server/chunks/ssr/_0g_rlog._.js +0 -3
- package/.next/server/chunks/ssr/_123ttsf._.js +0 -3
- package/.next/static/chunks/10k2xvap03z20.css +0 -1
- package/.next/static/chunks/1t9qms5pwwa3n.js +0 -1
- package/.next/static/chunks/2a51-ywz9b2a3.js +0 -1
- package/.next/static/chunks/2fvdgxih7x7hr.js +0 -1
- package/.next/static/chunks/3addj3i4-izma.js +0 -1
- package/.next/static/chunks/3gfh7nxwusbt0.js +0 -1
- package/.next/static/chunks/3gwa44cvz4qoi.js +0 -1
- package/.next/static/chunks/3zkg5iup3v4a2.js +0 -12
- package/.next/static/chunks/405m0bqtoihev.js +0 -1
- /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_buildManifest.js +0 -0
- /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
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
|
-
:
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
430
|
-
? { transform: `translateY(-${at * CAPTION_LEADING}em)` }
|
|
431
|
-
: {}),
|
|
419
|
+
transform: `translateY(-${at * CAPTION_LEADING}em)`,
|
|
432
420
|
}}
|
|
433
|
-
className=
|
|
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
|
|
765
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
817
|
-
* elapsed time. Keyed
|
|
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
|
-
|
|
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.
|
|
962
|
-
*
|
|
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({
|
|
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
|
-
{/*
|
|
982
|
-
<
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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">
|
|
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}
|