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
|
@@ -11,15 +11,17 @@ import {
|
|
|
11
11
|
EMOJI_POOL,
|
|
12
12
|
EMOJI_RATIO,
|
|
13
13
|
hash,
|
|
14
|
+
LETTERS,
|
|
14
15
|
RAMP,
|
|
15
16
|
smoothstep,
|
|
16
17
|
} from "../ascii.const";
|
|
17
18
|
import type { AsciiCharset } from "../face.const";
|
|
19
|
+
import type { FaceWord } from "../thursday.schema";
|
|
18
20
|
|
|
19
21
|
export type AsciiOrbMode =
|
|
20
22
|
| "idle"
|
|
21
23
|
| "connecting"
|
|
22
|
-
/** The user's turn: the resting body, retyping with the microphone */
|
|
24
|
+
/** The user's turn: the resting body, thinned out, retyping with the microphone */
|
|
23
25
|
| "listening"
|
|
24
26
|
/** Hanging up: the body draws in and goes out, leaving the field empty */
|
|
25
27
|
| "ending"
|
|
@@ -47,6 +49,11 @@ export type AsciiOrbProps = {
|
|
|
47
49
|
* glyphs retype; without them listening is the resting body alone.
|
|
48
50
|
*/
|
|
49
51
|
getMicSpectrum?: () => ArrayLike<number>;
|
|
52
|
+
/**
|
|
53
|
+
* A word to show (emote): the body steps aside, the letters light in one by
|
|
54
|
+
* one, hold, and go out, and the mode takes the face back. A new `at` shows it again.
|
|
55
|
+
*/
|
|
56
|
+
word?: FaceWord | null;
|
|
50
57
|
/** Side length (px). Everything scales with it; cell count scales with area. */
|
|
51
58
|
size?: number;
|
|
52
59
|
/**
|
|
@@ -62,14 +69,23 @@ const DEFAULT_COLOR: [number, number, number] = [235, 235, 235];
|
|
|
62
69
|
/** Color easing per frame (0.05 is about 0.5s) */
|
|
63
70
|
const COLOR_EASE = 0.05;
|
|
64
71
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
];
|
|
72
|
+
/** A word as five rows of letter indices, one empty column between letters; -1 is no ink. */
|
|
73
|
+
function spell(text: string) {
|
|
74
|
+
const rows: number[][] = [[], [], [], [], []];
|
|
75
|
+
let letter = 0;
|
|
76
|
+
for (const char of Array.from(text.toUpperCase())) {
|
|
77
|
+
const glyph = LETTERS[char];
|
|
78
|
+
if (!glyph) continue;
|
|
79
|
+
if (rows[0].length) for (const row of rows) row.push(-1);
|
|
80
|
+
glyph.forEach((line, r) => {
|
|
81
|
+
for (const px of line) rows[r].push(px === "#" ? letter : -1);
|
|
82
|
+
});
|
|
83
|
+
if (char !== " ") letter++;
|
|
84
|
+
}
|
|
85
|
+
return rows;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ERROR_ROWS = spell("ERROR");
|
|
73
89
|
|
|
74
90
|
/** Reference size the tuning constants assume; coordinates are normalized to it. */
|
|
75
91
|
const DESIGN = 680;
|
|
@@ -145,6 +161,8 @@ const RISE = {
|
|
|
145
161
|
comet: 0.45,
|
|
146
162
|
speech: 0.5,
|
|
147
163
|
err: 0.5,
|
|
164
|
+
word: 0.35,
|
|
165
|
+
listen: 0.3,
|
|
148
166
|
};
|
|
149
167
|
const FALL = {
|
|
150
168
|
scale: 0.7,
|
|
@@ -153,6 +171,8 @@ const FALL = {
|
|
|
153
171
|
comet: 0.9,
|
|
154
172
|
speech: 0.75,
|
|
155
173
|
err: 0.6,
|
|
174
|
+
word: 0.5,
|
|
175
|
+
listen: 0.6,
|
|
156
176
|
};
|
|
157
177
|
/** Hanging up pulls the body in faster than anything else grows back. */
|
|
158
178
|
const END_FALL = 0.4;
|
|
@@ -209,6 +229,14 @@ const LISTEN_PACE_VOICE = 8;
|
|
|
209
229
|
const LISTEN_LIFT = 0.24;
|
|
210
230
|
/** Seconds the retype rate takes to follow the voice (toward) */
|
|
211
231
|
const PACE_TIME = 0.14;
|
|
232
|
+
/**
|
|
233
|
+
* Listening also thins the body out, so the voice has gaps to show in: every
|
|
234
|
+
* cell's gate rises by THIN, and POP_RATE times a second each cell re-rolls up
|
|
235
|
+
* to POP of it, as far as the voice is loud — glyphs pop in and out with the words.
|
|
236
|
+
*/
|
|
237
|
+
const LISTEN_THIN = 0.16;
|
|
238
|
+
const LISTEN_POP = 0.6;
|
|
239
|
+
const LISTEN_POP_RATE = 9;
|
|
212
240
|
|
|
213
241
|
/** Working: a comet orbiting just outside the resting body */
|
|
214
242
|
const WORK_R = 210;
|
|
@@ -228,6 +256,15 @@ const ERR_STEP_OUT = 0.5;
|
|
|
228
256
|
const ERR_FADE_OUT = 0.7;
|
|
229
257
|
const ERR_CYCLE = 9.2;
|
|
230
258
|
|
|
259
|
+
/** A word (emote), once: letters light in one by one, hold, and go out the same way (s) */
|
|
260
|
+
const WORD_STEP_IN = 0.11;
|
|
261
|
+
const WORD_FADE_IN = 0.3;
|
|
262
|
+
const WORD_HOLD = 3.4;
|
|
263
|
+
const WORD_STEP_OUT = 0.08;
|
|
264
|
+
const WORD_FADE_OUT = 0.35;
|
|
265
|
+
/** The widest a word is drawn, as a share of the box: about her body */
|
|
266
|
+
const WORD_WIDTH = 0.62;
|
|
267
|
+
|
|
231
268
|
type Cell = {
|
|
232
269
|
/** Draw position on the canvas */
|
|
233
270
|
x: number;
|
|
@@ -249,26 +286,23 @@ type Cell = {
|
|
|
249
286
|
speck: number;
|
|
250
287
|
/** Index of the ERROR letter this cell belongs to, or -1 */
|
|
251
288
|
letter: number;
|
|
289
|
+
/** Index of the shown word's letter this cell belongs to, or -1; laid again for each word */
|
|
290
|
+
word: number;
|
|
252
291
|
};
|
|
253
292
|
|
|
254
|
-
/** Index of the
|
|
255
|
-
function
|
|
293
|
+
/** Index of the letter at (dx,dy) of a word centred on the box, each of its cells `s` grid cells wide; -1 when none */
|
|
294
|
+
function letterAt(
|
|
295
|
+
rows: number[][],
|
|
256
296
|
dx: number,
|
|
257
297
|
dy: number,
|
|
258
298
|
cw: number,
|
|
259
299
|
ch: number,
|
|
260
300
|
s: number,
|
|
261
301
|
) {
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (by < 0 || by >= rows) return -1;
|
|
268
|
-
const line = ERROR_BITMAP[by];
|
|
269
|
-
if (bx < 0 || bx >= line.length) return -1;
|
|
270
|
-
if (line[bx] !== "█") return -1;
|
|
271
|
-
return Math.floor(bx / 5);
|
|
302
|
+
const bx = Math.floor(dx / (cw * s) + rows[0].length / 2);
|
|
303
|
+
const by = Math.floor(dy / (ch * s) + rows.length / 2);
|
|
304
|
+
if (by < 0 || by >= rows.length) return -1;
|
|
305
|
+
return rows[by][bx] ?? -1;
|
|
272
306
|
}
|
|
273
307
|
|
|
274
308
|
/**
|
|
@@ -308,6 +342,10 @@ type Field = {
|
|
|
308
342
|
speech: number;
|
|
309
343
|
/** The ERROR letters */
|
|
310
344
|
err: number;
|
|
345
|
+
/** A word's letters (emote) */
|
|
346
|
+
word: number;
|
|
347
|
+
/** Listening's thinning out */
|
|
348
|
+
listen: number;
|
|
311
349
|
};
|
|
312
350
|
|
|
313
351
|
/** Where a mode wants the field. `e` is seconds since the mode was asked for. */
|
|
@@ -319,6 +357,8 @@ function targetFor(m: AsciiOrbMode, e: number, v: Voice): Field {
|
|
|
319
357
|
comet: 0,
|
|
320
358
|
speech: 0,
|
|
321
359
|
err: 0,
|
|
360
|
+
word: 0,
|
|
361
|
+
listen: 0,
|
|
322
362
|
};
|
|
323
363
|
switch (m) {
|
|
324
364
|
case "idle":
|
|
@@ -335,11 +375,12 @@ function targetFor(m: AsciiOrbMode, e: number, v: Voice): Field {
|
|
|
335
375
|
};
|
|
336
376
|
}
|
|
337
377
|
|
|
338
|
-
// the body holds still; the user's voice shows as ink and as the retype rate
|
|
378
|
+
// the body holds still; the user's voice shows as ink, in its gaps and as the retype rate
|
|
339
379
|
case "listening":
|
|
340
380
|
return {
|
|
341
381
|
...rest,
|
|
342
382
|
lift: LISTEN_LIFT * (0.7 * v.micPhrase + 0.3 * v.micLevel),
|
|
383
|
+
listen: 1,
|
|
343
384
|
};
|
|
344
385
|
|
|
345
386
|
case "speaking":
|
|
@@ -459,12 +500,52 @@ function errorValue(cell: Cell, t: number, age: number) {
|
|
|
459
500
|
return Math.max(0, appear - vanish) * flick;
|
|
460
501
|
}
|
|
461
502
|
|
|
503
|
+
/** Marks which cells ink each letter of `text`, fitted to about her body's width; returns the letter count. */
|
|
504
|
+
function layWord(cells: Cell[], cw: number, ch: number, text: string) {
|
|
505
|
+
const rows = spell(text);
|
|
506
|
+
const s = Math.max(
|
|
507
|
+
1,
|
|
508
|
+
Math.min(3, Math.floor((DESIGN * WORD_WIDTH) / (rows[0].length * cw))),
|
|
509
|
+
);
|
|
510
|
+
let letters = 0;
|
|
511
|
+
for (const cell of cells) {
|
|
512
|
+
cell.word = letterAt(rows, cell.dx, cell.dy, cw, ch, s);
|
|
513
|
+
letters = Math.max(letters, cell.word + 1);
|
|
514
|
+
}
|
|
515
|
+
return letters;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** A word's letters `age` seconds into its showing: each lights in turn, holds, and goes out in turn. */
|
|
519
|
+
function wordValue(cell: Cell, t: number, age: number) {
|
|
520
|
+
if (cell.word < 0) return 0;
|
|
521
|
+
const jitter = cell.seed * 0.08;
|
|
522
|
+
const appear = smoothstep(
|
|
523
|
+
0,
|
|
524
|
+
1,
|
|
525
|
+
(age - cell.word * WORD_STEP_IN - jitter) / WORD_FADE_IN,
|
|
526
|
+
);
|
|
527
|
+
const vanish = smoothstep(
|
|
528
|
+
0,
|
|
529
|
+
1,
|
|
530
|
+
(age - WORD_HOLD - cell.word * WORD_STEP_OUT - jitter) / WORD_FADE_OUT,
|
|
531
|
+
);
|
|
532
|
+
const flick = 0.86 + Math.sin(t * 3.5 + cell.seed * 6) * 0.14;
|
|
533
|
+
return Math.max(0, appear - vanish) * flick;
|
|
534
|
+
}
|
|
535
|
+
|
|
462
536
|
/**
|
|
463
537
|
* One cell's brightness: the body, plus whichever behaviours are up. Her voice
|
|
464
538
|
* hollows the body rather than replacing it, so there is nothing to dissolve
|
|
465
539
|
* between when she starts or stops.
|
|
466
540
|
*/
|
|
467
|
-
function fieldValue(
|
|
541
|
+
function fieldValue(
|
|
542
|
+
cell: Cell,
|
|
543
|
+
t: number,
|
|
544
|
+
errAge: number,
|
|
545
|
+
wordAge: number,
|
|
546
|
+
f: Field,
|
|
547
|
+
v: Voice,
|
|
548
|
+
) {
|
|
468
549
|
let value =
|
|
469
550
|
f.scale > 0.02
|
|
470
551
|
? idleValue(cell, t, f.lift, f.scale) * (1 - 0.6 * f.speech)
|
|
@@ -473,6 +554,7 @@ function fieldValue(cell: Cell, t: number, errAge: number, f: Field, v: Voice) {
|
|
|
473
554
|
if (f.comet > 0.01) value += cometValue(cell, t) * f.comet;
|
|
474
555
|
if (f.speech > 0.01) value += speechValue(cell, t, v, f.speech) * f.speech;
|
|
475
556
|
if (f.err > 0.01) value += errorValue(cell, t, errAge) * f.err;
|
|
557
|
+
if (f.word > 0.01) value += wordValue(cell, t, wordAge) * f.word;
|
|
476
558
|
return value;
|
|
477
559
|
}
|
|
478
560
|
|
|
@@ -486,6 +568,7 @@ export function AsciiOrb({
|
|
|
486
568
|
color = DEFAULT_COLOR,
|
|
487
569
|
getSpectrum,
|
|
488
570
|
getMicSpectrum,
|
|
571
|
+
word = null,
|
|
489
572
|
}: AsciiOrbProps) {
|
|
490
573
|
const hostRef = useRef<HTMLCanvasElement>(null);
|
|
491
574
|
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
|
@@ -539,8 +622,19 @@ export function AsciiOrb({
|
|
|
539
622
|
comet: 0,
|
|
540
623
|
speech: 0,
|
|
541
624
|
err: 0,
|
|
625
|
+
word: 0,
|
|
626
|
+
listen: 0,
|
|
542
627
|
});
|
|
543
628
|
|
|
629
|
+
/** The word being shown, when it started, and how many letters it has; its cells carry the rest */
|
|
630
|
+
const wordRef = useRef<{
|
|
631
|
+
text: string;
|
|
632
|
+
start: number;
|
|
633
|
+
letters: number;
|
|
634
|
+
} | null>(null);
|
|
635
|
+
/** Grid pitch in reference units, for laying a word onto cells that already exist */
|
|
636
|
+
const pitchRef = useRef({ cw: 1, ch: 1 });
|
|
637
|
+
|
|
544
638
|
// grid is rebuilt only when size or density changes
|
|
545
639
|
useEffect(() => {
|
|
546
640
|
const host = hostRef.current;
|
|
@@ -556,9 +650,10 @@ export function AsciiOrb({
|
|
|
556
650
|
// scale ERROR so it stays inside the box at any glyph size
|
|
557
651
|
const errScale = Math.max(
|
|
558
652
|
1,
|
|
559
|
-
Math.floor((DESIGN * 0.92) / (
|
|
653
|
+
Math.floor((DESIGN * 0.92) / (ERROR_ROWS[0].length * cwN)),
|
|
560
654
|
);
|
|
561
655
|
boxRef.current = size;
|
|
656
|
+
pitchRef.current = { cw: cwN, ch: chN };
|
|
562
657
|
|
|
563
658
|
const cells: Cell[] = [];
|
|
564
659
|
const cols = Math.ceil(size / cw);
|
|
@@ -573,7 +668,7 @@ export function AsciiOrb({
|
|
|
573
668
|
const dy = (y - size / 2) * norm;
|
|
574
669
|
const dist = Math.hypot(dx, dy);
|
|
575
670
|
|
|
576
|
-
const letter =
|
|
671
|
+
const letter = letterAt(ERROR_ROWS, dx, dy, cwN, chN, errScale);
|
|
577
672
|
if (letter < 0 && dist > FIELD_R) continue;
|
|
578
673
|
|
|
579
674
|
cells.push({
|
|
@@ -590,11 +685,14 @@ export function AsciiOrb({
|
|
|
590
685
|
emoji: hash(c * 2.7 + 31, r * 5.9 + 17) < EMOJI_RATIO,
|
|
591
686
|
speck: hash(c * 4.1 + 23, r * 6.3 + 41),
|
|
592
687
|
letter,
|
|
688
|
+
word: -1,
|
|
593
689
|
});
|
|
594
690
|
}
|
|
595
691
|
}
|
|
596
692
|
|
|
597
693
|
cellsRef.current = cells;
|
|
694
|
+
// a word showing while the grid changes is laid onto the new cells
|
|
695
|
+
if (wordRef.current) layWord(cells, cwN, chN, wordRef.current.text);
|
|
598
696
|
|
|
599
697
|
// bucketing by level keeps fillStyle changes to one per level
|
|
600
698
|
bucketsRef.current = {
|
|
@@ -650,6 +748,17 @@ export function AsciiOrb({
|
|
|
650
748
|
modeRef.current = { mode, start: performance.now() * 0.001 };
|
|
651
749
|
}, [mode]);
|
|
652
750
|
|
|
751
|
+
// a new word starts from its first letter, in place of one still showing
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
if (!word) return;
|
|
754
|
+
const { cw, ch } = pitchRef.current;
|
|
755
|
+
wordRef.current = {
|
|
756
|
+
text: word.text,
|
|
757
|
+
start: performance.now() * 0.001,
|
|
758
|
+
letters: layWord(cellsRef.current, cw, ch, word.text),
|
|
759
|
+
};
|
|
760
|
+
}, [word]);
|
|
761
|
+
|
|
653
762
|
// animation loop
|
|
654
763
|
useEffect(() => {
|
|
655
764
|
let raf = 0;
|
|
@@ -748,7 +857,25 @@ export function AsciiOrb({
|
|
|
748
857
|
|
|
749
858
|
// the field eases toward what this mode wants, each channel on its own clock
|
|
750
859
|
const cur = modeRef.current;
|
|
751
|
-
|
|
860
|
+
// a word takes the face from the mode until its last letter is out; an error keeps it
|
|
861
|
+
const shown = wordRef.current;
|
|
862
|
+
const wordAge = shown ? t - shown.start : 0;
|
|
863
|
+
const wording =
|
|
864
|
+
shown !== null &&
|
|
865
|
+
cur.mode !== "error" &&
|
|
866
|
+
wordAge < WORD_HOLD + shown.letters * WORD_STEP_OUT + WORD_FADE_OUT;
|
|
867
|
+
const want: Field = wording
|
|
868
|
+
? {
|
|
869
|
+
scale: 0,
|
|
870
|
+
lift: 0,
|
|
871
|
+
gather: 0,
|
|
872
|
+
comet: 0,
|
|
873
|
+
speech: 0,
|
|
874
|
+
err: 0,
|
|
875
|
+
word: 1,
|
|
876
|
+
listen: 0,
|
|
877
|
+
}
|
|
878
|
+
: targetFor(cur.mode, t - cur.start, voice);
|
|
752
879
|
const f = fieldRef.current;
|
|
753
880
|
f.scale = toward(
|
|
754
881
|
f.scale,
|
|
@@ -768,15 +895,20 @@ export function AsciiOrb({
|
|
|
768
895
|
errAge += dt;
|
|
769
896
|
}
|
|
770
897
|
f.err = toward(f.err, want.err, RISE.err, FALL.err, dt);
|
|
771
|
-
|
|
898
|
+
f.word = toward(f.word, want.word, RISE.word, FALL.word, dt);
|
|
899
|
+
f.listen = toward(f.listen, want.listen, RISE.listen, FALL.listen, dt);
|
|
900
|
+
const solidError = f.err > 0.5;
|
|
901
|
+
const solidWord = f.word > 0.5;
|
|
772
902
|
const rate = cs === "emojiOnly" ? EMOJI_CHAR_RATE : CHAR_RATE;
|
|
773
903
|
|
|
774
904
|
const all = cellsRef.current;
|
|
775
905
|
for (let ci = 0; ci < all.length; ci++) {
|
|
776
906
|
const cell = all[ci];
|
|
777
|
-
let v = fieldValue(cell, t, errAge, f, voice);
|
|
907
|
+
let v = fieldValue(cell, t, errAge, wordAge, f, voice);
|
|
778
908
|
|
|
779
|
-
if (
|
|
909
|
+
if (
|
|
910
|
+
!((solidError && cell.letter >= 0) || (solidWord && cell.word >= 0))
|
|
911
|
+
) {
|
|
780
912
|
// per-cell brightness response breaks concentric rings; multiplicative, so empty (0) stays empty
|
|
781
913
|
v *= 0.66 + cell.grain * 0.72;
|
|
782
914
|
// slowly drifting noise on top
|
|
@@ -795,7 +927,18 @@ export function AsciiOrb({
|
|
|
795
927
|
v = Math.max(v, 0.9);
|
|
796
928
|
}
|
|
797
929
|
|
|
798
|
-
const gate =
|
|
930
|
+
const gate =
|
|
931
|
+
cell.gap +
|
|
932
|
+
Math.sin(t * 0.28 + cell.seed * 6.283) * 0.05 +
|
|
933
|
+
f.listen *
|
|
934
|
+
(LISTEN_THIN +
|
|
935
|
+
(hash(
|
|
936
|
+
Math.floor(t * LISTEN_POP_RATE + cell.seed * 3),
|
|
937
|
+
cell.seed * 97,
|
|
938
|
+
) -
|
|
939
|
+
0.5) *
|
|
940
|
+
LISTEN_POP *
|
|
941
|
+
voice.micLevel);
|
|
799
942
|
if (v < gate) v = 0;
|
|
800
943
|
}
|
|
801
944
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import type { FaceKind } from "@/features/thursday/face.const";
|
|
14
14
|
import type {
|
|
15
15
|
CallStatus,
|
|
16
|
+
FaceWord,
|
|
16
17
|
ThursdayFace,
|
|
17
18
|
} from "@/features/thursday/thursday.schema";
|
|
18
19
|
import { useResolvedTheme } from "@/hooks/use-theme";
|
|
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
|
|
|
21
22
|
/**
|
|
22
23
|
* The call face. Two renderers with nothing in common inside (an SVG
|
|
23
24
|
* silhouette and a glyph canvas); the shared contract is CallStatus and `failed`,
|
|
24
|
-
* and each adapter maps them. A third face is one FaceKind, one state table, one FACES entry.
|
|
25
|
+
* and each adapter maps them. `word` is the orb's alone. A third face is one FaceKind, one state table, one FACES entry.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
export type FaceProps = {
|
|
@@ -29,6 +30,8 @@ export type FaceProps = {
|
|
|
29
30
|
status: CallStatus;
|
|
30
31
|
/** A call just failed to open or dropped. The status is idle by then; this says why the face is not at rest. */
|
|
31
32
|
failed?: boolean;
|
|
33
|
+
/** A word `emote` put on the face. Only the orb draws one; the call offers `emote` only with the orb. */
|
|
34
|
+
word?: FaceWord | null;
|
|
32
35
|
/** Box size in px, measured (see Face). */
|
|
33
36
|
size: number;
|
|
34
37
|
/** Audio bands, read once per animation frame, not through state. */
|
|
@@ -116,6 +119,7 @@ const ORB_LIGHT: [number, number, number] = [10, 10, 10];
|
|
|
116
119
|
function OrbFace({
|
|
117
120
|
status,
|
|
118
121
|
failed,
|
|
122
|
+
word,
|
|
119
123
|
size,
|
|
120
124
|
getSpectrum,
|
|
121
125
|
getMicSpectrum,
|
|
@@ -126,6 +130,7 @@ function OrbFace({
|
|
|
126
130
|
return (
|
|
127
131
|
<AsciiOrb
|
|
128
132
|
mode={failed ? "error" : ORB_MODE[status]}
|
|
133
|
+
word={word}
|
|
129
134
|
size={size}
|
|
130
135
|
color={dark ? ORB_DARK : ORB_LIGHT}
|
|
131
136
|
getSpectrum={getSpectrum}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { ShinyText } from "@/components/ui/shiny-text";
|
|
5
|
+
import type { CallMessage } from "@/features/thursday/thursday.schema";
|
|
6
|
+
import { capturesKeys } from "@/hooks/use-hotkey";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { ThursdayMark } from "./thursday-mark";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Side captions (Captions › sides): the conversation beside the face, one turn
|
|
12
|
+
* a side at the face's middle line and the rest receding around it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One speaker's words in a row, however many display groups Live cut them into. */
|
|
16
|
+
export type Turn = { id: string; role: CallMessage["role"]; text: string };
|
|
17
|
+
|
|
18
|
+
export function turnsOf(messages: CallMessage[]) {
|
|
19
|
+
const turns: Turn[] = [];
|
|
20
|
+
for (const message of messages) {
|
|
21
|
+
const last = turns.at(-1);
|
|
22
|
+
if (last?.role === message.role) last.text = `${last.text} ${message.text}`;
|
|
23
|
+
else turns.push({ ...message });
|
|
24
|
+
}
|
|
25
|
+
return turns;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Wheel delta per turn: a turn is a paragraph, so a trackpad flick moves one, not three. */
|
|
29
|
+
const WHEEL_STEP = 120;
|
|
30
|
+
|
|
31
|
+
/** How long the wheel keeps what it has gathered before starting over. */
|
|
32
|
+
const WHEEL_SETTLE_MS = 220;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which turn the side captions are on: the latest, or as far back as the arrow
|
|
36
|
+
* keys or the wheel took them. A new turn brings the focus back to now, as the
|
|
37
|
+
* centre caption does with new text. The keys are the window's while the
|
|
38
|
+
* captions are up, except in a field, a key capture or a dialog.
|
|
39
|
+
*/
|
|
40
|
+
export function useTurnFocus(turns: Turn[], enabled: boolean) {
|
|
41
|
+
const [back, setBack] = useState(0);
|
|
42
|
+
const count = useRef(turns.length);
|
|
43
|
+
const drag = useRef(0);
|
|
44
|
+
const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
45
|
+
const furthest = Math.max(0, turns.length - 1);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (turns.length === count.current) return;
|
|
49
|
+
count.current = turns.length;
|
|
50
|
+
setBack(0);
|
|
51
|
+
}, [turns.length]);
|
|
52
|
+
|
|
53
|
+
const step = useCallback(
|
|
54
|
+
(by: number) => setBack((was) => Math.min(furthest, Math.max(0, was + by))),
|
|
55
|
+
[furthest],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!enabled) return;
|
|
60
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
61
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
62
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey) return;
|
|
63
|
+
if (event.metaKey || event.shiftKey || capturesKeys(event.target)) return;
|
|
64
|
+
if ((event.target as HTMLElement | null)?.closest?.('[role="dialog"]'))
|
|
65
|
+
return;
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
step(event.key === "ArrowUp" ? 1 : -1);
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener("keydown", onKeyDown);
|
|
70
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
71
|
+
}, [enabled, step]);
|
|
72
|
+
|
|
73
|
+
useEffect(
|
|
74
|
+
() => () => {
|
|
75
|
+
if (settle.current) clearTimeout(settle.current);
|
|
76
|
+
},
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const onWheel = (event: React.WheelEvent) => {
|
|
81
|
+
drag.current += event.deltaY;
|
|
82
|
+
const steps = Math.trunc(drag.current / WHEEL_STEP);
|
|
83
|
+
if (steps !== 0) {
|
|
84
|
+
drag.current -= steps * WHEEL_STEP;
|
|
85
|
+
step(-steps);
|
|
86
|
+
}
|
|
87
|
+
if (settle.current) clearTimeout(settle.current);
|
|
88
|
+
settle.current = setTimeout(() => {
|
|
89
|
+
drag.current = 0;
|
|
90
|
+
}, WHEEL_SETTLE_MS);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { focus: Math.max(0, turns.length - 1 - back), back, onWheel };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How the turns around the focus recede (canvas "Thursday Call Polish" D3, user
|
|
98
|
+
* 09-17): each turn away steps back `DEPTH` px under `PERSPECTIVE`, so it draws
|
|
99
|
+
* smaller, softer and a little higher. These are the drawing, not settings.
|
|
100
|
+
*/
|
|
101
|
+
const SIDE_PERSPECTIVE = 1000;
|
|
102
|
+
const SIDE_DEPTH = 700;
|
|
103
|
+
/** Space between turns at the focus's size, and the extra that lifts earlier turns. */
|
|
104
|
+
const SIDE_GAP = 22;
|
|
105
|
+
const SIDE_RISE = 10;
|
|
106
|
+
const SIDE_BLUR = 1.1;
|
|
107
|
+
/** Ink by turns away on the focused side; the other side's turn level with the focus is `SIDE_PAIR`. */
|
|
108
|
+
const SIDE_FADE = [1, 0.46, 0.28, 0.16];
|
|
109
|
+
const SIDE_PAIR = 0.62;
|
|
110
|
+
/** Turns farther than this are not drawn. */
|
|
111
|
+
const SIDE_REACH = 3;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The conversation beside the face: her turns on the left, yours on the right,
|
|
115
|
+
* no plates. Each side holds its turn level with the focus at the face's middle
|
|
116
|
+
* — the focus itself, or that side's last turn before it — and the rest recede
|
|
117
|
+
* above and below. Out of focus a turn keeps three lines; the turn being said
|
|
118
|
+
* shines its name. Anchored outside the face box so nothing covers the face.
|
|
119
|
+
*/
|
|
120
|
+
export function SideCaptions({
|
|
121
|
+
turns,
|
|
122
|
+
focus,
|
|
123
|
+
live,
|
|
124
|
+
}: {
|
|
125
|
+
turns: Turn[];
|
|
126
|
+
focus: number;
|
|
127
|
+
/** The last turn is still being said. */
|
|
128
|
+
live: boolean;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<SideColumn turns={turns} focus={focus} live={live} role="assistant" />
|
|
133
|
+
<SideColumn turns={turns} focus={focus} live={live} role="user" />
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function SideColumn({
|
|
139
|
+
turns,
|
|
140
|
+
focus,
|
|
141
|
+
live,
|
|
142
|
+
role,
|
|
143
|
+
}: {
|
|
144
|
+
turns: Turn[];
|
|
145
|
+
focus: number;
|
|
146
|
+
live: boolean;
|
|
147
|
+
role: Turn["role"];
|
|
148
|
+
}) {
|
|
149
|
+
const mine = role === "user";
|
|
150
|
+
// Heights as laid out, before any transform: text grows while it is said, and
|
|
151
|
+
// leaving focus clamps a turn to three lines
|
|
152
|
+
const [heights, setHeights] = useState<Record<string, number>>({});
|
|
153
|
+
const watch = useRef<ResizeObserver | null>(null);
|
|
154
|
+
useEffect(() => () => watch.current?.disconnect(), []);
|
|
155
|
+
const measure = useCallback((node: HTMLDivElement | null) => {
|
|
156
|
+
if (!node) return;
|
|
157
|
+
watch.current ??= new ResizeObserver((entries) =>
|
|
158
|
+
setHeights((was) => {
|
|
159
|
+
let next = was;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const box = entry.target as HTMLElement;
|
|
162
|
+
const id = box.dataset.turn;
|
|
163
|
+
if (!id || was[id] === box.offsetHeight) continue;
|
|
164
|
+
if (next === was) next = { ...was };
|
|
165
|
+
next[id] = box.offsetHeight;
|
|
166
|
+
}
|
|
167
|
+
return next;
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
watch.current.observe(node);
|
|
171
|
+
return () => watch.current?.unobserve(node);
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
const own = turns.flatMap((turn, index) =>
|
|
175
|
+
turn.role === role ? [{ turn, index }] : [],
|
|
176
|
+
);
|
|
177
|
+
if (!own.length) return null;
|
|
178
|
+
const focused = turns[focus]?.role === role;
|
|
179
|
+
const level = focused
|
|
180
|
+
? focus
|
|
181
|
+
: (own.findLast((one) => one.index <= focus) ?? own[0]).index;
|
|
182
|
+
const at = own.findIndex((one) => one.index === level);
|
|
183
|
+
const scale = (k: number) =>
|
|
184
|
+
SIDE_PERSPECTIVE / (SIDE_PERSPECTIVE + SIDE_DEPTH * Math.abs(k - at));
|
|
185
|
+
const tall = (k: number) => heights[own[k].turn.id] ?? 42;
|
|
186
|
+
// Stacked by their drawn size, then placed back by it: perspective pulls each toward the middle line
|
|
187
|
+
const drawn = own.map(() => 0);
|
|
188
|
+
for (let k = at - 1; k >= 0; k--) {
|
|
189
|
+
drawn[k] =
|
|
190
|
+
drawn[k + 1] -
|
|
191
|
+
((tall(k) * scale(k)) / 2 +
|
|
192
|
+
SIDE_GAP * scale(k + 1) +
|
|
193
|
+
SIDE_RISE +
|
|
194
|
+
(tall(k + 1) * scale(k + 1)) / 2);
|
|
195
|
+
}
|
|
196
|
+
for (let k = at + 1; k < own.length; k++) {
|
|
197
|
+
drawn[k] =
|
|
198
|
+
drawn[k - 1] +
|
|
199
|
+
((tall(k - 1) * scale(k - 1)) / 2 +
|
|
200
|
+
SIDE_GAP * scale(k - 1) +
|
|
201
|
+
(tall(k) * scale(k)) / 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div
|
|
206
|
+
className={cn(
|
|
207
|
+
"pointer-events-none absolute top-1/2 h-0 w-[min(25rem,24vw)] [perspective:1000px]",
|
|
208
|
+
// the vanishing point is the face's edge on the middle line: what recedes goes toward her
|
|
209
|
+
mine
|
|
210
|
+
? "left-full ml-11 [perspective-origin:0_0]"
|
|
211
|
+
: "right-full mr-11 [perspective-origin:100%_0]",
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{own.map(({ turn, index }, k) => {
|
|
215
|
+
const away = Math.abs(k - at);
|
|
216
|
+
const fade = SIDE_FADE[away] ?? 0;
|
|
217
|
+
const ink =
|
|
218
|
+
index === focus
|
|
219
|
+
? 1
|
|
220
|
+
: focused
|
|
221
|
+
? fade
|
|
222
|
+
: index === level
|
|
223
|
+
? SIDE_PAIR
|
|
224
|
+
: fade * 0.8;
|
|
225
|
+
const name = mine ? "You" : "Thursday";
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
key={turn.id}
|
|
229
|
+
ref={measure}
|
|
230
|
+
data-turn={turn.id}
|
|
231
|
+
aria-hidden={away > SIDE_REACH}
|
|
232
|
+
style={{
|
|
233
|
+
transform: `translateY(-50%) translateY(${drawn[k] / scale(k)}px) translateZ(${-SIDE_DEPTH * away}px)`,
|
|
234
|
+
opacity: away > SIDE_REACH ? 0 : ink,
|
|
235
|
+
filter: away ? `blur(${away * SIDE_BLUR}px)` : undefined,
|
|
236
|
+
}}
|
|
237
|
+
className={cn(
|
|
238
|
+
"absolute top-0 w-max max-w-full text-base leading-[26px] text-foreground transition-[transform,opacity,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
|
|
239
|
+
away > SIDE_REACH ? "invisible" : "pointer-events-auto",
|
|
240
|
+
mine ? "left-0" : "right-0",
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
<div
|
|
244
|
+
className={cn(
|
|
245
|
+
"mb-1.5 flex h-4 items-center gap-1.5 text-xs font-medium",
|
|
246
|
+
!mine && "justify-end",
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
{mine ? (
|
|
250
|
+
<span className="size-2 rounded-full ring-[1.5px] ring-foreground ring-inset" />
|
|
251
|
+
) : (
|
|
252
|
+
<ThursdayMark size={14} />
|
|
253
|
+
)}
|
|
254
|
+
{live && index === turns.length - 1 ? (
|
|
255
|
+
<ShinyText text={name} />
|
|
256
|
+
) : (
|
|
257
|
+
<span>{name}</span>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
<p
|
|
261
|
+
className={cn(
|
|
262
|
+
"break-keep text-pretty",
|
|
263
|
+
index !== focus && "line-clamp-3",
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{turn.text}
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
})}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|