@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.
- package/dist/gui/components/Actions.js +1 -1
- package/dist/gui/components/ChatLayout.d.ts +2 -0
- package/dist/gui/components/ChatLayout.js +196 -72
- package/dist/gui/components/ChatPanelTabContent.js +2 -2
- package/dist/gui/components/ChatView.js +7 -1
- package/dist/gui/components/EditableUserMessage.d.ts +5 -1
- package/dist/gui/components/EditableUserMessage.js +75 -22
- package/dist/gui/components/HookNotification.js +34 -10
- package/dist/gui/components/MessageActions.js +1 -1
- package/dist/gui/components/TodoList.js +2 -4
- package/dist/gui/constants.d.ts +3 -0
- package/dist/gui/constants.js +4 -0
- package/dist/gui/hooks/index.d.ts +1 -0
- package/dist/gui/hooks/index.js +1 -0
- package/dist/gui/hooks/use-element-size.d.ts +12 -0
- package/dist/gui/hooks/use-element-size.js +38 -0
- package/package.json +8 -3
|
@@ -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-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
//
|
|
113
|
-
React.
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
//
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
const isInTopRegion = relativeY >= 0 && relativeY <= 100;
|
|
145
|
-
setIsCursorInTopRegion(isInTopRegion);
|
|
187
|
+
const updatePreviews = () => {
|
|
188
|
+
const previews = getAllUserMessagePreviews();
|
|
189
|
+
setMessagePreviews(previews);
|
|
146
190
|
};
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
202
|
+
}, [containerRef, getAllUserMessagePreviews]);
|
|
203
|
+
// Cleanup timeouts and RAF on unmount
|
|
204
|
+
React.useEffect(() => {
|
|
152
205
|
return () => {
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
}, [
|
|
157
|
-
// Show
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
239
|
+
// Clear any pending leave timeout
|
|
240
|
+
if (hoverLeaveTimeoutRef.current) {
|
|
241
|
+
clearTimeout(hoverLeaveTimeoutRef.current);
|
|
242
|
+
hoverLeaveTimeoutRef.current = null;
|
|
164
243
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
},
|
|
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
|
-
] }),
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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,
|
|
94
|
+
}, [messageIndex, message.images, onEditAndResend]);
|
|
53
95
|
const handleKeyDown = useCallback((e) => {
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
12
|
+
return { icon: FoldVertical, title: "Compacting Context..." };
|
|
13
13
|
}
|
|
14
14
|
return {
|
|
15
|
-
icon:
|
|
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:
|
|
35
|
+
return { icon: FoldVertical, title: "Compacting Response..." };
|
|
22
36
|
}
|
|
23
37
|
return {
|
|
24
|
-
icon:
|
|
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:
|
|
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-
|
|
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
|
|
11
|
-
_jsx(SquareCheckBig, { className: "size-8 text-
|
|
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) => {
|
package/dist/gui/constants.d.ts
CHANGED
|
@@ -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;
|
package/dist/gui/constants.js
CHANGED
|
@@ -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)
|
package/dist/gui/hooks/index.js
CHANGED
|
@@ -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.
|
|
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",
|