@townco/ui 0.1.121 → 0.1.123

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.
@@ -86,10 +86,15 @@ export function useChatMessages(client, startSession) {
86
86
  : {}),
87
87
  };
88
88
  addMessage(userMessage);
89
- // Update URL with session ID on first message (if not already in URL)
90
- if (typeof window !== "undefined" &&
91
- !new URL(window.location.href).searchParams.has("session")) {
92
- safeUpdateUrl(activeSessionId, false);
89
+ // Update URL with session ID if not already present
90
+ if (typeof window !== "undefined" && activeSessionId) {
91
+ const currentUrl = new URL(window.location.href);
92
+ if (!currentUrl.searchParams.has("session")) {
93
+ logger.info("Updating URL with session ID", {
94
+ sessionId: activeSessionId,
95
+ });
96
+ safeUpdateUrl(activeSessionId, false);
97
+ }
93
98
  }
94
99
  // Create placeholder for assistant message BEFORE sending
95
100
  const assistantMessage = {
@@ -8,17 +8,26 @@ const logger = createLogger("use-chat-session", "debug");
8
8
  * as the browser won't allow changing the URL to remove/modify credentials.
9
9
  */
10
10
  export function safeUpdateUrl(sessionId, useReplace = false) {
11
- if (typeof window === "undefined")
11
+ if (typeof window === "undefined") {
12
+ logger.debug("safeUpdateUrl: window is undefined, skipping");
12
13
  return;
14
+ }
13
15
  try {
14
16
  const url = new URL(window.location.href);
15
17
  url.searchParams.set("session", sessionId);
18
+ const newUrl = url.toString();
19
+ logger.info("safeUpdateUrl: updating URL", {
20
+ sessionId,
21
+ useReplace,
22
+ newUrl,
23
+ });
16
24
  if (useReplace) {
17
- window.history.replaceState({}, "", url.toString());
25
+ window.history.replaceState({}, "", newUrl);
18
26
  }
19
27
  else {
20
- window.history.pushState({}, "", url.toString());
28
+ window.history.pushState({}, "", newUrl);
21
29
  }
30
+ logger.info("safeUpdateUrl: URL updated successfully");
22
31
  }
23
32
  catch (error) {
24
33
  // URL update can fail with HTTP Basic Auth credentials in the URL
@@ -287,7 +287,10 @@ const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChan
287
287
  if (!hasInitialScrolledRef.current) {
288
288
  // Use a small delay to let initial content render
289
289
  const timeout = setTimeout(() => {
290
- container.scrollTop = container.scrollHeight;
290
+ // Only scroll if the content actually overflows the viewport.
291
+ if (container.scrollHeight > container.clientHeight + 1) {
292
+ container.scrollTop = container.scrollHeight;
293
+ }
291
294
  hasInitialScrolledRef.current = true;
292
295
  }, 100);
293
296
  return () => clearTimeout(timeout);
@@ -343,7 +343,7 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
343
343
  // Check if this message should be dimmed (comes after editing message)
344
344
  const shouldDim = editingMessageIndex !== null &&
345
345
  index > editingMessageIndex;
346
- return (_jsx(Message, { message: message, className: cn("group", shouldDim && "opacity-50"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: "flex flex-col w-full min-w-0", children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend, sticky: true, onEditingChange: (isEditing) => {
346
+ return (_jsx(Message, { message: message, className: cn("group", shouldDim && "opacity-50", message.role === "user" && "mb-4"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: cn("flex flex-col w-full min-w-0"), children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend, onEditingChange: (isEditing) => {
347
347
  setEditingMessageIndex(isEditing ? index : null);
348
348
  } })) : (_jsxs(_Fragment, { children: [
349
349
  _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }), _jsx(MessageActions, { message: message, isStreaming: message.isStreaming, onSendMessage: sendMessage, isLastAssistantMessage: index ===
@@ -55,8 +55,11 @@ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAnd
55
55
  }
56
56
  }, [message.content]);
57
57
  const handleStartEdit = useCallback(() => {
58
+ // Don't allow entering edit mode while the agent is streaming.
59
+ if (isStreaming)
60
+ return;
58
61
  setIsEditing(true);
59
- }, []);
62
+ }, [isStreaming]);
60
63
  // Focus the contenteditable element when entering edit mode
61
64
  useEffect(() => {
62
65
  if (isEditing && contentEditableRef.current) {
@@ -114,13 +117,13 @@ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAnd
114
117
  }
115
118
  : 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
119
  // 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 break-words [overflow-wrap:anywhere] 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
- ] })) }))] }));
120
+ _jsx("div", { ref: contentEditableRef, role: "textbox", tabIndex: isEditing ? 0 : -1, contentEditable: isEditing, onKeyDown: isEditing ? handleKeyDown : undefined, suppressContentEditableWarning: true, className: cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-foreground leading-relaxed outline-none", isEditing && "cursor-text", !isEditing && "cursor-default"), children: message.content }))] }) }), message.content && (_jsx("div", { className: "mt-2", children: _jsx(Actions, { className: cn("justify-end transition-opacity", isEditing
121
+ ? "opacity-100 pointer-events-auto"
122
+ : "opacity-0 pointer-events-none group-hover/user-message:opacity-100 group-hover/user-message:pointer-events-auto"), children: isEditing ? (_jsxs(_Fragment, { children: [
123
+ _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", disabled: isStreaming, onClick: handleSaveAndResend, className: "h-7 px-3 text-xs", children: "Send" })
124
+ ] })) : (_jsxs(_Fragment, { children: [
125
+ _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { disabled: isStreaming, onClick: handleStartEdit, tooltip: isStreaming ? "Wait for agent to finish" : "Edit", children: _jsx(Pencil, { className: "size-4" }) })
126
+ ] })) }) }))] }));
124
127
  }
125
128
  export const EditableUserMessage = memo(PureEditableUserMessage, (prevProps, nextProps) => {
126
129
  return (prevProps.isStreaming === nextProps.isStreaming &&
@@ -8,7 +8,10 @@ export interface MessageActionsProps {
8
8
  onRedo?: () => void;
9
9
  /** Callback to send a message to the agent */
10
10
  onSendMessage?: (message: string) => void;
11
- /** Whether this is the last assistant message (shows actions by default) */
11
+ /**
12
+ * Whether this is the last assistant message.
13
+ * If true, actions are shown by default (not just on hover).
14
+ */
12
15
  isLastAssistantMessage?: boolean;
13
16
  }
14
17
  declare function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLastAssistantMessage }: MessageActionsProps): import("react/jsx-runtime").JSX.Element | null;
@@ -17,20 +17,18 @@ const EXPORT_FORMATS = [
17
17
  ];
18
18
  function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLastAssistantMessage = false, }) {
19
19
  const [isCopied, setIsCopied] = useState(false);
20
- // Don't show actions while streaming
21
- if (isStreaming) {
20
+ // Only show actions for assistant messages with actual text content.
21
+ // (Tool-only assistant messages exist and shouldn't render a duplicate action row.)
22
+ if (message.role !== "assistant" || !message.content?.trim()) {
22
23
  return null;
23
24
  }
24
- // Only show actions for assistant messages with content
25
- if (message.role !== "assistant" || !message.content) {
26
- return null;
27
- }
28
- // For non-last messages, show on hover only
29
25
  const visibilityClass = isLastAssistantMessage
30
26
  ? ""
31
27
  : "opacity-0 group-hover:opacity-100 transition-opacity";
28
+ const disableWhileStreaming = Boolean(isStreaming);
29
+ const canCopy = Boolean(message.content) && !disableWhileStreaming;
32
30
  const handleCopy = async () => {
33
- if (!message.content) {
31
+ if (!message.content || disableWhileStreaming) {
34
32
  toast.error("There's no text to copy!");
35
33
  return;
36
34
  }
@@ -48,6 +46,8 @@ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLas
48
46
  }
49
47
  };
50
48
  const handleRedo = () => {
49
+ if (disableWhileStreaming)
50
+ return;
51
51
  if (onRedo) {
52
52
  onRedo();
53
53
  }
@@ -56,6 +56,8 @@ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLas
56
56
  }
57
57
  };
58
58
  const handleExport = (format) => {
59
+ if (disableWhileStreaming)
60
+ return;
59
61
  if (onSendMessage) {
60
62
  onSendMessage(`produce an artifact as ${format} for this session`);
61
63
  }
@@ -63,16 +65,18 @@ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLas
63
65
  toast.info("Export not available");
64
66
  }
65
67
  };
66
- return (_jsxs(Actions, { className: cn(visibilityClass), children: [
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
- _jsx(TooltipProvider, { children: _jsxs(Tooltip, { 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
- _jsx(Download, { className: "size-4" }), _jsx("span", { className: "sr-only", children: "Export" })
71
- ] }) }) }), _jsx(TooltipContent, { children: _jsx("p", { children: "Export" }) })
72
- ] }) }), _jsx(DropdownMenuContent, { align: "start", children: EXPORT_FORMATS.map((format) => (_jsxs(DropdownMenuItem, { onClick: () => handleExport(format.label), children: [
73
- _jsx(format.icon, { className: "size-4 mr-2" }), format.label] }, format.id))) })
74
- ] })
75
- ] }));
68
+ return (_jsx("div", { className: "mt-1", children: _jsxs(Actions, { className: cn(visibilityClass, isLastAssistantMessage
69
+ ? "pointer-events-auto"
70
+ : "pointer-events-none group-hover:pointer-events-auto"), children: [
71
+ _jsx(Action, { disabled: !canCopy, onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { disabled: disableWhileStreaming, onClick: handleRedo, tooltip: "Redo", children: _jsx(RotateCcw, { className: "size-4" }) }), _jsxs(DropdownMenu, { children: [
72
+ _jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [
73
+ _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"), disabled: disableWhileStreaming, size: "sm", type: "button", variant: "ghost", children: [
74
+ _jsx(Download, { className: "size-4" }), _jsx("span", { className: "sr-only", children: "Export" })
75
+ ] }) }) }), _jsx(TooltipContent, { children: _jsx("p", { children: "Export" }) })
76
+ ] }) }), _jsx(DropdownMenuContent, { align: "start", children: EXPORT_FORMATS.map((format) => (_jsxs(DropdownMenuItem, { onClick: () => handleExport(format.label), children: [
77
+ _jsx(format.icon, { className: "size-4 mr-2" }), format.label] }, format.id))) })
78
+ ] })
79
+ ] }) }));
76
80
  }
77
81
  export const MessageActions = memo(PureMessageActions, (prevProps, nextProps) => {
78
82
  if (prevProps.isStreaming !== nextProps.isStreaming) {
@@ -375,6 +375,11 @@ export function useScrollToBottom() {
375
375
  return;
376
376
  }
377
377
  const scrollIfNeeded = () => {
378
+ // If content doesn't overflow, don't force any programmatic scroll.
379
+ // This avoids "snapping" the first/small streaming message to the top.
380
+ if (container.scrollHeight <= container.clientHeight + 1) {
381
+ return;
382
+ }
378
383
  // Only auto-scroll if user was at bottom and isn't actively scrolling
379
384
  if (isAtBottomRef.current && !isUserScrollingRef.current) {
380
385
  requestAnimationFrame(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.121",
3
+ "version": "0.1.123",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "@radix-ui/react-slot": "^1.2.4",
50
50
  "@radix-ui/react-tabs": "^1.1.13",
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
- "@townco/core": "0.0.99",
52
+ "@townco/core": "0.0.101",
53
53
  "@types/mdast": "^4.0.4",
54
54
  "@uiw/react-json-view": "^2.0.0-alpha.39",
55
55
  "class-variance-authority": "^0.7.1",
@@ -67,7 +67,7 @@
67
67
  "zustand": "^5.0.8"
68
68
  },
69
69
  "devDependencies": {
70
- "@townco/tsconfig": "0.1.118",
70
+ "@townco/tsconfig": "0.1.120",
71
71
  "@types/node": "^24.10.0",
72
72
  "@types/react": "^19.2.2",
73
73
  "@types/unist": "^3.0.3",