thepopebot 1.2.33 → 1.2.35

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/lib/ai/index.js CHANGED
@@ -102,14 +102,16 @@ async function chat(threadId, message, attachments = [], options = {}) {
102
102
  * @param {string} threadId - Conversation thread ID
103
103
  * @param {string} message - User's message text
104
104
  * @param {Array} [attachments=[]] - Image/PDF attachments: { category, mimeType, dataUrl }
105
- * @param {object} [options] - { userId, chatTitle } for DB persistence
105
+ * @param {object} [options] - { userId, chatTitle, skipUserPersist } for DB persistence
106
106
  * @returns {AsyncIterableIterator<string>} Stream of text chunks
107
107
  */
108
108
  async function* chatStream(threadId, message, attachments = [], options = {}) {
109
109
  const agent = await getAgent();
110
110
 
111
- // Save user message to DB
112
- persistMessage(threadId, 'user', message || '[attachment]', options);
111
+ // Save user message to DB (skip on regeneration — message already exists)
112
+ if (!options.skipUserPersist) {
113
+ persistMessage(threadId, 'user', message || '[attachment]', options);
114
+ }
113
115
 
114
116
  // Build content blocks: text + any image/PDF attachments as vision
115
117
  const content = [];
package/lib/chat/api.js CHANGED
@@ -12,7 +12,7 @@ export async function POST(request) {
12
12
  }
13
13
 
14
14
  const body = await request.json();
15
- const { messages, chatId: rawChatId } = body;
15
+ const { messages, chatId: rawChatId, trigger } = body;
16
16
 
17
17
  if (!messages?.length) {
18
18
  return Response.json({ error: 'No messages' }, { status: 400 });
@@ -72,8 +72,10 @@ export async function POST(request) {
72
72
  },
73
73
  execute: async ({ writer }) => {
74
74
  // chatStream handles: save user msg, invoke agent, save assistant msg, auto-title
75
+ const skipUserPersist = trigger === 'regenerate-message';
75
76
  const chunks = chatStream(threadId, userText, attachments, {
76
77
  userId: session.user.id,
78
+ skipUserPersist,
77
79
  });
78
80
 
79
81
  // Signal start of assistant message
@@ -34,7 +34,7 @@ function AppSidebar({ user }) {
34
34
  /* @__PURE__ */ jsxs("div", { className: collapsed ? "flex justify-center" : "flex items-center justify-between", children: [
35
35
  !collapsed && /* @__PURE__ */ jsxs("span", { className: "px-2 font-semibold text-lg", children: [
36
36
  "The Pope Bot ",
37
- /* @__PURE__ */ jsxs("span", { className: "text-xs font-normal text-muted-foreground", children: [
37
+ /* @__PURE__ */ jsxs("span", { className: "text-[11px] font-normal text-muted-foreground", children: [
38
38
  "v",
39
39
  pkg.version
40
40
  ] })
@@ -39,7 +39,7 @@ export function AppSidebar({ user }) {
39
39
  {/* Top row: brand name + toggle icon (open) or just toggle icon (collapsed) */}
40
40
  <div className={collapsed ? 'flex justify-center' : 'flex items-center justify-between'}>
41
41
  {!collapsed && (
42
- <span className="px-2 font-semibold text-lg">The Pope Bot <span className="text-xs font-normal text-muted-foreground">v{pkg.version}</span></span>
42
+ <span className="px-2 font-semibold text-lg">The Pope Bot <span className="text-[11px] font-normal text-muted-foreground">v{pkg.version}</span></span>
43
43
  )}
44
44
  <Tooltip>
45
45
  <TooltipTrigger asChild>
@@ -118,7 +118,7 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles })
118
118
  }
119
119
  };
120
120
  const canSend = input.trim() || files.length > 0;
121
- return /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-2 pb-4 md:px-4", children: /* @__PURE__ */ jsx("form", { onSubmit: handleSubmit, className: "relative", children: /* @__PURE__ */ jsxs(
121
+ return /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-4 pb-4 md:px-6", children: /* @__PURE__ */ jsx("form", { onSubmit: handleSubmit, className: "relative", children: /* @__PURE__ */ jsxs(
122
122
  "div",
123
123
  {
124
124
  className: cn(
@@ -115,7 +115,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
115
115
  const canSend = input.trim() || files.length > 0;
116
116
 
117
117
  return (
118
- <div className="mx-auto w-full max-w-4xl px-2 pb-4 md:px-4">
118
+ <div className="mx-auto w-full max-w-4xl px-4 pb-4 md:px-6">
119
119
  <form onSubmit={handleSubmit} className="relative">
120
120
  <div
121
121
  className={cn(
@@ -16,7 +16,9 @@ function ChatPage({ session, needsSetup, chatId }) {
16
16
  } else {
17
17
  window.history.pushState({}, "", "/");
18
18
  }
19
- setActiveChatId(id);
19
+ setResolvedChatId(null);
20
+ setInitialMessages([]);
21
+ setActiveChatId(id || null);
20
22
  }, []);
21
23
  useEffect(() => {
22
24
  const onPopState = () => {
@@ -26,7 +26,11 @@ export function ChatPage({ session, needsSetup, chatId }) {
26
26
  } else {
27
27
  window.history.pushState({}, '', '/');
28
28
  }
29
- setActiveChatId(id);
29
+ // Clear current chat immediately — unmounts Chat via the
30
+ // {resolvedChatId && ...} guard before the effect runs
31
+ setResolvedChatId(null);
32
+ setInitialMessages([]);
33
+ setActiveChatId(id || null);
30
34
  }, []);
31
35
 
32
36
  // Browser back/forward
@@ -2,7 +2,7 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useChat } from "@ai-sdk/react";
4
4
  import { DefaultChatTransport } from "ai";
5
- import { useState, useEffect, useRef, useMemo } from "react";
5
+ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
6
6
  import { Messages } from "./messages.js";
7
7
  import { ChatInput } from "./chat-input.js";
8
8
  import { ChatHeader } from "./chat-header.js";
@@ -23,7 +23,9 @@ function Chat({ chatId, initialMessages = [] }) {
23
23
  status,
24
24
  stop,
25
25
  error,
26
- sendMessage
26
+ sendMessage,
27
+ regenerate,
28
+ setMessages
27
29
  } = useChat({
28
30
  id: chatId,
29
31
  messages: initialMessages,
@@ -31,13 +33,13 @@ function Chat({ chatId, initialMessages = [] }) {
31
33
  onError: (err) => console.error("Chat error:", err)
32
34
  });
33
35
  useEffect(() => {
34
- if (!hasNavigated.current && messages.length >= 2 && !window.location.pathname.includes(chatId)) {
36
+ if (!hasNavigated.current && messages.length >= 1 && status !== "ready" && window.location.pathname !== `/chat/${chatId}`) {
35
37
  hasNavigated.current = true;
36
38
  window.history.replaceState({}, "", `/chat/${chatId}`);
37
39
  window.dispatchEvent(new Event("chatsupdated"));
38
40
  setTimeout(() => window.dispatchEvent(new Event("chatsupdated")), 5e3);
39
41
  }
40
- }, [messages.length, chatId]);
42
+ }, [messages.length, status, chatId]);
41
43
  const handleSend = () => {
42
44
  if (!input.trim() && files.length === 0) return;
43
45
  const text = input;
@@ -56,9 +58,31 @@ function Chat({ chatId, initialMessages = [] }) {
56
58
  sendMessage({ text: text || void 0, files: fileParts });
57
59
  }
58
60
  };
61
+ const handleRetry = useCallback((message) => {
62
+ if (message.role === "assistant") {
63
+ regenerate({ messageId: message.id });
64
+ } else {
65
+ const idx = messages.findIndex((m) => m.id === message.id);
66
+ const nextAssistant = messages.slice(idx + 1).find((m) => m.role === "assistant");
67
+ if (nextAssistant) {
68
+ regenerate({ messageId: nextAssistant.id });
69
+ } else {
70
+ const text = message.parts?.filter((p) => p.type === "text").map((p) => p.text).join("\n") || message.content || "";
71
+ if (text.trim()) {
72
+ sendMessage({ text });
73
+ }
74
+ }
75
+ }
76
+ }, [messages, regenerate, sendMessage]);
77
+ const handleEdit = useCallback((message, newText) => {
78
+ const idx = messages.findIndex((m) => m.id === message.id);
79
+ if (idx === -1) return;
80
+ setMessages(messages.slice(0, idx));
81
+ sendMessage({ text: newText });
82
+ }, [messages, setMessages, sendMessage]);
59
83
  return /* @__PURE__ */ jsxs("div", { className: "flex h-svh flex-col", children: [
60
84
  /* @__PURE__ */ jsx(ChatHeader, { chatId }),
61
- messages.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col items-center justify-center px-2 md:px-4", children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-4xl", children: [
85
+ messages.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col items-center justify-center px-4 md:px-6", children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-4xl", children: [
62
86
  /* @__PURE__ */ jsx(Greeting, {}),
63
87
  error && /* @__PURE__ */ jsx("div", { className: "mt-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive", children: error.message || "Something went wrong. Please try again." }),
64
88
  /* @__PURE__ */ jsx("div", { className: "mt-4", children: /* @__PURE__ */ jsx(
@@ -74,7 +98,7 @@ function Chat({ chatId, initialMessages = [] }) {
74
98
  }
75
99
  ) })
76
100
  ] }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
77
- /* @__PURE__ */ jsx(Messages, { messages, status }),
101
+ /* @__PURE__ */ jsx(Messages, { messages, status, onRetry: handleRetry, onEdit: handleEdit }),
78
102
  error && /* @__PURE__ */ jsx("div", { className: "mx-auto w-full max-w-4xl px-2 md:px-4", children: /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive", children: error.message || "Something went wrong. Please try again." }) }),
79
103
  /* @__PURE__ */ jsx(
80
104
  ChatInput,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useChat } from '@ai-sdk/react';
4
4
  import { DefaultChatTransport } from 'ai';
5
- import { useState, useEffect, useRef, useMemo } from 'react';
5
+ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
6
6
  import { Messages } from './messages.js';
7
7
  import { ChatInput } from './chat-input.js';
8
8
  import { ChatHeader } from './chat-header.js';
@@ -28,6 +28,8 @@ export function Chat({ chatId, initialMessages = [] }) {
28
28
  stop,
29
29
  error,
30
30
  sendMessage,
31
+ regenerate,
32
+ setMessages,
31
33
  } = useChat({
32
34
  id: chatId,
33
35
  messages: initialMessages,
@@ -35,16 +37,16 @@ export function Chat({ chatId, initialMessages = [] }) {
35
37
  onError: (err) => console.error('Chat error:', err),
36
38
  });
37
39
 
38
- // After first exchange, update URL and notify sidebar
40
+ // After first message sent, update URL and notify sidebar
39
41
  useEffect(() => {
40
- if (!hasNavigated.current && messages.length >= 2 && !window.location.pathname.includes(chatId)) {
42
+ if (!hasNavigated.current && messages.length >= 1 && status !== 'ready' && window.location.pathname !== `/chat/${chatId}`) {
41
43
  hasNavigated.current = true;
42
44
  window.history.replaceState({}, '', `/chat/${chatId}`);
43
45
  window.dispatchEvent(new Event('chatsupdated'));
44
46
  // Dispatch again after delay to pick up async title update
45
47
  setTimeout(() => window.dispatchEvent(new Event('chatsupdated')), 5000);
46
48
  }
47
- }, [messages.length, chatId]);
49
+ }, [messages.length, status, chatId]);
48
50
 
49
51
  const handleSend = () => {
50
52
  if (!input.trim() && files.length === 0) return;
@@ -67,11 +69,44 @@ export function Chat({ chatId, initialMessages = [] }) {
67
69
  }
68
70
  };
69
71
 
72
+ const handleRetry = useCallback((message) => {
73
+ if (message.role === 'assistant') {
74
+ regenerate({ messageId: message.id });
75
+ } else {
76
+ // User message — find the next assistant message and regenerate it
77
+ const idx = messages.findIndex((m) => m.id === message.id);
78
+ const nextAssistant = messages.slice(idx + 1).find((m) => m.role === 'assistant');
79
+ if (nextAssistant) {
80
+ regenerate({ messageId: nextAssistant.id });
81
+ } else {
82
+ // No assistant response yet — extract text and resend
83
+ const text =
84
+ message.parts
85
+ ?.filter((p) => p.type === 'text')
86
+ .map((p) => p.text)
87
+ .join('\n') ||
88
+ message.content ||
89
+ '';
90
+ if (text.trim()) {
91
+ sendMessage({ text });
92
+ }
93
+ }
94
+ }
95
+ }, [messages, regenerate, sendMessage]);
96
+
97
+ const handleEdit = useCallback((message, newText) => {
98
+ const idx = messages.findIndex((m) => m.id === message.id);
99
+ if (idx === -1) return;
100
+ // Truncate conversation to before this message, then send edited text
101
+ setMessages(messages.slice(0, idx));
102
+ sendMessage({ text: newText });
103
+ }, [messages, setMessages, sendMessage]);
104
+
70
105
  return (
71
106
  <div className="flex h-svh flex-col">
72
107
  <ChatHeader chatId={chatId} />
73
108
  {messages.length === 0 ? (
74
- <div className="flex flex-1 flex-col items-center justify-center px-2 md:px-4">
109
+ <div className="flex flex-1 flex-col items-center justify-center px-4 md:px-6">
75
110
  <div className="w-full max-w-4xl">
76
111
  <Greeting />
77
112
  {error && (
@@ -94,7 +129,7 @@ export function Chat({ chatId, initialMessages = [] }) {
94
129
  </div>
95
130
  ) : (
96
131
  <>
97
- <Messages messages={messages} status={status} />
132
+ <Messages messages={messages} status={status} onRetry={handleRetry} onEdit={handleEdit} />
98
133
  {error && (
99
134
  <div className="mx-auto w-full max-w-4xl px-2 md:px-4">
100
135
  <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
@@ -1,10 +1,7 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { jsx } from "react/jsx-runtime";
3
3
  function Greeting() {
4
- return /* @__PURE__ */ jsxs("div", { className: "w-full", children: [
5
- /* @__PURE__ */ jsx("div", { className: "font-semibold text-xl md:text-2xl text-foreground", children: "Hello there!" }),
6
- /* @__PURE__ */ jsx("div", { className: "text-xl text-muted-foreground md:text-2xl", children: "How can I help you today?" })
7
- ] });
4
+ return /* @__PURE__ */ jsx("div", { className: "w-full text-center", children: /* @__PURE__ */ jsx("div", { className: "font-semibold text-2xl md:text-3xl text-foreground", children: "Hello! How can I help?" }) });
8
5
  }
9
6
  export {
10
7
  Greeting
@@ -2,12 +2,9 @@
2
2
 
3
3
  export function Greeting() {
4
4
  return (
5
- <div className="w-full">
6
- <div className="font-semibold text-xl md:text-2xl text-foreground">
7
- Hello there!
8
- </div>
9
- <div className="text-xl text-muted-foreground md:text-2xl">
10
- How can I help you today?
5
+ <div className="w-full text-center">
6
+ <div className="font-semibold text-2xl md:text-3xl text-foreground">
7
+ Hello! How can I help?
11
8
  </div>
12
9
  </div>
13
10
  );
@@ -1,59 +1,179 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState, useRef, useEffect } from "react";
3
4
  import { Streamdown } from "streamdown";
4
5
  import { cn } from "../utils.js";
5
- import { SpinnerIcon, FileTextIcon } from "./icons.js";
6
- function PreviewMessage({ message, isLoading }) {
6
+ import { SpinnerIcon, FileTextIcon, CopyIcon, CheckIcon, RefreshIcon, SquarePenIcon } from "./icons.js";
7
+ function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
7
8
  const isUser = message.role === "user";
9
+ const [copied, setCopied] = useState(false);
10
+ const [editing, setEditing] = useState(false);
11
+ const [editText, setEditText] = useState("");
12
+ const textareaRef = useRef(null);
8
13
  const text = message.parts?.filter((p) => p.type === "text").map((p) => p.text).join("\n") || message.content || "";
9
14
  const fileParts = message.parts?.filter((p) => p.type === "file") || [];
10
15
  const imageParts = fileParts.filter((p) => p.mediaType?.startsWith("image/"));
11
16
  const otherFileParts = fileParts.filter((p) => !p.mediaType?.startsWith("image/"));
17
+ const handleCopy = async () => {
18
+ try {
19
+ await navigator.clipboard.writeText(text);
20
+ setCopied(true);
21
+ setTimeout(() => setCopied(false), 2e3);
22
+ } catch {
23
+ }
24
+ };
25
+ const handleEditStart = () => {
26
+ setEditText(text);
27
+ setEditing(true);
28
+ };
29
+ const handleEditCancel = () => {
30
+ setEditing(false);
31
+ setEditText("");
32
+ };
33
+ const handleEditSubmit = () => {
34
+ const trimmed = editText.trim();
35
+ if (trimmed && trimmed !== text) {
36
+ onEdit?.(message, trimmed);
37
+ }
38
+ setEditing(false);
39
+ setEditText("");
40
+ };
41
+ useEffect(() => {
42
+ if (editing && textareaRef.current) {
43
+ const ta = textareaRef.current;
44
+ ta.focus();
45
+ ta.style.height = "auto";
46
+ ta.style.height = `${ta.scrollHeight}px`;
47
+ ta.setSelectionRange(ta.value.length, ta.value.length);
48
+ }
49
+ }, [editing]);
12
50
  return /* @__PURE__ */ jsx(
13
51
  "div",
14
52
  {
15
53
  className: cn(
16
- "flex gap-4 w-full",
54
+ "group flex gap-4 w-full",
17
55
  isUser ? "justify-end" : "justify-start"
18
56
  ),
19
- children: /* @__PURE__ */ jsxs(
20
- "div",
21
- {
22
- className: cn(
23
- "max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed",
24
- isUser ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"
57
+ children: /* @__PURE__ */ jsx("div", { className: "flex flex-col max-w-[80%]", children: editing ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
58
+ /* @__PURE__ */ jsx(
59
+ "textarea",
60
+ {
61
+ ref: textareaRef,
62
+ value: editText,
63
+ onChange: (e) => {
64
+ setEditText(e.target.value);
65
+ e.target.style.height = "auto";
66
+ e.target.style.height = `${e.target.scrollHeight}px`;
67
+ },
68
+ onKeyDown: (e) => {
69
+ if (e.key === "Enter" && !e.shiftKey) {
70
+ e.preventDefault();
71
+ handleEditSubmit();
72
+ }
73
+ if (e.key === "Escape") {
74
+ handleEditCancel();
75
+ }
76
+ },
77
+ className: "w-full resize-none rounded-xl border border-border bg-muted px-4 py-3 text-sm leading-relaxed text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
78
+ rows: 1
79
+ }
80
+ ),
81
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
82
+ /* @__PURE__ */ jsx(
83
+ "button",
84
+ {
85
+ onClick: handleEditCancel,
86
+ className: "rounded-md px-3 py-1 text-xs text-muted-foreground hover:text-foreground",
87
+ children: "Cancel"
88
+ }
25
89
  ),
26
- children: [
27
- imageParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: imageParts.map((part, i) => /* @__PURE__ */ jsx(
28
- "img",
29
- {
30
- src: part.url,
31
- alt: "attachment",
32
- className: "max-h-64 max-w-full rounded-lg object-contain"
33
- },
34
- i
35
- )) }),
36
- otherFileParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: otherFileParts.map((part, i) => /* @__PURE__ */ jsxs(
37
- "div",
38
- {
39
- className: cn(
40
- "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
41
- isUser ? "bg-primary-foreground/20" : "bg-foreground/10"
42
- ),
43
- children: [
44
- /* @__PURE__ */ jsx(FileTextIcon, { size: 12 }),
45
- /* @__PURE__ */ jsx("span", { className: "max-w-[150px] truncate", children: part.name || part.mediaType || "file" })
46
- ]
47
- },
48
- i
49
- )) }),
50
- text ? isUser ? /* @__PURE__ */ jsx("div", { className: "whitespace-pre-wrap break-words", children: text }) : /* @__PURE__ */ jsx(Streamdown, { mode: isLoading ? "streaming" : "static", children: text }) : isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
51
- /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }),
52
- /* @__PURE__ */ jsx("span", { children: "Working..." })
53
- ] }) : null
54
- ]
55
- }
56
- )
90
+ /* @__PURE__ */ jsx(
91
+ "button",
92
+ {
93
+ onClick: handleEditSubmit,
94
+ className: "rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-80",
95
+ children: "Send"
96
+ }
97
+ )
98
+ ] })
99
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
100
+ /* @__PURE__ */ jsxs(
101
+ "div",
102
+ {
103
+ className: cn(
104
+ "rounded-xl px-4 py-3 text-sm leading-relaxed",
105
+ isUser ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"
106
+ ),
107
+ children: [
108
+ imageParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: imageParts.map((part, i) => /* @__PURE__ */ jsx(
109
+ "img",
110
+ {
111
+ src: part.url,
112
+ alt: "attachment",
113
+ className: "max-h-64 max-w-full rounded-lg object-contain"
114
+ },
115
+ i
116
+ )) }),
117
+ otherFileParts.length > 0 && /* @__PURE__ */ jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: otherFileParts.map((part, i) => /* @__PURE__ */ jsxs(
118
+ "div",
119
+ {
120
+ className: cn(
121
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
122
+ isUser ? "bg-primary-foreground/20" : "bg-foreground/10"
123
+ ),
124
+ children: [
125
+ /* @__PURE__ */ jsx(FileTextIcon, { size: 12 }),
126
+ /* @__PURE__ */ jsx("span", { className: "max-w-[150px] truncate", children: part.name || part.mediaType || "file" })
127
+ ]
128
+ },
129
+ i
130
+ )) }),
131
+ text ? isUser ? /* @__PURE__ */ jsx("div", { className: "whitespace-pre-wrap break-words", children: text }) : /* @__PURE__ */ jsx(Streamdown, { mode: isLoading ? "streaming" : "static", children: text }) : isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
132
+ /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }),
133
+ /* @__PURE__ */ jsx("span", { children: "Working..." })
134
+ ] }) : null
135
+ ]
136
+ }
137
+ ),
138
+ !isLoading && text && /* @__PURE__ */ jsxs(
139
+ "div",
140
+ {
141
+ className: cn(
142
+ "flex gap-1 mt-1 opacity-0 transition-opacity group-hover:opacity-100",
143
+ isUser ? "justify-end" : "justify-start"
144
+ ),
145
+ children: [
146
+ /* @__PURE__ */ jsx(
147
+ "button",
148
+ {
149
+ onClick: handleCopy,
150
+ className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
151
+ "aria-label": "Copy message",
152
+ children: copied ? /* @__PURE__ */ jsx(CheckIcon, { size: 14 }) : /* @__PURE__ */ jsx(CopyIcon, { size: 14 })
153
+ }
154
+ ),
155
+ onRetry && /* @__PURE__ */ jsx(
156
+ "button",
157
+ {
158
+ onClick: () => onRetry(message),
159
+ className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
160
+ "aria-label": "Retry",
161
+ children: /* @__PURE__ */ jsx(RefreshIcon, { size: 14 })
162
+ }
163
+ ),
164
+ isUser && onEdit && /* @__PURE__ */ jsx(
165
+ "button",
166
+ {
167
+ onClick: handleEditStart,
168
+ className: "rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted",
169
+ "aria-label": "Edit message",
170
+ children: /* @__PURE__ */ jsx(SquarePenIcon, { size: 14 })
171
+ }
172
+ )
173
+ ]
174
+ }
175
+ )
176
+ ] }) })
57
177
  }
58
178
  );
59
179
  }
@@ -1,11 +1,16 @@
1
1
  'use client';
2
2
 
3
+ import { useState, useRef, useEffect } from 'react';
3
4
  import { Streamdown } from 'streamdown';
4
5
  import { cn } from '../utils.js';
5
- import { SpinnerIcon, FileTextIcon } from './icons.js';
6
+ import { SpinnerIcon, FileTextIcon, CopyIcon, CheckIcon, RefreshIcon, SquarePenIcon } from './icons.js';
6
7
 
7
- export function PreviewMessage({ message, isLoading }) {
8
+ export function PreviewMessage({ message, isLoading, onRetry, onEdit }) {
8
9
  const isUser = message.role === 'user';
10
+ const [copied, setCopied] = useState(false);
11
+ const [editing, setEditing] = useState(false);
12
+ const [editText, setEditText] = useState('');
13
+ const textareaRef = useRef(null);
9
14
 
10
15
  // Extract text from parts (AI SDK v5+) or fall back to content
11
16
  const text =
@@ -21,65 +26,183 @@ export function PreviewMessage({ message, isLoading }) {
21
26
  const imageParts = fileParts.filter((p) => p.mediaType?.startsWith('image/'));
22
27
  const otherFileParts = fileParts.filter((p) => !p.mediaType?.startsWith('image/'));
23
28
 
29
+ const handleCopy = async () => {
30
+ try {
31
+ await navigator.clipboard.writeText(text);
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2000);
34
+ } catch {}
35
+ };
36
+
37
+ const handleEditStart = () => {
38
+ setEditText(text);
39
+ setEditing(true);
40
+ };
41
+
42
+ const handleEditCancel = () => {
43
+ setEditing(false);
44
+ setEditText('');
45
+ };
46
+
47
+ const handleEditSubmit = () => {
48
+ const trimmed = editText.trim();
49
+ if (trimmed && trimmed !== text) {
50
+ onEdit?.(message, trimmed);
51
+ }
52
+ setEditing(false);
53
+ setEditText('');
54
+ };
55
+
56
+ // Auto-resize and focus textarea when entering edit mode
57
+ useEffect(() => {
58
+ if (editing && textareaRef.current) {
59
+ const ta = textareaRef.current;
60
+ ta.focus();
61
+ ta.style.height = 'auto';
62
+ ta.style.height = `${ta.scrollHeight}px`;
63
+ // Move cursor to end
64
+ ta.setSelectionRange(ta.value.length, ta.value.length);
65
+ }
66
+ }, [editing]);
67
+
24
68
  return (
25
69
  <div
26
70
  className={cn(
27
- 'flex gap-4 w-full',
71
+ 'group flex gap-4 w-full',
28
72
  isUser ? 'justify-end' : 'justify-start'
29
73
  )}
30
74
  >
31
- <div
32
- className={cn(
33
- 'max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed',
34
- isUser
35
- ? 'bg-primary text-primary-foreground'
36
- : 'bg-muted text-foreground'
37
- )}
38
- >
39
- {imageParts.length > 0 && (
40
- <div className="mb-2 flex flex-wrap gap-2">
41
- {imageParts.map((part, i) => (
42
- <img
43
- key={i}
44
- src={part.url}
45
- alt="attachment"
46
- className="max-h-64 max-w-full rounded-lg object-contain"
47
- />
48
- ))}
75
+ <div className="flex flex-col max-w-[80%]">
76
+ {editing ? (
77
+ <div className="flex flex-col gap-2">
78
+ <textarea
79
+ ref={textareaRef}
80
+ value={editText}
81
+ onChange={(e) => {
82
+ setEditText(e.target.value);
83
+ e.target.style.height = 'auto';
84
+ e.target.style.height = `${e.target.scrollHeight}px`;
85
+ }}
86
+ onKeyDown={(e) => {
87
+ if (e.key === 'Enter' && !e.shiftKey) {
88
+ e.preventDefault();
89
+ handleEditSubmit();
90
+ }
91
+ if (e.key === 'Escape') {
92
+ handleEditCancel();
93
+ }
94
+ }}
95
+ className="w-full resize-none rounded-xl border border-border bg-muted px-4 py-3 text-sm leading-relaxed text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
96
+ rows={1}
97
+ />
98
+ <div className="flex justify-end gap-2">
99
+ <button
100
+ onClick={handleEditCancel}
101
+ className="rounded-md px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
102
+ >
103
+ Cancel
104
+ </button>
105
+ <button
106
+ onClick={handleEditSubmit}
107
+ className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-80"
108
+ >
109
+ Send
110
+ </button>
111
+ </div>
49
112
  </div>
50
- )}
51
- {otherFileParts.length > 0 && (
52
- <div className="mb-2 flex flex-wrap gap-2">
53
- {otherFileParts.map((part, i) => (
113
+ ) : (
114
+ <>
115
+ <div
116
+ className={cn(
117
+ 'rounded-xl px-4 py-3 text-sm leading-relaxed',
118
+ isUser
119
+ ? 'bg-primary text-primary-foreground'
120
+ : 'bg-muted text-foreground'
121
+ )}
122
+ >
123
+ {imageParts.length > 0 && (
124
+ <div className="mb-2 flex flex-wrap gap-2">
125
+ {imageParts.map((part, i) => (
126
+ <img
127
+ key={i}
128
+ src={part.url}
129
+ alt="attachment"
130
+ className="max-h-64 max-w-full rounded-lg object-contain"
131
+ />
132
+ ))}
133
+ </div>
134
+ )}
135
+ {otherFileParts.length > 0 && (
136
+ <div className="mb-2 flex flex-wrap gap-2">
137
+ {otherFileParts.map((part, i) => (
138
+ <div
139
+ key={i}
140
+ className={cn(
141
+ 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
142
+ isUser
143
+ ? 'bg-primary-foreground/20'
144
+ : 'bg-foreground/10'
145
+ )}
146
+ >
147
+ <FileTextIcon size={12} />
148
+ <span className="max-w-[150px] truncate">
149
+ {part.name || part.mediaType || 'file'}
150
+ </span>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ )}
155
+ {text ? (
156
+ isUser ? (
157
+ <div className="whitespace-pre-wrap break-words">{text}</div>
158
+ ) : (
159
+ <Streamdown mode={isLoading ? 'streaming' : 'static'}>{text}</Streamdown>
160
+ )
161
+ ) : isLoading ? (
162
+ <div className="flex items-center gap-2 text-muted-foreground">
163
+ <SpinnerIcon size={14} />
164
+ <span>Working...</span>
165
+ </div>
166
+ ) : null}
167
+ </div>
168
+
169
+ {/* Action toolbar */}
170
+ {!isLoading && text && (
54
171
  <div
55
- key={i}
56
172
  className={cn(
57
- 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
58
- isUser
59
- ? 'bg-primary-foreground/20'
60
- : 'bg-foreground/10'
173
+ 'flex gap-1 mt-1 opacity-0 transition-opacity group-hover:opacity-100',
174
+ isUser ? 'justify-end' : 'justify-start'
61
175
  )}
62
176
  >
63
- <FileTextIcon size={12} />
64
- <span className="max-w-[150px] truncate">
65
- {part.name || part.mediaType || 'file'}
66
- </span>
177
+ <button
178
+ onClick={handleCopy}
179
+ className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
180
+ aria-label="Copy message"
181
+ >
182
+ {copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
183
+ </button>
184
+ {onRetry && (
185
+ <button
186
+ onClick={() => onRetry(message)}
187
+ className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
188
+ aria-label="Retry"
189
+ >
190
+ <RefreshIcon size={14} />
191
+ </button>
192
+ )}
193
+ {isUser && onEdit && (
194
+ <button
195
+ onClick={handleEditStart}
196
+ className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-muted"
197
+ aria-label="Edit message"
198
+ >
199
+ <SquarePenIcon size={14} />
200
+ </button>
201
+ )}
67
202
  </div>
68
- ))}
69
- </div>
203
+ )}
204
+ </>
70
205
  )}
71
- {text ? (
72
- isUser ? (
73
- <div className="whitespace-pre-wrap break-words">{text}</div>
74
- ) : (
75
- <Streamdown mode={isLoading ? 'streaming' : 'static'}>{text}</Streamdown>
76
- )
77
- ) : isLoading ? (
78
- <div className="flex items-center gap-2 text-muted-foreground">
79
- <SpinnerIcon size={14} />
80
- <span>Working...</span>
81
- </div>
82
- ) : null}
83
206
  </div>
84
207
  </div>
85
208
  );
@@ -4,7 +4,7 @@ import { useRef, useEffect, useState } from "react";
4
4
  import { PreviewMessage, ThinkingMessage } from "./message.js";
5
5
  import { Greeting } from "./greeting.js";
6
6
  import { ArrowDown } from "lucide-react";
7
- function Messages({ messages, status }) {
7
+ function Messages({ messages, status, onRetry, onEdit }) {
8
8
  const containerRef = useRef(null);
9
9
  const endRef = useRef(null);
10
10
  const [isAtBottom, setIsAtBottom] = useState(true);
@@ -32,13 +32,15 @@ function Messages({ messages, status }) {
32
32
  {
33
33
  className: "absolute inset-0 touch-pan-y overflow-y-auto",
34
34
  ref: containerRef,
35
- children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4", children: [
35
+ children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-4 py-4 md:gap-6 md:px-6", children: [
36
36
  messages.length === 0 && /* @__PURE__ */ jsx(Greeting, {}),
37
37
  messages.map((message, index) => /* @__PURE__ */ jsx(
38
38
  PreviewMessage,
39
39
  {
40
40
  message,
41
- isLoading: status === "streaming" && index === messages.length - 1
41
+ isLoading: status === "streaming" && index === messages.length - 1,
42
+ onRetry,
43
+ onEdit
42
44
  },
43
45
  message.id
44
46
  )),
@@ -5,7 +5,7 @@ import { PreviewMessage, ThinkingMessage } from './message.js';
5
5
  import { Greeting } from './greeting.js';
6
6
  import { ArrowDown } from 'lucide-react';
7
7
 
8
- export function Messages({ messages, status }) {
8
+ export function Messages({ messages, status, onRetry, onEdit }) {
9
9
  const containerRef = useRef(null);
10
10
  const endRef = useRef(null);
11
11
  const [isAtBottom, setIsAtBottom] = useState(true);
@@ -41,7 +41,7 @@ export function Messages({ messages, status }) {
41
41
  className="absolute inset-0 touch-pan-y overflow-y-auto"
42
42
  ref={containerRef}
43
43
  >
44
- <div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
44
+ <div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-4 py-4 md:gap-6 md:px-6">
45
45
  {messages.length === 0 && <Greeting />}
46
46
 
47
47
  {messages.map((message, index) => (
@@ -49,6 +49,8 @@ export function Messages({ messages, status }) {
49
49
  key={message.id}
50
50
  message={message}
51
51
  isLoading={status === 'streaming' && index === messages.length - 1}
52
+ onRetry={onRetry}
53
+ onEdit={onEdit}
52
54
  />
53
55
  ))}
54
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.33",
3
+ "version": "1.2.35",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {