@townco/ui 0.1.97 → 0.1.98

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.
@@ -11,7 +11,7 @@ export const Actions = ({ className, children, ...props }) => (_jsx("div", { cla
11
11
  * Single action button with optional tooltip
12
12
  */
13
13
  export const Action = ({ tooltip, children, label, className, variant = "ghost", size = "sm", ...props }) => {
14
- const button = (_jsxs(Button, { className: cn("relative size-8 p-1.5 text-muted-foreground hover:text-foreground", className), size: size, type: "button", variant: variant, ...props, children: [children, _jsx("span", { className: "sr-only", children: label || tooltip })
14
+ const button = (_jsxs(Button, { className: cn("relative size-7 p-1.5 text-muted-foreground hover:text-foreground", className), size: size, type: "button", variant: variant, ...props, children: [children, _jsx("span", { className: "sr-only", children: label || tooltip })
15
15
  ] }));
16
16
  if (tooltip) {
17
17
  return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [
@@ -15,6 +15,8 @@ interface ChatLayoutContextValue {
15
15
  setIsDraggingAside: (dragging: boolean) => void;
16
16
  asideWidth: number;
17
17
  setAsideWidth: (width: number) => void;
18
+ mainWidth: number;
19
+ setMainWidth: (width: number) => void;
18
20
  }
19
21
  declare const ChatLayoutContext: React.Context<ChatLayoutContextValue | undefined>;
20
22
  declare const useChatLayoutContext: () => ChatLayoutContextValue;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AnimatePresence, motion } from "framer-motion";
3
- import { ArrowDown, ArrowUp, X } from "lucide-react";
3
+ import { ArrowDown, X } from "lucide-react";
4
4
  import * as React from "react";
5
- import { ASIDE_WIDTH_DEFAULT, ASIDE_WIDTH_MAX, ASIDE_WIDTH_MIN, } from "../constants.js";
5
+ import { ASIDE_WIDTH_DEFAULT, ASIDE_WIDTH_MAX, ASIDE_WIDTH_MIN, CHAT_MAX_WIDTH, TOC_MIN_SPACING, } from "../constants.js";
6
+ import { useElementSize } from "../hooks/use-element-size.js";
6
7
  import { useLockBodyScroll } from "../hooks/use-lock-body-scroll.js";
7
8
  import { useIsMobile } from "../hooks/use-mobile.js";
8
9
  import { useScrollToBottom } from "../hooks/use-scroll-to-bottom.js";
@@ -30,6 +31,8 @@ const ChatLayoutRoot = React.forwardRef(({ defaultSidebarOpen = false, defaultPa
30
31
  const [isDraggingAside, setIsDraggingAside] = React.useState(false);
31
32
  // Track aside panel width (for main content padding)
32
33
  const [asideWidth, setAsideWidth] = React.useState(ASIDE_WIDTH_DEFAULT);
34
+ // Track main content width (for ToC visibility logic)
35
+ const [mainWidth, setMainWidth] = React.useState(0);
33
36
  // Helper to toggle the right panel
34
37
  const togglePanel = React.useCallback(() => {
35
38
  // Prevent rapid toggling during animation
@@ -77,6 +80,8 @@ const ChatLayoutRoot = React.forwardRef(({ defaultSidebarOpen = false, defaultPa
77
80
  setIsDraggingAside,
78
81
  asideWidth,
79
82
  setAsideWidth,
83
+ mainWidth,
84
+ setMainWidth,
80
85
  }, children: _jsx("div", { ref: ref, "data-panel-state": panelOpen ? "expanded" : "collapsed", className: cn("flex h-screen flex-row bg-background text-foreground overflow-hidden", className), ...props, children: children }) }));
81
86
  });
82
87
  ChatLayoutRoot.displayName = "ChatLayout.Root";
@@ -85,8 +90,18 @@ const ChatLayoutHeader = React.forwardRef(({ className, children, ...props }, re
85
90
  });
86
91
  ChatLayoutHeader.displayName = "ChatLayout.Header";
87
92
  const ChatLayoutMain = React.forwardRef(({ className, children }, ref) => {
88
- const { panelOpen, isDraggingAside, asideWidth } = useChatLayoutContext();
89
- return (_jsx("div", { ref: ref, className: cn("flex flex-1 flex-col overflow-hidden h-full min-w-0",
93
+ const { panelOpen, isDraggingAside, asideWidth, setMainWidth } = useChatLayoutContext();
94
+ // Internal ref for measuring width
95
+ const internalRef = React.useRef(null);
96
+ // Measure the main content width
97
+ const { width } = useElementSize(internalRef);
98
+ // Update context with the width
99
+ React.useEffect(() => {
100
+ setMainWidth(width);
101
+ }, [width, setMainWidth]);
102
+ // Merge external and internal refs
103
+ React.useImperativeHandle(ref, () => internalRef.current);
104
+ return (_jsx("div", { ref: internalRef, className: cn("flex flex-1 flex-col overflow-hidden h-full min-w-0",
90
105
  // Use CSS transition for smooth reflow (like left sidebar does)
91
106
  !isDraggingAside && "transition-[padding] duration-250", className), style: {
92
107
  // Add padding when panel is open to make room for the fixed aside
@@ -99,85 +114,160 @@ const ChatLayoutBody = React.forwardRef(({ showToaster = true, className, childr
99
114
  });
100
115
  ChatLayoutBody.displayName = "ChatLayout.Body";
101
116
  const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, initialScrollToBottom = true, ...props }, ref) => {
102
- const { containerRef, endRef, isAtBottom, scrollToBottom, isUserMessageAboveFold, hasMoreUserMessagesBelow, userMessagesAboveCount, userMessagesBelowCount, scrollToPreviousUserMessage, scrollToNextUserMessage, scrollToUserMessageByIndex, getUserMessagePreviews, } = useScrollToBottom();
117
+ const { mainWidth } = useChatLayoutContext();
118
+ const { containerRef, endRef, isAtBottom, scrollToBottom, userMessagesAboveCount, scrollToUserMessageByIndex, } = useScrollToBottom();
119
+ // Animation variants for framer-motion
120
+ const tocContainerVariants = React.useMemo(() => ({
121
+ hidden: { opacity: 0, x: -8 },
122
+ visible: {
123
+ opacity: 1,
124
+ x: 0,
125
+ transition: {
126
+ duration: 0.2,
127
+ ease: [0.4, 0, 0.2, 1], // easeOut cubic-bezier
128
+ },
129
+ },
130
+ exit: {
131
+ opacity: 0,
132
+ x: -8,
133
+ transition: { duration: 0.15 },
134
+ },
135
+ }), []);
136
+ const hoverMenuVariants = React.useMemo(() => ({
137
+ hidden: {
138
+ opacity: 0,
139
+ x: -12,
140
+ scale: 0.95,
141
+ transition: { duration: 0.15 },
142
+ },
143
+ visible: {
144
+ opacity: 1,
145
+ x: 0,
146
+ scale: 1,
147
+ transition: {
148
+ duration: 0.15,
149
+ ease: [0.4, 0, 0.2, 1], // easeOut cubic-bezier
150
+ },
151
+ },
152
+ exit: {
153
+ opacity: 0,
154
+ x: -8,
155
+ scale: 0.98,
156
+ transition: { duration: 0.15 },
157
+ },
158
+ }), []);
103
159
  // State for hover menu
104
160
  const [showMessageMenu, setShowMessageMenu] = React.useState(false);
105
161
  const [messagePreviews, setMessagePreviews] = React.useState([]);
106
- const hoverTimeoutRef = React.useRef(null);
107
- // State for navigator pill visibility (only show when scrolling or cursor in top region)
108
- const [isScrolling, setIsScrolling] = React.useState(false);
109
- const [isCursorInTopRegion, setIsCursorInTopRegion] = React.useState(false);
110
- const [isHoveringNav, setIsHoveringNav] = React.useState(false);
111
- const scrollTimeoutRef = React.useRef(null);
112
- // Track scrolling activity
113
- React.useEffect(() => {
162
+ const [hoveredBarIndex, setHoveredBarIndex] = React.useState(null);
163
+ const [menuYPosition, setMenuYPosition] = React.useState(0);
164
+ const hoverEnterTimeoutRef = React.useRef(null);
165
+ const hoverLeaveTimeoutRef = React.useRef(null);
166
+ const rafRef = React.useRef(null);
167
+ const pendingYPositionRef = React.useRef(0);
168
+ // Function to get ALL user messages (not just those above fold)
169
+ const getAllUserMessagePreviews = React.useCallback(() => {
114
170
  const container = containerRef.current;
115
- if (!container)
116
- return;
117
- const handleScroll = () => {
118
- setIsScrolling(true);
119
- // Clear existing timeout
120
- if (scrollTimeoutRef.current) {
121
- clearTimeout(scrollTimeoutRef.current);
122
- }
123
- // Hide after 1.5 seconds of no scrolling
124
- scrollTimeoutRef.current = setTimeout(() => {
125
- setIsScrolling(false);
126
- }, 1500);
127
- };
128
- container.addEventListener("scroll", handleScroll, { passive: true });
129
- return () => {
130
- container.removeEventListener("scroll", handleScroll);
131
- if (scrollTimeoutRef.current) {
132
- clearTimeout(scrollTimeoutRef.current);
133
- }
134
- };
171
+ if (!container) {
172
+ return [];
173
+ }
174
+ // Find all user messages in the DOM
175
+ const userMessages = container.querySelectorAll('[aria-label="user message"]');
176
+ return Array.from(userMessages).map((element, index) => {
177
+ const textContent = element.textContent || "";
178
+ const preview = textContent.slice(0, 100) + (textContent.length > 100 ? "..." : "");
179
+ return { index, preview };
180
+ });
135
181
  }, [containerRef]);
136
- // Track cursor position (show pill when cursor is in top 100px of the container)
182
+ // Load ALL message previews and update when messages change
137
183
  React.useEffect(() => {
138
184
  const container = containerRef.current;
139
185
  if (!container)
140
186
  return;
141
- const handleMouseMove = (e) => {
142
- const containerRect = container.getBoundingClientRect();
143
- const relativeY = e.clientY - containerRect.top;
144
- const isInTopRegion = relativeY >= 0 && relativeY <= 100;
145
- setIsCursorInTopRegion(isInTopRegion);
187
+ const updatePreviews = () => {
188
+ const previews = getAllUserMessagePreviews();
189
+ setMessagePreviews(previews);
146
190
  };
147
- const handleMouseLeave = () => {
148
- setIsCursorInTopRegion(false);
191
+ // Initial load
192
+ updatePreviews();
193
+ // Watch for DOM changes (new messages being added)
194
+ const mutationObserver = new MutationObserver(updatePreviews);
195
+ mutationObserver.observe(container, {
196
+ childList: true,
197
+ subtree: true,
198
+ });
199
+ return () => {
200
+ mutationObserver.disconnect();
149
201
  };
150
- container.addEventListener("mousemove", handleMouseMove);
151
- container.addEventListener("mouseleave", handleMouseLeave);
202
+ }, [containerRef, getAllUserMessagePreviews]);
203
+ // Cleanup timeouts and RAF on unmount
204
+ React.useEffect(() => {
152
205
  return () => {
153
- container.removeEventListener("mousemove", handleMouseMove);
154
- container.removeEventListener("mouseleave", handleMouseLeave);
206
+ if (hoverEnterTimeoutRef.current) {
207
+ clearTimeout(hoverEnterTimeoutRef.current);
208
+ }
209
+ if (hoverLeaveTimeoutRef.current) {
210
+ clearTimeout(hoverLeaveTimeoutRef.current);
211
+ }
212
+ if (rafRef.current !== null) {
213
+ cancelAnimationFrame(rafRef.current);
214
+ }
155
215
  };
156
- }, [containerRef]);
157
- // Show navigator pill only when scrolling, cursor in top region, or hovering over the nav
158
- const showNavigatorPill = isUserMessageAboveFold &&
159
- (isScrolling || isCursorInTopRegion || isHoveringNav);
160
- // Handle mouse enter on navigation component
216
+ }, []);
217
+ // Show ToC when there are more than 1 user message AND there's enough space
218
+ // (main width must be greater than chat max width + space for ToC on both sides)
219
+ const hasEnoughSpace = mainWidth > CHAT_MAX_WIDTH + TOC_MIN_SPACING;
220
+ const showToC = messagePreviews.length > 1 && hasEnoughSpace;
221
+ // Handle mouse move on navigation component - track cursor position with RAF throttling
222
+ const handleNavMouseMove = React.useCallback((e) => {
223
+ // Get the nav element's bounding rect to calculate relative position
224
+ const navElement = e.currentTarget;
225
+ const navRect = navElement.getBoundingClientRect();
226
+ const relativeY = e.clientY - navRect.top;
227
+ // Store the pending position
228
+ pendingYPositionRef.current = relativeY;
229
+ // Use requestAnimationFrame to throttle updates to 60fps
230
+ if (rafRef.current === null) {
231
+ rafRef.current = requestAnimationFrame(() => {
232
+ setMenuYPosition(pendingYPositionRef.current);
233
+ rafRef.current = null;
234
+ });
235
+ }
236
+ }, []);
237
+ // Handle mouse enter on navigation component with 1.5s delay
161
238
  const handleNavMouseEnter = React.useCallback(() => {
162
- if (hoverTimeoutRef.current) {
163
- clearTimeout(hoverTimeoutRef.current);
239
+ // Clear any pending leave timeout
240
+ if (hoverLeaveTimeoutRef.current) {
241
+ clearTimeout(hoverLeaveTimeoutRef.current);
242
+ hoverLeaveTimeoutRef.current = null;
164
243
  }
165
- setIsHoveringNav(true);
166
- const previews = getUserMessagePreviews();
167
- setMessagePreviews(previews);
168
- setShowMessageMenu(true);
169
- }, [getUserMessagePreviews]);
244
+ // Set a 1.5s delay before showing the menu
245
+ hoverEnterTimeoutRef.current = setTimeout(() => {
246
+ setShowMessageMenu(true);
247
+ }, 1500);
248
+ }, []);
170
249
  // Handle mouse leave with delay
171
250
  const handleNavMouseLeave = React.useCallback(() => {
172
- hoverTimeoutRef.current = setTimeout(() => {
251
+ // Clear any pending enter timeout
252
+ if (hoverEnterTimeoutRef.current) {
253
+ clearTimeout(hoverEnterTimeoutRef.current);
254
+ hoverEnterTimeoutRef.current = null;
255
+ }
256
+ hoverLeaveTimeoutRef.current = setTimeout(() => {
173
257
  setShowMessageMenu(false);
174
- setIsHoveringNav(false);
175
- }, 150);
258
+ setHoveredBarIndex(null);
259
+ }, 500);
260
+ }, []);
261
+ // Handle hovering over individual bars
262
+ const handleBarMouseEnter = React.useCallback((index) => {
263
+ setHoveredBarIndex(index);
264
+ }, []);
265
+ const handleBarMouseLeave = React.useCallback(() => {
266
+ setHoveredBarIndex(null);
176
267
  }, []);
177
268
  // Handle click on a message in the menu
178
269
  const handleMessageClick = React.useCallback((index) => {
179
270
  scrollToUserMessageByIndex(index, "smooth");
180
- setShowMessageMenu(false);
181
271
  }, [scrollToUserMessageByIndex]);
182
272
  const hasInitialScrolledRef = React.useRef(false);
183
273
  // Merge refs
@@ -209,17 +299,51 @@ const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChan
209
299
  return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [
210
300
  _jsxs("div", { ref: containerRef, className: cn("h-full overflow-y-auto flex flex-col", className), ...props, children: [
211
301
  _jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }), _jsx("div", { ref: endRef, className: "shrink-0" })
212
- ] }), showNavigatorPill && (_jsxs("nav", { className: "absolute top-4 left-1/2 -translate-x-1/2 z-10", onMouseEnter: handleNavMouseEnter, onMouseLeave: handleNavMouseLeave, "aria-label": "User message navigation", children: [
213
- _jsxs("div", { className: cn("flex items-center gap-0.5 rounded-full", "bg-card border border-border shadow-lg", "animate-in fade-in slide-in-from-top-2"), children: [userMessagesAboveCount > 0 && (_jsxs("button", { type: "button", onClick: () => scrollToPreviousUserMessage("smooth"), className: cn("flex items-center gap-1 py-2 pl-2.5 pr-2", hasMoreUserMessagesBelow
214
- ? "rounded-l-full"
215
- : "rounded-full pr-2.5", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-colors duration-150"), "aria-label": `${userMessagesAboveCount} previous user message${userMessagesAboveCount !== 1 ? "s" : ""}`, children: [
216
- _jsx(ArrowUp, { className: "size-4" }), _jsx("span", { className: "text-xs font-medium min-w-[1ch]", children: userMessagesAboveCount })
217
- ] })), userMessagesAboveCount > 0 && hasMoreUserMessagesBelow && (_jsx("div", { className: "w-px h-4 bg-border" })), hasMoreUserMessagesBelow && (_jsxs("button", { type: "button", onClick: () => scrollToNextUserMessage("smooth"), className: cn("flex items-center gap-1 py-2 pl-2 pr-2.5", userMessagesAboveCount > 0
218
- ? "rounded-r-full"
219
- : "rounded-full pl-2.5", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-colors duration-150"), "aria-label": `${userMessagesBelowCount} next user message${userMessagesBelowCount !== 1 ? "s" : ""}`, children: [
220
- _jsx("span", { className: "text-xs font-medium min-w-[1ch]", children: userMessagesBelowCount }), _jsx(ArrowDown, { className: "size-4" })
221
- ] }))] }), showMessageMenu && messagePreviews.length > 0 && (_jsx("div", { className: cn("absolute top-full left-1/2 -translate-x-1/2 mt-2", "w-72 max-h-64 overflow-y-auto", "bg-card border border-border rounded-lg shadow-xl", "animate-in fade-in slide-in-from-top-1 duration-150"), children: _jsx("div", { className: "p-1", children: messagePreviews.map(({ index, preview }) => (_jsxs("button", { type: "button", onClick: () => handleMessageClick(index), className: cn("w-full text-left px-3 py-2 rounded-md", "text-sm text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-colors duration-100", "truncate"), children: [
222
- _jsxs("span", { className: "text-muted-foreground mr-2 text-xs", children: [index + 1, "."] }), preview] }, index))) }) }))] })), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom("smooth"), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
302
+ ] }), _jsx(AnimatePresence, { mode: "wait", children: showToC && (_jsxs(motion.nav, { variants: tocContainerVariants, initial: "hidden", animate: "visible", exit: "exit", className: "absolute p-4 left-0 top-1/2 -translate-y-1/2 z-10", onMouseEnter: handleNavMouseEnter, onMouseMove: handleNavMouseMove, onMouseLeave: handleNavMouseLeave, "aria-label": "User message navigation", children: [
303
+ _jsx("div", { className: "flex flex-col", children: messagePreviews.map(({ index }) => {
304
+ // Determine if this is the "current" message in view
305
+ // The current message is the first one visible in the viewport
306
+ // userMessagesAboveCount tells us how many are above (not visible)
307
+ // So the current visible message is at index userMessagesAboveCount
308
+ const isCurrentMessage = index === userMessagesAboveCount;
309
+ const isHovered = hoveredBarIndex === index;
310
+ return (_jsx("button", { type: "button", onClick: () => handleMessageClick(index), onMouseEnter: () => handleBarMouseEnter(index), onMouseLeave: handleBarMouseLeave, className: "py-2 px-1 group", "aria-label": `Go to user message ${index + 1}`, children: _jsx("div", { className: cn("w-4 h-[2px] rounded-full bg-primary transition-opacity duration-200", isCurrentMessage ? "opacity-80" : "opacity-10", isHovered && "opacity-60", !isHovered && "group-hover:opacity-60") }) }, index));
311
+ }) }), _jsx(AnimatePresence, { children: showMessageMenu &&
312
+ messagePreviews.length > 0 &&
313
+ hoveredBarIndex !== null &&
314
+ (() => {
315
+ // Menu item height (py-2 = 0.5rem top + 0.5rem bottom = 1rem = 16px, plus content)
316
+ // Approximate height per item: 40px (padding + text line height)
317
+ const itemHeight = 40;
318
+ // Show up to 3 items at a time, or fewer if there aren't enough messages
319
+ const maxVisibleItems = Math.min(3, messagePreviews.length);
320
+ const menuVisibleHeight = itemHeight * maxVisibleItems + 10;
321
+ // Calculate the translateY to center the hovered item in the menu
322
+ // We want to move the content up so the hovered item is in the middle (at itemHeight * 1)
323
+ const targetCenterPosition = itemHeight * 1; // Middle position
324
+ const hoveredItemPosition = itemHeight * hoveredBarIndex;
325
+ const translateY = targetCenterPosition - hoveredItemPosition;
326
+ // Clamp the translation so we don't over-scroll at the edges
327
+ const maxTranslateY = 0; // Don't scroll past the first item
328
+ const minTranslateY = menuVisibleHeight -
329
+ itemHeight * messagePreviews.length -
330
+ 10;
331
+ const clampedTranslateY = Math.max(minTranslateY, Math.min(maxTranslateY, translateY));
332
+ return (_jsx(motion.div, { variants: hoverMenuVariants, initial: "hidden", animate: "visible", exit: "exit", className: cn("absolute left-12 pointer-events-none", "w-72 overflow-hidden", "bg-card border border-border rounded-lg shadow-xl"), style: {
333
+ top: `${menuYPosition}px`,
334
+ y: "-50%",
335
+ height: `${menuVisibleHeight}px`,
336
+ willChange: "transform",
337
+ }, children: _jsx("div", { className: "p-1", style: {
338
+ transform: `translateY(${clampedTranslateY}px)`,
339
+ transition: "transform 0.15s cubic-bezier(0.4, 0, 0.2, 1)",
340
+ willChange: "transform",
341
+ }, children: messagePreviews.map(({ index, preview }) => {
342
+ const isHovered = index === hoveredBarIndex;
343
+ return (_jsx("div", { className: cn("w-full text-left px-3 py-2 rounded-md", "text-sm text-muted-foreground", "transition-colors duration-100", "truncate", isHovered && "bg-accent text-foreground"), style: { height: `${itemHeight}px` }, children: preview }, index));
344
+ }) }) }, "hover-menu"));
345
+ })() })
346
+ ] }, "toc")) }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom("smooth"), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
223
347
  });
224
348
  ChatLayoutMessages.displayName = "ChatLayout.Messages";
225
349
  const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
@@ -34,8 +34,8 @@ FilesTabContent.displayName = "FilesTabContent";
34
34
  export const SourcesTabContent = React.forwardRef(({ sources = [], highlightedSourceId, className, ...props }, ref) => {
35
35
  // Show empty state if no sources
36
36
  if (sources.length === 0) {
37
- return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-center justify-center h-full text-center py-8", className), ...props, children: [
38
- _jsx(Globe, { className: "size-8 text-muted-foreground/50 mb-3" }), _jsx("p", { className: "text-paragraph-sm text-muted-foreground", children: "No sources yet" }), _jsx("p", { className: "text-caption text-muted-foreground/70 mt-1", children: "Sources from web searches and fetches will appear here" })
37
+ return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-center justify-center h-full text-center py-8 max-w-sm mx-auto", className), ...props, children: [
38
+ _jsx(Globe, { className: "size-8 text-muted-foreground opacity-50 mb-3" }), _jsx("p", { className: "text-paragraph text-muted-foreground", children: "No sources yet" }), _jsx("p", { className: "text-paragraph-sm text-muted-foreground/70 mt-1", children: "Sources will appear when your agent searches the web or fetches data." })
39
39
  ] }));
40
40
  }
41
41
  return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: sources.map((source) => (_jsx(SourceListItem, { source: source, isSelected: source.id === highlightedSourceId }, source.id))) }));
@@ -94,6 +94,7 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
94
94
  const [agentPromptParameters, setAgentPromptParameters] = useState([]);
95
95
  const [placeholder, setPlaceholder] = useState("Type a message or / for commands...");
96
96
  const [hideTopBar, setHideTopBar] = useState(false);
97
+ const [editingMessageIndex, setEditingMessageIndex] = useState(null);
97
98
  const todos = useChatStore(selectTodosForCurrentSession);
98
99
  const _latestContextSize = useChatStore((state) => state.latestContextSize);
99
100
  const { resolvedTheme, setTheme } = useTheme();
@@ -309,7 +310,12 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
309
310
  .slice(0, index + 1)
310
311
  .filter((m) => m.role === "user").length - 1
311
312
  : -1;
312
- return (_jsx(Message, { message: message, className: cn(spacingClass, "group"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: "flex flex-col w-full", children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend })) : (_jsxs(_Fragment, { children: [
313
+ // Check if this message should be dimmed (comes after editing message)
314
+ const shouldDim = editingMessageIndex !== null &&
315
+ index > editingMessageIndex;
316
+ return (_jsx(Message, { message: message, className: cn(spacingClass, "group", shouldDim && "opacity-50"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: "flex flex-col w-full", children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend, sticky: true, onEditingChange: (isEditing) => {
317
+ setEditingMessageIndex(isEditing ? index : null);
318
+ } })) : (_jsxs(_Fragment, { children: [
313
319
  _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }), _jsx(MessageActions, { message: message, isStreaming: message.isStreaming, onSendMessage: sendMessage, isLastAssistantMessage: index ===
314
320
  messages.findLastIndex((m) => m.role === "assistant"), onRedo: () => {
315
321
  // Find the user message that preceded this assistant message
@@ -10,7 +10,11 @@ export interface EditableUserMessageProps {
10
10
  mimeType: string;
11
11
  data: string;
12
12
  }>) => void;
13
+ /** Whether to make the message content sticky */
14
+ sticky?: boolean;
15
+ /** Callback when editing state changes */
16
+ onEditingChange?: (isEditing: boolean) => void;
13
17
  }
14
- declare function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend }: EditableUserMessageProps): import("react/jsx-runtime").JSX.Element;
18
+ declare function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend, sticky, onEditingChange }: EditableUserMessageProps): import("react/jsx-runtime").JSX.Element;
15
19
  export declare const EditableUserMessage: import("react").MemoExoticComponent<typeof PureEditableUserMessage>;
16
20
  export {};
@@ -1,16 +1,42 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check, Copy, Pencil } from "lucide-react";
4
- import { memo, useCallback, useState } from "react";
4
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
5
5
  import { toast } from "sonner";
6
+ import { cn } from "../lib/utils.js";
6
7
  import { Action, Actions } from "./Actions.js";
7
8
  import { Button } from "./Button.js";
8
- import { MessageContent } from "./MessageContent.js";
9
- import { Textarea } from "./Textarea.js";
10
- function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend, }) {
9
+ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend, sticky = false, onEditingChange, }) {
11
10
  const [isEditing, setIsEditing] = useState(false);
12
- const [editedContent, setEditedContent] = useState(message.content || "");
13
11
  const [isCopied, setIsCopied] = useState(false);
12
+ const containerRef = useRef(null);
13
+ const contentEditableRef = useRef(null);
14
+ // Notify parent when editing state changes
15
+ useEffect(() => {
16
+ onEditingChange?.(isEditing);
17
+ }, [isEditing, onEditingChange]);
18
+ // Handle clicking the sticky message to scroll back to its position
19
+ const handleStickyClick = useCallback(() => {
20
+ if (!sticky || !containerRef.current)
21
+ return;
22
+ // Find the scroll container (parent with overflow-y-auto)
23
+ let scrollContainer = containerRef.current.parentElement;
24
+ while (scrollContainer &&
25
+ !scrollContainer.classList.contains("overflow-y-auto")) {
26
+ scrollContainer = scrollContainer.parentElement;
27
+ }
28
+ if (scrollContainer) {
29
+ // Get the element's position relative to the scroll container
30
+ const _elementRect = containerRef.current.getBoundingClientRect();
31
+ const _containerRect = scrollContainer.getBoundingClientRect();
32
+ const elementTop = containerRef.current.offsetTop;
33
+ // Scroll so the element is at the top of the container
34
+ scrollContainer.scrollTo({
35
+ top: elementTop - 16, // Small offset for padding
36
+ behavior: "smooth",
37
+ });
38
+ }
39
+ }, [sticky]);
14
40
  const handleCopy = useCallback(async () => {
15
41
  if (!message.content) {
16
42
  toast.error("There's no text to copy!");
@@ -29,14 +55,30 @@ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAnd
29
55
  }
30
56
  }, [message.content]);
31
57
  const handleStartEdit = useCallback(() => {
32
- setEditedContent(message.content || "");
33
58
  setIsEditing(true);
34
- }, [message.content]);
59
+ }, []);
60
+ // Focus the contenteditable element when entering edit mode
61
+ useEffect(() => {
62
+ if (isEditing && contentEditableRef.current) {
63
+ contentEditableRef.current.focus();
64
+ // Move cursor to end
65
+ const range = document.createRange();
66
+ const selection = window.getSelection();
67
+ range.selectNodeContents(contentEditableRef.current);
68
+ range.collapse(false);
69
+ selection?.removeAllRanges();
70
+ selection?.addRange(range);
71
+ }
72
+ }, [isEditing]);
35
73
  const handleCancelEdit = useCallback(() => {
36
74
  setIsEditing(false);
37
- setEditedContent(message.content || "");
75
+ // Reset content to original
76
+ if (contentEditableRef.current) {
77
+ contentEditableRef.current.textContent = message.content || "";
78
+ }
38
79
  }, [message.content]);
39
80
  const handleSaveAndResend = useCallback(() => {
81
+ const editedContent = contentEditableRef.current?.textContent || "";
40
82
  if (!editedContent.trim())
41
83
  return;
42
84
  // Convert images back to attachments format if the message had images
@@ -49,31 +91,42 @@ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAnd
49
91
  }));
50
92
  onEditAndResend(messageIndex, editedContent.trim(), attachments);
51
93
  setIsEditing(false);
52
- }, [messageIndex, editedContent, message.images, onEditAndResend]);
94
+ }, [messageIndex, message.images, onEditAndResend]);
53
95
  const handleKeyDown = useCallback((e) => {
54
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
96
+ // Enter without Shift submits, Shift+Enter inserts line break
97
+ if (e.key === "Enter" && !e.shiftKey) {
55
98
  e.preventDefault();
56
99
  handleSaveAndResend();
57
100
  }
101
+ // Escape cancels editing
58
102
  if (e.key === "Escape") {
59
103
  e.preventDefault();
60
104
  handleCancelEdit();
61
105
  }
62
106
  }, [handleSaveAndResend, handleCancelEdit]);
63
- if (isEditing) {
64
- return (_jsxs("div", { className: "w-full bg-secondary rounded-2xl px-4 py-4", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 mb-2", children: message.images.map((image, imageIndex) => (_jsx("img", { src: `data:${image.mimeType};base64,${image.data}`, alt: `Attachment ${imageIndex + 1}`, className: "max-w-[200px] max-h-[200px] rounded-lg object-cover opacity-50" }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), _jsx(Textarea, { value: editedContent, onChange: (e) => setEditedContent(e.target.value), onKeyDown: handleKeyDown, className: "min-h-[60px] w-full resize-none bg-transparent border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 p-0 text-base leading-relaxed text-foreground", autoFocus: true, placeholder: "Edit your message..." }), _jsxs("div", { className: "flex items-center justify-end gap-2 mt-2", children: [
65
- _jsx(Button, { variant: "ghost", size: "sm", onClick: handleCancelEdit, className: "h-7 px-2 text-xs", children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleSaveAndResend, disabled: !editedContent.trim(), className: "h-7 px-3 text-xs", children: "Send" })
66
- ] })
67
- ] }));
68
- }
69
- return (_jsxs("div", { className: "w-full group/user-message", children: [
70
- _jsx(MessageContent, { message: message }), !isStreaming && message.content && (_jsxs(Actions, { className: "mt-2 opacity-0 group-hover/user-message:opacity-100 transition-opacity", children: [
71
- _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { onClick: handleStartEdit, tooltip: "Edit", children: _jsx(Pencil, { className: "size-4" }) })
72
- ] }))] }));
107
+ return (_jsxs("div", { ref: containerRef, className: "w-full group/user-message", children: [
108
+ _jsx("div", { className: cn("w-full rounded-2xl bg-secondary px-4 py-4 transition-colors", sticky && "sticky top-0 z-10 bg-secondary cursor-pointer", isEditing &&
109
+ "ring-2 ring-primary/20 focus-within:ring-primary/40 transition-all bg-transparent"), onClick: sticky && !isEditing ? handleStickyClick : undefined, onKeyDown: sticky && !isEditing
110
+ ? (e) => {
111
+ if (e.key === "Enter" || e.key === " ") {
112
+ handleStickyClick();
113
+ }
114
+ }
115
+ : undefined, role: sticky && !isEditing ? "button" : undefined, tabIndex: sticky && !isEditing ? 0 : undefined, children: _jsxs("div", { className: "flex flex-col gap-2", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: message.images.map((image, imageIndex) => (_jsx("img", { src: `data:${image.mimeType};base64,${image.data}`, alt: `Attachment ${imageIndex + 1}`, className: cn("max-w-[200px] max-h-[200px] rounded-lg object-cover", isEditing && "opacity-50") }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), message.content && (
116
+ // biome-ignore lint/a11y/useSemanticElements: contentEditable div preserves whitespace formatting better than textarea
117
+ _jsx("div", { ref: contentEditableRef, role: "textbox", tabIndex: isEditing ? 0 : -1, contentEditable: isEditing, onKeyDown: isEditing ? handleKeyDown : undefined, suppressContentEditableWarning: true, className: cn("whitespace-pre-wrap text-foreground leading-relaxed outline-none", isEditing && "cursor-text", !isEditing && "cursor-default"), children: message.content }))] }) }), !isStreaming && message.content && (_jsx(Actions, { className: cn("mt-2 transition-opacity justify-end", isEditing
118
+ ? "opacity-100"
119
+ : "opacity-0 group-hover/user-message:opacity-100"), children: isEditing ? (_jsxs(_Fragment, { children: [
120
+ _jsx(Button, { variant: "ghost", size: "sm", onClick: handleCancelEdit, className: "h-7 px-2 text-xs text-muted-foreground hover:text-foreground", children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleSaveAndResend, className: "h-7 px-3 text-xs", children: "Send" })
121
+ ] })) : (_jsxs(_Fragment, { children: [
122
+ _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { onClick: handleStartEdit, tooltip: "Edit", children: _jsx(Pencil, { className: "size-4" }) })
123
+ ] })) }))] }));
73
124
  }
74
125
  export const EditableUserMessage = memo(PureEditableUserMessage, (prevProps, nextProps) => {
75
126
  return (prevProps.isStreaming === nextProps.isStreaming &&
76
127
  prevProps.message.id === nextProps.message.id &&
77
128
  prevProps.message.content === nextProps.message.content &&
78
- prevProps.messageIndex === nextProps.messageIndex);
129
+ prevProps.messageIndex === nextProps.messageIndex &&
130
+ prevProps.sticky === nextProps.sticky &&
131
+ prevProps.onEditingChange === nextProps.onEditingChange);
79
132
  });
@@ -1,33 +1,47 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircle, AlertTriangle, Archive, CheckCircle2, ChevronDown, Loader2, Scissors, } from "lucide-react";
2
+ import { AlertCircle, AlertTriangle, CheckCircle2, ChevronDown, FoldVertical, Loader2, } from "lucide-react";
3
3
  import { useEffect, useState } from "react";
4
4
  /**
5
5
  * Get display information for a hook type
6
6
  */
7
- function getHookDisplayInfo(hookType, _callback, status, action) {
7
+ function getHookDisplayInfo(hookType, callback, status, action, midTurn) {
8
8
  const isTriggered = status === "triggered";
9
9
  const noActionNeeded = action === "no_action_needed";
10
10
  if (hookType === "context_size") {
11
11
  if (isTriggered) {
12
- return { icon: Archive, title: "Compacting Context..." };
12
+ return { icon: FoldVertical, title: "Compacting Context..." };
13
13
  }
14
14
  return {
15
- icon: Archive,
15
+ icon: FoldVertical,
16
16
  title: noActionNeeded ? "Context Check" : "Context Compacted",
17
17
  };
18
18
  }
19
19
  if (hookType === "tool_response") {
20
+ // Check for mid-turn compaction (either by callback name or metadata flag)
21
+ const isMidTurnCompaction = callback === "mid_turn_compaction" || midTurn === true;
22
+ if (isMidTurnCompaction) {
23
+ if (isTriggered) {
24
+ return { icon: FoldVertical, title: "Compacting Context Mid-Turn..." };
25
+ }
26
+ return {
27
+ icon: FoldVertical,
28
+ title: noActionNeeded
29
+ ? "Context Check"
30
+ : "Context Compacted (Mid-Turn)",
31
+ };
32
+ }
33
+ // Regular tool response compaction
20
34
  if (isTriggered) {
21
- return { icon: Scissors, title: "Compacting Response..." };
35
+ return { icon: FoldVertical, title: "Compacting Response..." };
22
36
  }
23
37
  return {
24
- icon: Scissors,
38
+ icon: FoldVertical,
25
39
  title: noActionNeeded ? "Response Check" : "Tool Response Compacted",
26
40
  };
27
41
  }
28
42
  // Fallback for unknown hook types
29
43
  return {
30
- icon: Archive,
44
+ icon: FoldVertical,
31
45
  title: isTriggered ? "Running Hook..." : "Hook Executed",
32
46
  };
33
47
  }
@@ -44,7 +58,7 @@ function formatNumber(num) {
44
58
  export function HookNotification({ notification }) {
45
59
  const [isExpanded, setIsExpanded] = useState(false);
46
60
  const [elapsedTime, setElapsedTime] = useState(0);
47
- const { icon: IconComponent, title } = getHookDisplayInfo(notification.hookType, notification.callback, notification.status, notification.metadata?.action);
61
+ const { icon: IconComponent, title } = getHookDisplayInfo(notification.hookType, notification.callback, notification.status, notification.metadata?.action, notification.metadata?.midTurn);
48
62
  const isTriggered = notification.status === "triggered";
49
63
  const isCompleted = notification.status === "completed";
50
64
  const isError = notification.status === "error";
@@ -65,15 +79,25 @@ export function HookNotification({ notification }) {
65
79
  const interval = setInterval(updateElapsed, 100);
66
80
  return () => clearInterval(interval);
67
81
  }, [isTriggered, notification.triggeredAt]);
82
+ // Check if this is mid-turn compaction
83
+ const isMidTurnCompaction = notification.callback === "mid_turn_compaction" ||
84
+ notification.metadata?.midTurn === true;
68
85
  // Build subtitle showing key info
69
86
  let subtitle = "";
70
87
  if (isTriggered && notification.currentPercentage !== undefined) {
71
88
  subtitle = `Context at ${notification.currentPercentage.toFixed(1)}% (threshold: ${notification.threshold}%)`;
72
89
  }
73
90
  else if (isCompleted && notification.metadata?.tokensSaved !== undefined) {
74
- const { tokensSaved, originalTokens } = notification.metadata;
91
+ const { tokensSaved, originalTokens, messagesRemoved } = notification.metadata;
92
+ // For mid-turn compaction - show context reduction info
93
+ if (isMidTurnCompaction) {
94
+ const messagesInfo = messagesRemoved !== undefined
95
+ ? `${formatNumber(messagesRemoved)} messages summarized, `
96
+ : "";
97
+ subtitle = `${messagesInfo}${formatNumber(tokensSaved ?? 0)} tokens saved`;
98
+ }
75
99
  // For tool_response hooks - show percentage reduced of the tool response
76
- if (originalTokens !== undefined && originalTokens > 0) {
100
+ else if (originalTokens !== undefined && originalTokens > 0) {
77
101
  const percentageSaved = ((tokensSaved ?? 0) / originalTokens) * 100;
78
102
  subtitle = `${percentageSaved.toFixed(0)}% reduced`;
79
103
  }
@@ -66,7 +66,7 @@ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLas
66
66
  return (_jsxs(Actions, { className: cn("mt-2 mb-10", visibilityClass), children: [
67
67
  _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? _jsx(Check, { className: "size-4" }) : _jsx(Copy, { className: "size-4" }) }), _jsx(Action, { onClick: handleRedo, tooltip: "Redo", children: _jsx(RotateCcw, { className: "size-4" }) }), _jsxs(DropdownMenu, { children: [
68
68
  _jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [
69
- _jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { className: cn("relative size-8 p-1.5 text-muted-foreground hover:text-foreground"), size: "sm", type: "button", variant: "ghost", children: [
69
+ _jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { className: cn("relative size-7 p-1.5 text-muted-foreground hover:text-foreground"), size: "sm", type: "button", variant: "ghost", children: [
70
70
  _jsx(Download, { className: "size-4" }), _jsx("span", { className: "sr-only", children: "Export" })
71
71
  ] }) }) }), _jsx(TooltipContent, { children: _jsx("p", { children: "Export" }) })
72
72
  ] }) }), _jsx(DropdownMenuContent, { align: "start", children: EXPORT_FORMATS.map((format) => (_jsxs(DropdownMenuItem, { onClick: () => handleExport(format.label), children: [
@@ -7,10 +7,8 @@ import { TodoListItem } from "./TodoListItem.js";
7
7
  * Empty state component for the todo list
8
8
  */
9
9
  function TodoListEmptyState() {
10
- return (_jsxs("div", { className: "flex flex-col items-center justify-center h-full gap-3", children: [
11
- _jsx(SquareCheckBig, { className: "size-8 text-neutral-300" }), _jsxs("p", { className: "text-base leading-6 text-neutral-400 text-center", children: ["There's nothing on the",
12
- _jsx("br", {}),
13
- "to-do list yet."] })
10
+ return (_jsxs("div", { className: "flex flex-col items-center justify-center h-full text-center py-8 max-w-sm mx-auto", children: [
11
+ _jsx(SquareCheckBig, { className: "size-8 text-muted-foreground opacity-50 mb-3" }), _jsx("p", { className: "text-paragraph text-muted-foreground", children: "To-do list is empty" }), _jsx("p", { className: "text-paragraph-sm text-muted-foreground/70 mt-1", children: "Your agent will create tasks as it works through requests." })
14
12
  ] }));
15
13
  }
16
14
  export const TodoList = React.forwardRef(({ client, todos, className, ...props }, ref) => {
@@ -4,3 +4,6 @@ export declare const SIDEBAR_TAP_ZONE = 104;
4
4
  export declare const ASIDE_WIDTH_DEFAULT = 450;
5
5
  export declare const ASIDE_WIDTH_MIN = 250;
6
6
  export declare const ASIDE_WIDTH_MAX = 800;
7
+ export declare const CHAT_MAX_WIDTH = 720;
8
+ export declare const TOC_WIDTH = 56;
9
+ export declare const TOC_MIN_SPACING: number;
@@ -6,3 +6,7 @@ export const SIDEBAR_TAP_ZONE = 104;
6
6
  export const ASIDE_WIDTH_DEFAULT = 450;
7
7
  export const ASIDE_WIDTH_MIN = 250;
8
8
  export const ASIDE_WIDTH_MAX = 800;
9
+ // Chat layout constants
10
+ export const CHAT_MAX_WIDTH = 720; // matches CSS variable --max-width-chat
11
+ export const TOC_WIDTH = 56; // Table of Contents navigation width
12
+ export const TOC_MIN_SPACING = TOC_WIDTH * 2; // Minimum spacing needed on sides (2 * TOC width)
@@ -1,3 +1,4 @@
1
+ export { useElementSize } from "./use-element-size.js";
1
2
  export { useDocumentTitle, useFavicon } from "./use-favicon.js";
2
3
  export { useLockBodyScroll } from "./use-lock-body-scroll.js";
3
4
  export { useIsMobile } from "./use-mobile.js";
@@ -1,3 +1,4 @@
1
+ export { useElementSize } from "./use-element-size.js";
1
2
  export { useDocumentTitle, useFavicon } from "./use-favicon.js";
2
3
  export { useLockBodyScroll } from "./use-lock-body-scroll.js";
3
4
  export { useIsMobile } from "./use-mobile.js";
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+ interface ElementSize {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /**
7
+ * Hook to observe and track an element's size using ResizeObserver
8
+ * @param ref - React ref to the element to observe
9
+ * @returns Object containing current width and height
10
+ */
11
+ export declare function useElementSize<T extends HTMLElement = HTMLDivElement>(ref: React.RefObject<T | null>): ElementSize;
12
+ export {};
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Hook to observe and track an element's size using ResizeObserver
4
+ * @param ref - React ref to the element to observe
5
+ * @returns Object containing current width and height
6
+ */
7
+ export function useElementSize(ref) {
8
+ const [size, setSize] = React.useState({
9
+ width: 0,
10
+ height: 0,
11
+ });
12
+ React.useEffect(() => {
13
+ const element = ref.current;
14
+ if (!element)
15
+ return undefined;
16
+ // Create ResizeObserver to track size changes
17
+ const resizeObserver = new ResizeObserver((entries) => {
18
+ if (!Array.isArray(entries) || !entries.length) {
19
+ return;
20
+ }
21
+ const entry = entries[0];
22
+ if (!entry)
23
+ return;
24
+ const { width, height } = entry.contentRect;
25
+ setSize({ width, height });
26
+ });
27
+ // Start observing
28
+ resizeObserver.observe(element);
29
+ // Set initial size
30
+ const rect = element.getBoundingClientRect();
31
+ setSize({ width: rect.width, height: rect.height });
32
+ // Cleanup
33
+ return () => {
34
+ resizeObserver.disconnect();
35
+ };
36
+ }, [ref]);
37
+ return size;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,18 +15,22 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": {
18
+ "development": "./src/index.ts",
18
19
  "import": "./dist/index.js",
19
20
  "types": "./dist/index.d.ts"
20
21
  },
21
22
  "./core": {
23
+ "development": "./src/core/index.ts",
22
24
  "import": "./dist/core/index.js",
23
25
  "types": "./dist/core/index.d.ts"
24
26
  },
25
27
  "./tui": {
28
+ "development": "./src/tui/index.ts",
26
29
  "import": "./dist/tui/index.js",
27
30
  "types": "./dist/tui/index.d.ts"
28
31
  },
29
32
  "./gui": {
33
+ "development": "./src/gui/index.ts",
30
34
  "import": "./dist/gui/index.js",
31
35
  "types": "./dist/gui/index.d.ts"
32
36
  },
@@ -70,10 +74,11 @@
70
74
  "@townco/tsconfig": "0.1.94",
71
75
  "@types/node": "^24.10.0",
72
76
  "@types/react": "^19.2.2",
77
+ "@types/unist": "^3.0.3",
78
+ "@typescript/native-preview": "^7.0.0-dev.20251207.1",
73
79
  "ink": "^6.4.0",
74
80
  "react": "19.2.1",
75
- "tailwindcss": "^4.1.17",
76
- "@typescript/native-preview": "^7.0.0-dev.20251207.1"
81
+ "tailwindcss": "^4.1.17"
77
82
  },
78
83
  "peerDependencies": {
79
84
  "ink": "^6.4.0",