@backmatter/studio 0.0.7 → 0.0.9
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/dist/chunks/UiKitApp-b8qFsmA-.js +2401 -0
- package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
- package/dist/chunks/{dashboard-DCHRI0WD.js → dashboard-BNUcbYU0.js} +380 -382
- package/dist/chunks/{index-BS0-Bvbk.js → index-laWAz5_p.js} +111 -112
- package/dist/dashboard.js +2 -2
- package/dist/index.js +287 -146
- package/dist/site-widget.css +1615 -0
- package/dist/site-widget.js +30896 -630
- package/dist/studio.css +303 -98
- package/package.json +4 -4
- package/src/App.tsx +12 -29
- package/src/api.ts +55 -0
- package/src/chat/ChangeCard.tsx +2 -0
- package/src/chat/ChatMessage.tsx +73 -62
- package/src/chat/ChatPanel.tsx +110 -15
- package/src/chat/DocListCard.tsx +2 -0
- package/src/chat/LiveStatus.tsx +78 -8
- package/src/chat/Reveal.tsx +34 -0
- package/src/chat/Shimmer.tsx +65 -23
- package/src/chat/chat-helpers.ts +35 -0
- package/src/chat/motion.ts +73 -0
- package/src/chat/useStickToBottom.ts +129 -14
- package/src/chrome/AppShell.tsx +47 -10
- package/src/chrome/AppSidebar.tsx +9 -34
- package/src/chrome/ContentExplorerSidebar.tsx +149 -96
- package/src/chrome/UserMenu.tsx +3 -1
- package/src/chrome/content-tree-visibility.ts +55 -0
- package/src/chrome/explorer-icons.tsx +2 -4
- package/src/devbar-state.ts +125 -0
- package/src/index.css +3 -131
- package/src/pages/CliAuthorizePage.tsx +61 -28
- package/src/pages/MediaGalleryPage.tsx +144 -85
- package/src/pages/MediaLightbox.tsx +195 -96
- package/src/pages/SelectRepositoriesLink.tsx +28 -4
- package/src/pages/SettingsGeneralPage.tsx +25 -28
- package/src/pages/SettingsGithubPage.tsx +413 -124
- package/src/pages/SettingsUsagePage.tsx +23 -1
- package/src/pages/media-grid.ts +14 -1
- package/src/pages/ui-kit/UiKitApp.tsx +164 -0
- package/src/pages/ui-kit/buttons.tsx +30 -0
- package/src/pages/ui-kit/catalog.tsx +41 -0
- package/src/pages/ui-kit/colors.tsx +95 -0
- package/src/pages/ui-kit/devbar.tsx +160 -0
- package/src/pages/ui-kit/feedback.tsx +39 -0
- package/src/pages/ui-kit/fields.tsx +47 -0
- package/src/pages/ui-kit/lists.tsx +19 -0
- package/src/pages/ui-kit/overlays.tsx +39 -0
- package/src/pages/ui-kit/path.ts +9 -0
- package/src/pages/ui-kit/sections.tsx +32 -0
- package/src/pages/ui-kit/surfaces.tsx +121 -0
- package/src/pages/ui-kit/topic.tsx +112 -0
- package/src/pages/ui-kit/topics.ts +71 -0
- package/src/pages/ui-kit/type.tsx +55 -0
- package/src/preview-frame-radius.ts +51 -0
- package/src/preview-frame.tsx +93 -49
- package/src/shell-resets.css +155 -0
- package/src/site-bar.tsx +1791 -425
- package/src/site-dashboard-href.ts +21 -0
- package/src/site-devbar-overlay-inject.ts +15 -0
- package/src/site-devbar-overlay.css +217 -0
- package/src/site-pointer.ts +1 -1
- package/src/site-publish-menu.tsx +33 -0
- package/src/site-widget-styles.ts +7 -0
- package/src/site-widget.tsx +61 -21
- package/src/studio-prose-ui.css +39 -0
- package/src/theme/ThemeProvider.tsx +4 -1
- package/src/workspace-path.ts +18 -1
- package/dist/chunks/EmbedPublishPage-88VsmOhi.js +0 -149
- package/dist/chunks/UiKitPage-X4hkFL2d.js +0 -173
- package/dist/chunks/WorkspaceRoutes-DXzdWqN3.js +0 -59523
- package/dist/chunks/status-bar-action-wj1KZ51m.js +0 -30612
- package/src/pages/UiKitPage.tsx +0 -331
package/src/chat/LiveStatus.tsx
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
// One-line live status for a chat turn: shimmering label that tracks the
|
|
4
4
|
// current thought or tool, plus an elapsed timer. Stays a single row so
|
|
5
5
|
// thinking / acting doesn't stack into a transcript of its own.
|
|
6
|
-
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
7
7
|
import * as stylex from "@stylexjs/stylex";
|
|
8
8
|
import type { UIMessage } from "ai";
|
|
9
9
|
import { colors, space, type } from "../styles/tokens.stylex";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
delayThinkingReturn,
|
|
12
|
+
formatElapsed,
|
|
13
|
+
liveActivity,
|
|
14
|
+
THINKING_RETURN_MS,
|
|
15
|
+
type LiveActivity,
|
|
16
|
+
} from "./chat-helpers";
|
|
11
17
|
import { SparkIcon } from "./icons";
|
|
18
|
+
import { motion } from "./motion";
|
|
12
19
|
import { Shimmer } from "./Shimmer";
|
|
13
20
|
|
|
14
21
|
export function LiveStatus({
|
|
@@ -19,8 +26,48 @@ export function LiveStatus({
|
|
|
19
26
|
status: "submitted" | "streaming" | "ready" | "error";
|
|
20
27
|
}) {
|
|
21
28
|
const activity = liveActivity(parts, status);
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const shown = useShownActivity(activity, status);
|
|
30
|
+
if (!shown) return null;
|
|
31
|
+
return <LiveStatusLine activity={shown} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keeps the first Thinking immediate. After the answer has taken the row,
|
|
36
|
+
* a return of Thinking waits so the end of the turn can't flash it.
|
|
37
|
+
*/
|
|
38
|
+
function useShownActivity(
|
|
39
|
+
activity: LiveActivity | null,
|
|
40
|
+
status: "submitted" | "streaming" | "ready" | "error",
|
|
41
|
+
): LiveActivity | null {
|
|
42
|
+
const [shown, setShown] = useState<LiveActivity | null>(activity);
|
|
43
|
+
const answerTookOver = useRef(false);
|
|
44
|
+
const latest = useRef(activity);
|
|
45
|
+
latest.current = activity;
|
|
46
|
+
const key = activity ? `${activity.kind}:${activity.label}` : "";
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const next = latest.current;
|
|
50
|
+
if (!next) {
|
|
51
|
+
// The answer is on screen while the turn is still live. A Thinking
|
|
52
|
+
// line that appears in that gap is the end-of-turn flicker.
|
|
53
|
+
answerTookOver.current = status === "streaming" || status === "submitted";
|
|
54
|
+
setShown(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (delayThinkingReturn(status, next, answerTookOver.current)) {
|
|
58
|
+
const id = window.setTimeout(() => {
|
|
59
|
+
const pending = latest.current;
|
|
60
|
+
if (!pending || pending.kind !== "thinking") return;
|
|
61
|
+
answerTookOver.current = false;
|
|
62
|
+
setShown(pending);
|
|
63
|
+
}, THINKING_RETURN_MS);
|
|
64
|
+
return () => window.clearTimeout(id);
|
|
65
|
+
}
|
|
66
|
+
answerTookOver.current = false;
|
|
67
|
+
setShown(next);
|
|
68
|
+
}, [key, status]);
|
|
69
|
+
|
|
70
|
+
return shown;
|
|
24
71
|
}
|
|
25
72
|
|
|
26
73
|
function activityKey(activity: LiveActivity) {
|
|
@@ -40,26 +87,49 @@ function LiveStatusLine({ activity }: { activity: LiveActivity }) {
|
|
|
40
87
|
}, []);
|
|
41
88
|
|
|
42
89
|
return (
|
|
43
|
-
<p {...stylex.props(styles.row)} aria-live="polite" aria-atomic="true">
|
|
90
|
+
<p {...stylex.props(styles.row, motion.settle)} aria-live="polite" aria-atomic="true">
|
|
44
91
|
<span {...stylex.props(styles.icon)}>
|
|
45
92
|
<SparkIcon size={12} />
|
|
46
93
|
</span>
|
|
47
|
-
<
|
|
94
|
+
<ActivityLabel kind={activity.kind}>
|
|
48
95
|
<Shimmer>{activity.label}</Shimmer>
|
|
49
|
-
</
|
|
96
|
+
</ActivityLabel>
|
|
50
97
|
<span {...stylex.props(styles.time)}>{formatElapsed(now - started.current.at)}</span>
|
|
51
98
|
</p>
|
|
52
99
|
);
|
|
53
100
|
}
|
|
54
101
|
|
|
102
|
+
/** Fades when the kind of activity changes. The first paint rides the row entrance. */
|
|
103
|
+
function ActivityLabel({
|
|
104
|
+
kind,
|
|
105
|
+
children,
|
|
106
|
+
}: {
|
|
107
|
+
kind: LiveActivity["kind"];
|
|
108
|
+
children: ReactNode;
|
|
109
|
+
}) {
|
|
110
|
+
const [prevKind, setPrevKind] = useState(kind);
|
|
111
|
+
const [swap, setSwap] = useState(false);
|
|
112
|
+
if (kind !== prevKind) {
|
|
113
|
+
setPrevKind(kind);
|
|
114
|
+
setSwap(true);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<span key={kind} {...stylex.props(styles.label, swap && motion.fade)}>
|
|
118
|
+
{children}
|
|
119
|
+
</span>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
55
123
|
const styles = stylex.create({
|
|
56
124
|
row: {
|
|
57
125
|
display: "flex",
|
|
58
126
|
alignItems: "center",
|
|
59
127
|
gap: space.sm,
|
|
60
|
-
|
|
128
|
+
height: 20,
|
|
61
129
|
margin: 0,
|
|
130
|
+
overflow: "hidden",
|
|
62
131
|
fontSize: type.sm,
|
|
132
|
+
lineHeight: "20px",
|
|
63
133
|
color: colors.textMuted,
|
|
64
134
|
},
|
|
65
135
|
icon: {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/studio/src/chat/Reveal.tsx
|
|
2
|
+
//
|
|
3
|
+
// Plays the chat entrance once, on mount. Passed `active` false (history,
|
|
4
|
+
// a settled turn) it renders children with no wrapper motion. A later render
|
|
5
|
+
// does not replay it.
|
|
6
|
+
import { useState, type ReactNode } from "react";
|
|
7
|
+
import * as stylex from "@stylexjs/stylex";
|
|
8
|
+
import { motion } from "./motion";
|
|
9
|
+
|
|
10
|
+
export function Reveal({
|
|
11
|
+
active,
|
|
12
|
+
origin,
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
active: boolean;
|
|
16
|
+
origin: "start" | "end";
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
const [play] = useState(active);
|
|
20
|
+
if (!play) return children;
|
|
21
|
+
const fromEnd = origin === "end";
|
|
22
|
+
const fromStart = origin === "start";
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
{...stylex.props(
|
|
26
|
+
motion.rise,
|
|
27
|
+
fromEnd && motion.originEnd,
|
|
28
|
+
fromStart && motion.originStart,
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/chat/Shimmer.tsx
CHANGED
|
@@ -1,39 +1,81 @@
|
|
|
1
1
|
// packages/studio/src/chat/Shimmer.tsx
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// The same treatment as the Linear homepage hero: the word stays put, and a
|
|
4
|
+
// soft diagonal beam travels across the letters. The beam is a separate layer,
|
|
5
|
+
// masked to the glyphs, so the word itself never moves or changes size.
|
|
6
|
+
// It crosses during the first three quarters of the loop, then rests fully
|
|
7
|
+
// past the letters. The loop starts and ends in that same off-screen spot,
|
|
8
|
+
// so the restart is invisible. Linear, because a sweep is constant speed.
|
|
5
9
|
import type { ReactNode } from "react";
|
|
6
10
|
import * as stylex from "@stylexjs/stylex";
|
|
7
11
|
import { colors } from "../styles/tokens.stylex";
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
// 180×72, matching the hero. A narrow ink bar, rotated and blurred, so the
|
|
14
|
+
// pass is a soft streak rather than a hard edge. Ink, because Studio is light:
|
|
15
|
+
// the streak brings muted letters up to the text color as it passes.
|
|
16
|
+
const beam =
|
|
17
|
+
'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 180 72\' fill=\'none\'%3E%3Cdefs%3E%3Cfilter id=\'b\' x=\'-100%25\' y=\'-100%25\' width=\'300%25\' height=\'300%25\'%3E%3CfeGaussianBlur stdDeviation=\'6\'/%3E%3C/filter%3E%3C/defs%3E%3Cg transform=\'rotate(-30 90 36)\' filter=\'url(%23b)\'%3E%3Crect x=\'72\' y=\'18\' width=\'36\' height=\'36\' fill=\'%2318181b\' fill-opacity=\'.45\'/%3E%3Crect x=\'84\' y=\'16\' width=\'12\' height=\'40\' fill=\'%2318181b\'/%3E%3C/g%3E%3C/svg%3E")';
|
|
18
|
+
|
|
19
|
+
const glide = stylex.keyframes({
|
|
20
|
+
"0%, 100%": { transform: "translateX(0.001%)" },
|
|
21
|
+
"75%, 99.999%": { transform: "translateX(100%)" },
|
|
12
22
|
});
|
|
13
23
|
|
|
14
24
|
export function Shimmer({ children }: { children: ReactNode }) {
|
|
15
|
-
return
|
|
25
|
+
return (
|
|
26
|
+
<span {...stylex.props(styles.word)}>
|
|
27
|
+
{children}
|
|
28
|
+
<span aria-hidden {...stylex.props(styles.beam)}>
|
|
29
|
+
{children}
|
|
30
|
+
</span>
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
16
33
|
}
|
|
17
34
|
|
|
18
35
|
const styles = stylex.create({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
word: {
|
|
37
|
+
position: "relative",
|
|
38
|
+
display: "inline-block",
|
|
39
|
+
maxWidth: "100%",
|
|
40
|
+
overflow: "hidden",
|
|
41
|
+
textOverflow: "ellipsis",
|
|
42
|
+
whiteSpace: "nowrap",
|
|
43
|
+
verticalAlign: "bottom",
|
|
44
|
+
color: colors.textMuted,
|
|
45
|
+
isolation: "isolate",
|
|
46
|
+
},
|
|
47
|
+
beam: {
|
|
48
|
+
position: "absolute",
|
|
49
|
+
inset: 0,
|
|
50
|
+
color: "transparent",
|
|
51
|
+
WebkitTextFillColor: "transparent",
|
|
52
|
+
pointerEvents: "none",
|
|
53
|
+
userSelect: "none",
|
|
54
|
+
contain: "paint",
|
|
55
|
+
// The glyphs are the mask. The moving layer is the ::after.
|
|
56
|
+
maskImage: "linear-gradient(#000, #000)",
|
|
57
|
+
WebkitMaskImage: "linear-gradient(#000, #000)",
|
|
58
|
+
maskClip: "text",
|
|
59
|
+
WebkitMaskClip: "text",
|
|
60
|
+
"::after": {
|
|
61
|
+
content: '""',
|
|
62
|
+
position: "absolute",
|
|
63
|
+
top: 0,
|
|
64
|
+
left: "-180px",
|
|
65
|
+
width: "calc(100% + 180px)",
|
|
66
|
+
height: "100%",
|
|
67
|
+
backgroundImage: beam,
|
|
68
|
+
backgroundSize: "180px 72px",
|
|
69
|
+
backgroundRepeat: "no-repeat",
|
|
70
|
+
backgroundPosition: "left center",
|
|
71
|
+
animationName: glide,
|
|
72
|
+
animationDuration: "2.4s",
|
|
73
|
+
animationTimingFunction: "linear",
|
|
74
|
+
animationIterationCount: "infinite",
|
|
75
|
+
pointerEvents: "none",
|
|
27
76
|
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
WebkitBackgroundClip: "text",
|
|
31
|
-
animationName: {
|
|
32
|
-
default: sweep,
|
|
33
|
-
"@media (prefers-reduced-motion: reduce)": "none",
|
|
77
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
78
|
+
display: "none",
|
|
34
79
|
},
|
|
35
|
-
animationDuration: "1.8s",
|
|
36
|
-
animationTimingFunction: "linear",
|
|
37
|
-
animationIterationCount: "infinite",
|
|
38
80
|
},
|
|
39
81
|
});
|
package/src/chat/chat-helpers.ts
CHANGED
|
@@ -142,6 +142,41 @@ export function liveActivity(
|
|
|
142
142
|
return { kind: "thinking", label: "Thinking" };
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Whether a reply has anything to put in the transcript. Reasoning stays on
|
|
147
|
+
* the status line, and a reply that only has that must not take a row — the
|
|
148
|
+
* list gap would shove the thinking line down.
|
|
149
|
+
*/
|
|
150
|
+
export function assistantShowsRow(parts: UIMessage["parts"] | undefined): boolean {
|
|
151
|
+
for (const part of parts ?? []) {
|
|
152
|
+
if (part.type === "text" && part.text.trim()) return true;
|
|
153
|
+
if (part.type === "reasoning") continue;
|
|
154
|
+
if (
|
|
155
|
+
isToolUIPart(part) &&
|
|
156
|
+
(part.state === "output-available" || part.state === "output-error")
|
|
157
|
+
) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wait before bringing "Thinking" back after the answer has taken the row.
|
|
166
|
+
* The text part finishes a moment before the turn does, and mounting the
|
|
167
|
+
* indicator for that gap jumps the thread. A real pause still shows it.
|
|
168
|
+
*/
|
|
169
|
+
export const THINKING_RETURN_MS = 450;
|
|
170
|
+
|
|
171
|
+
export function delayThinkingReturn(
|
|
172
|
+
status: "submitted" | "streaming" | "ready" | "error",
|
|
173
|
+
next: LiveActivity | null,
|
|
174
|
+
answerTookOver: boolean,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (status !== "submitted" && status !== "streaming") return false;
|
|
177
|
+
return answerTookOver && next?.kind === "thinking";
|
|
178
|
+
}
|
|
179
|
+
|
|
145
180
|
export function mcpToolLabel(
|
|
146
181
|
name: string,
|
|
147
182
|
input: unknown,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// packages/studio/src/chat/motion.ts
|
|
2
|
+
//
|
|
3
|
+
// One entrance for chat inserts: a new message, a tool row, the live status
|
|
4
|
+
// line. Ease-out so it starts immediately and settles. The travel stays short
|
|
5
|
+
// so it doesn't fight the stick-to-bottom scroll. Status text that replaces
|
|
6
|
+
// itself (Thinking → a tool) only fades — same curve, shorter, no movement.
|
|
7
|
+
import * as stylex from "@stylexjs/stylex";
|
|
8
|
+
|
|
9
|
+
/** Entering elements. Matches the dev bar. */
|
|
10
|
+
const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
11
|
+
|
|
12
|
+
const rise = stylex.keyframes({
|
|
13
|
+
from: { opacity: 0, transform: "translateY(6px) scale(0.98)" },
|
|
14
|
+
to: { opacity: 1, transform: "translateY(0) scale(1)" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const fade = stylex.keyframes({
|
|
18
|
+
from: { opacity: 0 },
|
|
19
|
+
to: { opacity: 1 },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// The live status line settles in place. No travel: a translate here reads as
|
|
23
|
+
// the thinking row jumping. Blur clears with the fade, same as the hero chat.
|
|
24
|
+
const settle = stylex.keyframes({
|
|
25
|
+
from: { opacity: 0, filter: "blur(2px)" },
|
|
26
|
+
// A zero blur compiles to `blur()` and no longer matches `blur(2px)`,
|
|
27
|
+
// so the fade would snap. A thousandth of a pixel is still sharp.
|
|
28
|
+
to: { opacity: 1, filter: "blur(0.001px)" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const motion = stylex.create({
|
|
32
|
+
rise: {
|
|
33
|
+
animationName: rise,
|
|
34
|
+
animationDuration: "200ms",
|
|
35
|
+
animationTimingFunction: EASE_OUT,
|
|
36
|
+
// `backwards` holds the first frame only. `forwards` would leave
|
|
37
|
+
// scale(1) on the element and soften the text after it settles.
|
|
38
|
+
animationFillMode: "backwards",
|
|
39
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
40
|
+
animationName: "none",
|
|
41
|
+
animationDuration: "0s",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
fade: {
|
|
45
|
+
animationName: fade,
|
|
46
|
+
animationDuration: "120ms",
|
|
47
|
+
animationTimingFunction: EASE_OUT,
|
|
48
|
+
animationFillMode: "backwards",
|
|
49
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
50
|
+
animationName: "none",
|
|
51
|
+
animationDuration: "0s",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
settle: {
|
|
55
|
+
animationName: settle,
|
|
56
|
+
animationDuration: "200ms",
|
|
57
|
+
animationTimingFunction: EASE_OUT,
|
|
58
|
+
// Backwards only. Leaving `blur(0)` on the element keeps the text soft.
|
|
59
|
+
animationFillMode: "backwards",
|
|
60
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
61
|
+
animationName: "none",
|
|
62
|
+
animationDuration: "0s",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
// User bubbles grow from the composer corner.
|
|
66
|
+
originEnd: {
|
|
67
|
+
transformOrigin: "100% 100%",
|
|
68
|
+
},
|
|
69
|
+
// Assistant text and status grow from the leading edge.
|
|
70
|
+
originStart: {
|
|
71
|
+
transformOrigin: "0% 100%",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -1,24 +1,98 @@
|
|
|
1
1
|
// packages/studio/src/chat/useStickToBottom.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// Follows the live edge while the reader is already there, easing instead of
|
|
4
|
+
// jumping. A newly sent message re-engages that follow and rises with a short
|
|
5
|
+
// ease-out, so the thread moves up instead of snapping. The tail (see
|
|
6
|
+
// turnSpacer) makes the destination the sent message near the top. Later
|
|
7
|
+
// growth still eases. Scrolling up lets go. Scrolling back down re-engages.
|
|
8
|
+
import { useCallback, useEffect, useRef, type RefObject, type UIEvent } from "react";
|
|
7
9
|
|
|
8
|
-
/** Fraction of the remaining distance covered per frame
|
|
10
|
+
/** Fraction of the remaining distance covered per frame while streaming. */
|
|
9
11
|
const EASE = 0.18;
|
|
12
|
+
/** A sent message travels up quickly. Ease-out quint, under 300ms. */
|
|
13
|
+
const SEND_MS = 220;
|
|
10
14
|
/** Distance from the bottom (px) still considered "at the bottom". */
|
|
11
15
|
const STICKY_THRESHOLD = 48;
|
|
16
|
+
/** How much of the previous turn stays visible above a newly sent message. */
|
|
17
|
+
export const TURN_PEEK = 64;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extra tail under the transcript so a sent message can sit `peek` px from the
|
|
21
|
+
* top while the scroller is at its bottom. The reply grows into that tail;
|
|
22
|
+
* once the tail is used up, the bottom is the real end again.
|
|
23
|
+
*/
|
|
24
|
+
export function turnSpacer({
|
|
25
|
+
clientHeight,
|
|
26
|
+
anchorOffset,
|
|
27
|
+
contentEnd,
|
|
28
|
+
paddingBottom,
|
|
29
|
+
peek,
|
|
30
|
+
}: {
|
|
31
|
+
clientHeight: number;
|
|
32
|
+
/** Scroll-content y of the anchor message's top. */
|
|
33
|
+
anchorOffset: number;
|
|
34
|
+
/** Scroll-content y where the tail starts (end of messages, before padding). */
|
|
35
|
+
contentEnd: number;
|
|
36
|
+
paddingBottom: number;
|
|
37
|
+
peek: number;
|
|
38
|
+
}): number {
|
|
39
|
+
const desired = Math.max(0, anchorOffset - peek);
|
|
40
|
+
return Math.max(0, desired + clientHeight - contentEnd - paddingBottom);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where a follow should land. The tail can be taller than the sent message's
|
|
45
|
+
* slot for a moment; chasing that bottom scrolls past the slot and then
|
|
46
|
+
* snaps back when the tail shrinks.
|
|
47
|
+
*/
|
|
48
|
+
export function scrollTarget(max: number, slot: number | null): number {
|
|
49
|
+
if (slot == null) return max;
|
|
50
|
+
return Math.min(max, Math.max(0, slot));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** cubic-bezier(0.23, 1, 0.32, 1). Same curve as the message entrance. */
|
|
54
|
+
function easeOutQuint(t: number): number {
|
|
55
|
+
if (t <= 0) return 0;
|
|
56
|
+
if (t >= 1) return 1;
|
|
57
|
+
let u = t;
|
|
58
|
+
for (let i = 0; i < 6; i++) {
|
|
59
|
+
const x = bezier(u, 0.23, 0.32) - t;
|
|
60
|
+
const dx = bezierSlope(u, 0.23, 0.32);
|
|
61
|
+
if (Math.abs(dx) < 1e-6) break;
|
|
62
|
+
u = Math.min(1, Math.max(0, u - x / dx));
|
|
63
|
+
}
|
|
64
|
+
return bezier(u, 1, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function bezier(u: number, p1: number, p2: number): number {
|
|
68
|
+
const v = 1 - u;
|
|
69
|
+
return 3 * v * v * u * p1 + 3 * v * u * u * p2 + u * u * u;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function bezierSlope(u: number, p1: number, p2: number): number {
|
|
73
|
+
const v = 1 - u;
|
|
74
|
+
return 3 * v * v * p1 + 6 * v * u * (p2 - p1) + 3 * u * u * (1 - p2);
|
|
75
|
+
}
|
|
12
76
|
|
|
13
77
|
export function useStickToBottom<T extends HTMLElement>({
|
|
14
78
|
active,
|
|
15
79
|
resetKey,
|
|
80
|
+
pinKey,
|
|
81
|
+
slotRef,
|
|
16
82
|
deps,
|
|
17
83
|
}: {
|
|
18
84
|
/** Whether the container is mounted/visible. Becoming active jumps instantly. */
|
|
19
85
|
active: boolean;
|
|
20
86
|
/** Changing this (e.g. thread id) jumps instantly instead of animating. */
|
|
21
87
|
resetKey: unknown;
|
|
88
|
+
/** Changing this (a newly sent message) re-engages follow with a quick rise. */
|
|
89
|
+
pinKey?: unknown;
|
|
90
|
+
/**
|
|
91
|
+
* Scroll offset where the sent message should sit. While set, follow will
|
|
92
|
+
* not travel past it. Null follows the real bottom (a reply that has filled
|
|
93
|
+
* the viewport, or no pinned turn).
|
|
94
|
+
*/
|
|
95
|
+
slotRef?: RefObject<number | null>;
|
|
22
96
|
/** Re-evaluate when any of these change (messages, status…). */
|
|
23
97
|
deps: readonly unknown[];
|
|
24
98
|
}) {
|
|
@@ -27,6 +101,7 @@ export function useStickToBottom<T extends HTMLElement>({
|
|
|
27
101
|
const frame = useRef<number | null>(null);
|
|
28
102
|
const lastActive = useRef(false);
|
|
29
103
|
const lastKey = useRef<unknown>(resetKey);
|
|
104
|
+
const lastPin = useRef(pinKey);
|
|
30
105
|
|
|
31
106
|
const stop = useCallback(() => {
|
|
32
107
|
if (frame.current != null) cancelAnimationFrame(frame.current);
|
|
@@ -34,25 +109,63 @@ export function useStickToBottom<T extends HTMLElement>({
|
|
|
34
109
|
}, []);
|
|
35
110
|
|
|
36
111
|
const follow = useCallback(
|
|
37
|
-
(
|
|
112
|
+
(mode: "instant" | "send" | "ease") => {
|
|
38
113
|
const el = ref.current;
|
|
39
114
|
if (!el || !stick.current) return;
|
|
40
115
|
const reduceMotion =
|
|
41
116
|
typeof matchMedia === "function" &&
|
|
42
117
|
matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
43
|
-
if (instant || reduceMotion) {
|
|
118
|
+
if (mode === "instant" || reduceMotion) {
|
|
44
119
|
stop();
|
|
45
120
|
el.scrollTop = el.scrollHeight;
|
|
46
121
|
return;
|
|
47
122
|
}
|
|
48
|
-
|
|
123
|
+
// A send replaces whatever chase is in flight. Streaming growth leaves
|
|
124
|
+
// a running send alone; that loop already tracks the moving target.
|
|
125
|
+
if (mode === "ease" && frame.current != null) return;
|
|
126
|
+
// The turn is already in its slot. Reply text grows into the tail;
|
|
127
|
+
// chasing a new measurement here moves the whole thread.
|
|
128
|
+
if (mode === "ease" && slotRef?.current != null) return;
|
|
129
|
+
stop();
|
|
130
|
+
if (mode === "send") {
|
|
131
|
+
const from = el.scrollTop;
|
|
132
|
+
const start = performance.now();
|
|
133
|
+
// Frozen at the slot. A tail that is still too tall must not become
|
|
134
|
+
// the destination, or the thread rises past it and snaps back.
|
|
135
|
+
const dest = scrollTarget(
|
|
136
|
+
Math.max(0, el.scrollHeight - el.clientHeight),
|
|
137
|
+
slotRef?.current ?? null,
|
|
138
|
+
);
|
|
139
|
+
const step = (ts: number) => {
|
|
140
|
+
const node = ref.current;
|
|
141
|
+
if (!node || !stick.current) {
|
|
142
|
+
stop();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const max = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
146
|
+
const target = Math.min(dest, max);
|
|
147
|
+
const t = Math.min(1, (ts - start) / SEND_MS);
|
|
148
|
+
node.scrollTop = from + (target - from) * easeOutQuint(t);
|
|
149
|
+
if (t < 1) {
|
|
150
|
+
frame.current = requestAnimationFrame(step);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
node.scrollTop = target;
|
|
154
|
+
stop();
|
|
155
|
+
};
|
|
156
|
+
frame.current = requestAnimationFrame(step);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
49
159
|
const step = () => {
|
|
50
160
|
const node = ref.current;
|
|
51
161
|
if (!node || !stick.current) {
|
|
52
162
|
stop();
|
|
53
163
|
return;
|
|
54
164
|
}
|
|
55
|
-
const target =
|
|
165
|
+
const target = scrollTarget(
|
|
166
|
+
Math.max(0, node.scrollHeight - node.clientHeight),
|
|
167
|
+
slotRef?.current ?? null,
|
|
168
|
+
);
|
|
56
169
|
const diff = target - node.scrollTop;
|
|
57
170
|
if (Math.abs(diff) < 0.5) {
|
|
58
171
|
node.scrollTop = target;
|
|
@@ -65,7 +178,7 @@ export function useStickToBottom<T extends HTMLElement>({
|
|
|
65
178
|
};
|
|
66
179
|
frame.current = requestAnimationFrame(step);
|
|
67
180
|
},
|
|
68
|
-
[stop],
|
|
181
|
+
[stop, slotRef],
|
|
69
182
|
);
|
|
70
183
|
|
|
71
184
|
useEffect(() => {
|
|
@@ -74,12 +187,14 @@ export function useStickToBottom<T extends HTMLElement>({
|
|
|
74
187
|
stop();
|
|
75
188
|
return;
|
|
76
189
|
}
|
|
77
|
-
const
|
|
190
|
+
const pinChanged = lastPin.current !== pinKey;
|
|
191
|
+
const opened = !lastActive.current || lastKey.current !== resetKey;
|
|
78
192
|
lastActive.current = true;
|
|
79
193
|
lastKey.current = resetKey;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
194
|
+
lastPin.current = pinKey;
|
|
195
|
+
if (opened || pinChanged) stick.current = true;
|
|
196
|
+
follow(opened ? "instant" : pinChanged ? "send" : "ease");
|
|
197
|
+
}, [active, resetKey, pinKey, follow, stop, ...deps]);
|
|
83
198
|
|
|
84
199
|
useEffect(() => stop, [stop]);
|
|
85
200
|
|