@backmatter/studio 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/chunks/UiKitApp-BxvTwsko.js +2410 -0
  2. package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
  3. package/dist/chunks/{dashboard-bNp6AbKE.js → dashboard-8rjkfEXl.js} +380 -382
  4. package/dist/chunks/{index-DoDE-hwB.js → index-laWAz5_p.js} +111 -112
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +287 -146
  7. package/dist/site-widget.css +1615 -0
  8. package/dist/site-widget.js +30903 -628
  9. package/dist/studio.css +268 -67
  10. package/package.json +4 -4
  11. package/src/App.tsx +12 -29
  12. package/src/api.ts +55 -0
  13. package/src/chat/ChangeCard.tsx +2 -0
  14. package/src/chat/ChatMessage.tsx +73 -62
  15. package/src/chat/ChatPanel.tsx +110 -15
  16. package/src/chat/DocListCard.tsx +2 -0
  17. package/src/chat/LiveStatus.tsx +78 -8
  18. package/src/chat/Reveal.tsx +34 -0
  19. package/src/chat/Shimmer.tsx +65 -23
  20. package/src/chat/chat-helpers.ts +35 -0
  21. package/src/chat/motion.ts +73 -0
  22. package/src/chat/useStickToBottom.ts +129 -14
  23. package/src/chrome/ContentExplorerSidebar.tsx +23 -8
  24. package/src/chrome/content-tree-visibility.ts +55 -0
  25. package/src/devbar-state.ts +125 -0
  26. package/src/index.css +3 -131
  27. package/src/pages/CliAuthorizePage.tsx +61 -28
  28. package/src/pages/MediaGalleryPage.tsx +74 -11
  29. package/src/pages/MediaLightbox.tsx +93 -1
  30. package/src/pages/SettingsUsagePage.tsx +23 -1
  31. package/src/pages/media-grid.ts +13 -0
  32. package/src/pages/ui-kit/UiKitApp.tsx +164 -0
  33. package/src/pages/ui-kit/buttons.tsx +30 -0
  34. package/src/pages/ui-kit/catalog.tsx +41 -0
  35. package/src/pages/ui-kit/colors.tsx +95 -0
  36. package/src/pages/ui-kit/devbar.tsx +160 -0
  37. package/src/pages/ui-kit/feedback.tsx +39 -0
  38. package/src/pages/ui-kit/fields.tsx +47 -0
  39. package/src/pages/ui-kit/lists.tsx +19 -0
  40. package/src/pages/ui-kit/overlays.tsx +39 -0
  41. package/src/pages/ui-kit/path.ts +9 -0
  42. package/src/pages/ui-kit/sections.tsx +32 -0
  43. package/src/pages/ui-kit/surfaces.tsx +121 -0
  44. package/src/pages/ui-kit/topic.tsx +112 -0
  45. package/src/pages/ui-kit/topics.ts +71 -0
  46. package/src/pages/ui-kit/type.tsx +55 -0
  47. package/src/preview-frame-radius.ts +51 -0
  48. package/src/preview-frame.tsx +108 -49
  49. package/src/shell-resets.css +155 -0
  50. package/src/site-bar.tsx +1810 -425
  51. package/src/site-dashboard-href.ts +21 -0
  52. package/src/site-devbar-overlay-inject.ts +15 -0
  53. package/src/site-devbar-overlay.css +217 -0
  54. package/src/site-pointer.ts +1 -1
  55. package/src/site-publish-menu.tsx +33 -0
  56. package/src/site-widget-styles.ts +7 -0
  57. package/src/site-widget.tsx +61 -21
  58. package/src/studio-prose-ui.css +39 -0
  59. package/src/theme/ThemeProvider.tsx +4 -1
  60. package/src/workspace-path.ts +10 -6
  61. package/dist/chunks/EmbedPublishPage-CtIlaeGO.js +0 -149
  62. package/dist/chunks/UiKitPage-DcnSQ-Hf.js +0 -173
  63. package/dist/chunks/WorkspaceRoutes-DVpUOaMV.js +0 -59550
  64. package/dist/chunks/status-bar-action-C0fsAWvD.js +0 -30618
  65. package/src/pages/UiKitPage.tsx +0 -331
@@ -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 { formatElapsed, liveActivity, type LiveActivity } from "./chat-helpers";
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
- if (!activity) return null;
23
- return <LiveStatusLine activity={activity} />;
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
- <span {...stylex.props(styles.label)}>
94
+ <ActivityLabel kind={activity.kind}>
48
95
  <Shimmer>{activity.label}</Shimmer>
49
- </span>
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
- minHeight: 20,
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
+ }
@@ -1,39 +1,81 @@
1
1
  // packages/studio/src/chat/Shimmer.tsx
2
2
  //
3
- // Text with a soft highlight sweeping across it. Used for transient status
4
- // copy ("Thinking…", running tool calls) instead of a pulsing opacity.
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
- const sweep = stylex.keyframes({
10
- "0%": { backgroundPosition: "200% 0" },
11
- "100%": { backgroundPosition: "-200% 0" },
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 <span {...stylex.props(styles.text)}>{children}</span>;
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
- text: {
20
- color: {
21
- default: "transparent",
22
- "@media (prefers-reduced-motion: reduce)": colors.textMuted,
23
- },
24
- backgroundImage: {
25
- default: `linear-gradient(90deg, ${colors.textMuted} 0%, ${colors.textMuted} 35%, ${colors.text} 50%, ${colors.textMuted} 65%, ${colors.textMuted} 100%)`,
26
- "@media (prefers-reduced-motion: reduce)": "none",
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
- backgroundSize: "200% 100%",
29
- backgroundClip: "text",
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
  });
@@ -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
- // Keeps a scroll container pinned to its bottom while content grows (streaming,
4
- // new messages), easing there with a short animation instead of jumping. The
5
- // user can break away by scrolling up; scrolling back down re-engages.
6
- import { useCallback, useEffect, useRef, type UIEvent } from "react";
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 (ease-out). */
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
- (instant: boolean) => {
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
- if (frame.current != null) return; // already chasing the bottom
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 = node.scrollHeight - node.clientHeight;
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 instant = !lastActive.current || lastKey.current !== resetKey;
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
- if (instant) stick.current = true;
81
- follow(instant);
82
- }, [active, resetKey, follow, stop, ...deps]);
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
 
@@ -11,6 +11,7 @@ import {
11
11
  contentDocPath,
12
12
  contentRailHref,
13
13
  isWorkspaceContentPath,
14
+ isWorkspaceEditorPath,
14
15
  isWorkspaceMediaPath,
15
16
  isWorkspaceSettingsPath,
16
17
  mediaPath,
@@ -54,6 +55,11 @@ import {
54
55
  type FolderCreateContext,
55
56
  } from "./folder-create-context";
56
57
  import { canWriteContent } from "../workspace/permissions";
58
+ import {
59
+ contentTreeVisible,
60
+ toggleContentTree,
61
+ useContentTreeOpen,
62
+ } from "./content-tree-visibility";
57
63
 
58
64
  const hover = "@media (hover: hover)";
59
65
  const statusDot = "#ea580c";
@@ -158,7 +164,8 @@ function ContentExplorerSidebar({
158
164
 
159
165
  const empty = workspace.contentTypes.length === 0;
160
166
  const settingsActive = isWorkspaceSettingsPath(location.pathname);
161
- const showTree = isWorkspaceContentPath(location.pathname);
167
+ const treeOpen = useContentTreeOpen();
168
+ const showTree = contentTreeVisible(location.pathname, treeOpen);
162
169
  const canWrite = canWriteContent(workspace.role);
163
170
 
164
171
  const tree = showTree ? (
@@ -232,6 +239,7 @@ function ContentExplorerSidebar({
232
239
  pendingCount={pendingCount}
233
240
  settingsActive={settingsActive}
234
241
  workspaceSlug={workspace.workspaceSlug}
242
+ treeOpen={treeOpen}
235
243
  />
236
244
  {tree && treeHost ? createPortal(tree, treeHost) : null}
237
245
  </RailColumn>
@@ -242,13 +250,18 @@ function ActivityRail({
242
250
  pendingCount,
243
251
  settingsActive,
244
252
  workspaceSlug,
253
+ treeOpen,
245
254
  }: {
246
255
  pendingCount: number;
247
256
  settingsActive: boolean;
248
257
  workspaceSlug: string;
258
+ treeOpen: boolean;
249
259
  }) {
250
260
  const navigate = useNavigate();
251
261
  const location = useLocation();
262
+ const onDocument = isWorkspaceEditorPath(location.pathname);
263
+ const filesLabel = onDocument ? (treeOpen ? "Hide files" : "Show files") : "Content";
264
+ const filesActive = isWorkspaceContentPath(location.pathname) && (!onDocument || treeOpen);
252
265
 
253
266
  return (
254
267
  <nav
@@ -256,19 +269,21 @@ function ActivityRail({
256
269
  {...stylex.props(styles.rail)}
257
270
  >
258
271
  <div {...stylex.props(styles.railTop)}>
259
- <Tooltip label="Content" side="right">
272
+ <Tooltip label={filesLabel} side="right">
260
273
  <button
261
274
  type="button"
262
- aria-label="Content"
263
- aria-current={isWorkspaceContentPath(location.pathname) ? "page" : undefined}
275
+ aria-label={filesLabel}
276
+ aria-pressed={onDocument ? treeOpen : undefined}
277
+ aria-current={!onDocument && filesActive ? "page" : undefined}
264
278
  onClick={() => {
279
+ if (onDocument) {
280
+ toggleContentTree();
281
+ return;
282
+ }
265
283
  const href = contentRailHref(location.pathname, workspaceSlug);
266
284
  if (href) navigate(href);
267
285
  }}
268
- {...stylex.props(
269
- styles.railBtn,
270
- isWorkspaceContentPath(location.pathname) && styles.railBtnActive,
271
- )}
286
+ {...stylex.props(styles.railBtn, filesActive && styles.railBtnActive)}
272
287
  >
273
288
  <ExplorerRailFilesIcon />
274
289
  </button>