@townco/ui 0.1.121 → 0.1.122
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/core/hooks/use-chat-messages.js +9 -4
- package/dist/core/hooks/use-chat-session.js +12 -3
- package/dist/gui/components/ChatView.js +1 -1
- package/dist/gui/components/EditableUserMessage.js +11 -8
- package/dist/gui/components/MessageActions.d.ts +4 -1
- package/dist/gui/components/MessageActions.js +22 -18
- package/package.json +3 -3
|
@@ -86,10 +86,15 @@ export function useChatMessages(client, startSession) {
|
|
|
86
86
|
: {}),
|
|
87
87
|
};
|
|
88
88
|
addMessage(userMessage);
|
|
89
|
-
// Update URL with session ID
|
|
90
|
-
if (typeof window !== "undefined" &&
|
|
91
|
-
|
|
92
|
-
|
|
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({}, "",
|
|
25
|
+
window.history.replaceState({}, "", newUrl);
|
|
18
26
|
}
|
|
19
27
|
else {
|
|
20
|
-
window.history.pushState({}, "",
|
|
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
|
|
@@ -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,
|
|
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 }))] }) }),
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
21
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.122",
|
|
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.
|
|
52
|
+
"@townco/core": "0.0.100",
|
|
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.
|
|
70
|
+
"@townco/tsconfig": "0.1.119",
|
|
71
71
|
"@types/node": "^24.10.0",
|
|
72
72
|
"@types/react": "^19.2.2",
|
|
73
73
|
"@types/unist": "^3.0.3",
|