iris-chatbot 5.4.0 → 5.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "5.4.0",
9
+ "version": "5.5.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -152,6 +152,75 @@ button:focus-visible {
152
152
  gap: 8px;
153
153
  }
154
154
 
155
+ .user-edit-shell {
156
+ display: flex;
157
+ flex-direction: column;
158
+ gap: 14px;
159
+ width: 100%;
160
+ }
161
+
162
+ .user-edit-textarea {
163
+ width: 100%;
164
+ min-height: 110px;
165
+ border: none;
166
+ background: transparent;
167
+ color: inherit;
168
+ line-height: 1.45;
169
+ resize: none;
170
+ outline: none;
171
+ }
172
+
173
+ .user-edit-textarea:focus,
174
+ .user-edit-textarea:focus-visible {
175
+ outline: none;
176
+ border-color: transparent;
177
+ box-shadow: none;
178
+ }
179
+
180
+ .user-edit-textarea::placeholder {
181
+ color: color-mix(in srgb, currentColor 65%, transparent);
182
+ }
183
+
184
+ .user-edit-actions {
185
+ display: flex;
186
+ justify-content: flex-end;
187
+ gap: 10px;
188
+ }
189
+
190
+ .user-edit-cancel,
191
+ .user-edit-send {
192
+ height: 40px;
193
+ border-radius: 999px;
194
+ padding: 0 22px;
195
+ font-size: 14px;
196
+ border: 1px solid transparent;
197
+ transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease;
198
+ }
199
+
200
+ .user-edit-cancel {
201
+ background: rgba(22, 24, 28, 0.82);
202
+ color: #f2f4f8;
203
+ }
204
+
205
+ .user-edit-cancel:hover {
206
+ background: rgba(22, 24, 28, 0.95);
207
+ }
208
+
209
+ .user-edit-send {
210
+ background: #f2f2f2;
211
+ color: #101010;
212
+ }
213
+
214
+ .user-edit-send:hover {
215
+ background: #ffffff;
216
+ }
217
+
218
+ .user-edit-cancel:disabled,
219
+ .user-edit-send:disabled {
220
+ opacity: 0.65;
221
+ cursor: not-allowed;
222
+ }
223
+
155
224
  .user-attachment-row {
156
225
  display: flex;
157
226
  flex-wrap: wrap;
@@ -369,6 +369,13 @@ type PendingAttachment = {
369
369
  kind: ChatAttachment["kind"];
370
370
  };
371
371
 
372
+ type HandleSendOptions = {
373
+ parentMessageId?: string | null;
374
+ historyHeadMessageId?: string | null;
375
+ preUploadedAttachments?: ChatAttachment[];
376
+ suppressComposerReset?: boolean;
377
+ };
378
+
372
379
  export default function ChatView({
373
380
  thread,
374
381
  messages,
@@ -1175,19 +1182,23 @@ export default function ChatView({
1175
1182
  };
1176
1183
  }, [handleTextSelection]);
1177
1184
 
1178
- const handleSend = useCallback(async (overrideInput?: string) => {
1185
+ const handleSend = useCallback(async (overrideInput?: string, options?: HandleSendOptions) => {
1179
1186
  const resolvedThread =
1180
1187
  thread || (activeThreadId ? await db.threads.get(activeThreadId) : null);
1181
1188
  if (!resolvedThread) return;
1182
1189
  const trimmed = (overrideInput ?? input).trim();
1183
- const hasQuoted = Boolean(quotedContext);
1184
- const pendingComposerAttachments = pendingAttachments;
1190
+ const resetComposer = options?.suppressComposerReset !== true;
1191
+ const hasQuoted = resetComposer && Boolean(quotedContext);
1192
+ const pendingComposerAttachments = options?.preUploadedAttachments ? [] : pendingAttachments;
1193
+ const preUploadedAttachments = normalizeChatAttachments(options?.preUploadedAttachments);
1194
+ const hasPreUploadedAttachments = preUploadedAttachments.length > 0;
1185
1195
  const hasPendingAttachments = pendingComposerAttachments.length > 0;
1186
- if (!trimmed && !hasQuoted && !hasPendingAttachments) return;
1187
- if (isUploadingAttachments) return;
1196
+ if (!trimmed && !hasQuoted && !hasPendingAttachments && !hasPreUploadedAttachments) return;
1197
+ if (isUploadingAttachments && hasPendingAttachments) return;
1188
1198
  let draftEditContextForRequest: PendingDraftContext | null = null;
1189
1199
  const sendTriggeredByMic =
1190
- typeof overrideInput === "string" || micModeRef.current !== "off";
1200
+ !options &&
1201
+ (typeof overrideInput === "string" || micModeRef.current !== "off");
1191
1202
  const stopMicAfterValidationFailure = () => {
1192
1203
  if (!sendTriggeredByMic) {
1193
1204
  return;
@@ -1222,7 +1233,10 @@ export default function ChatView({
1222
1233
  }
1223
1234
 
1224
1235
  const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
1225
- const currentPathMessages = buildPath(resolvedThread.headMessageId, messageMap);
1236
+ const currentPathMessages = buildPath(
1237
+ options?.historyHeadMessageId ?? resolvedThread.headMessageId,
1238
+ messageMap,
1239
+ );
1226
1240
  const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
1227
1241
  for (const message of currentPathMessages) {
1228
1242
  const attachments = normalizeChatAttachments(message.attachments);
@@ -1243,7 +1257,7 @@ export default function ChatView({
1243
1257
  stopMicAfterValidationFailure();
1244
1258
  return;
1245
1259
  }
1246
- if (hasPendingAttachments && !connectionAttachmentProvider) {
1260
+ if ((hasPendingAttachments || hasPreUploadedAttachments) && !connectionAttachmentProvider) {
1247
1261
  setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
1248
1262
  stopMicAfterValidationFailure();
1249
1263
  return;
@@ -1305,7 +1319,7 @@ export default function ChatView({
1305
1319
  return;
1306
1320
  }
1307
1321
 
1308
- let uploadedAttachments: ChatAttachment[] = [];
1322
+ let uploadedAttachments: ChatAttachment[] = preUploadedAttachments;
1309
1323
  if (pendingComposerAttachments.length > 0) {
1310
1324
  setIsUploadingAttachments(true);
1311
1325
  try {
@@ -1326,10 +1340,11 @@ export default function ChatView({
1326
1340
  if (!uploadResponse.ok) {
1327
1341
  throw new Error(uploadPayload.error || "Failed to upload attachments.");
1328
1342
  }
1329
- uploadedAttachments = normalizeChatAttachments(uploadPayload.attachments);
1330
- if (uploadedAttachments.length !== pendingComposerAttachments.length) {
1343
+ const newAttachments = normalizeChatAttachments(uploadPayload.attachments);
1344
+ if (newAttachments.length !== pendingComposerAttachments.length) {
1331
1345
  throw new Error("One or more attachments were rejected during upload.");
1332
1346
  }
1347
+ uploadedAttachments = [...uploadedAttachments, ...newAttachments];
1333
1348
  if (
1334
1349
  connectionAttachmentProvider &&
1335
1350
  uploadedAttachments.some(
@@ -1354,9 +1369,11 @@ export default function ChatView({
1354
1369
  (inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) + 1,
1355
1370
  );
1356
1371
  setError(null);
1357
- setInput("");
1358
- const capturedQuotedContext = quotedContext;
1359
- setQuotedContext(null);
1372
+ if (resetComposer) {
1373
+ setInput("");
1374
+ setQuotedContext(null);
1375
+ }
1376
+ const capturedQuotedContext = hasQuoted ? quotedContext : null;
1360
1377
  setFocusedMessageId(null);
1361
1378
  pendingSendScrollThreadIdRef.current = resolvedThread.id;
1362
1379
  isUserNearBottomRef.current = true;
@@ -1371,16 +1388,19 @@ export default function ChatView({
1371
1388
  : `Explain this: "${capturedQuotedContext}"`
1372
1389
  : trimmed;
1373
1390
 
1374
- const isFirstMessage = resolvedThread.title === "New chat";
1391
+ const isFirstMessage =
1392
+ resolvedThread.title === "New chat" &&
1393
+ (options?.parentMessageId ?? resolvedThread.headMessageId) === null;
1375
1394
 
1376
1395
  const { userMessage, assistantMessage } = await appendUserAndAssistant({
1377
1396
  thread: resolvedThread,
1378
1397
  content: contentToSend,
1379
1398
  attachments: uploadedAttachments,
1399
+ parentId: options?.parentMessageId,
1380
1400
  provider: connection.kind === "builtin" ? connection.provider ?? connection.id : connection.id,
1381
1401
  model,
1382
1402
  });
1383
- if (pendingComposerAttachments.length > 0) {
1403
+ if (resetComposer && pendingComposerAttachments.length > 0) {
1384
1404
  const sentAttachmentIds = new Set(
1385
1405
  pendingComposerAttachments.map((attachment) => attachment.id),
1386
1406
  );
@@ -1966,6 +1986,116 @@ export default function ChatView({
1966
1986
  toolEventsByMessage,
1967
1987
  ]);
1968
1988
 
1989
+ const handleResendEditedUserMessage = useCallback(
1990
+ async (messageId: string, nextContent: string) => {
1991
+ const resolvedThread =
1992
+ thread || (activeThreadId ? await db.threads.get(activeThreadId) : null);
1993
+ if (!resolvedThread) {
1994
+ return false;
1995
+ }
1996
+
1997
+ const targetMessage = messageMap.get(messageId);
1998
+ if (!targetMessage || targetMessage.role !== "user") {
1999
+ setError("Could not edit that message.");
2000
+ return false;
2001
+ }
2002
+
2003
+ const isMessageInActivePath = threadMessages.some((message) => message.id === messageId);
2004
+ if (!isMessageInActivePath) {
2005
+ setError("That message is not in the active thread.");
2006
+ return false;
2007
+ }
2008
+
2009
+ const trimmedContent = nextContent.trim();
2010
+ const existingAttachments = normalizeChatAttachments(targetMessage.attachments);
2011
+ if (!trimmedContent && existingAttachments.length === 0) {
2012
+ setError("Message cannot be empty.");
2013
+ return false;
2014
+ }
2015
+
2016
+ if (!settings) {
2017
+ setError("Settings not loaded yet.");
2018
+ return false;
2019
+ }
2020
+
2021
+ if (!connection) {
2022
+ setError("Select a model connection before sending a message.");
2023
+ onOpenSettings();
2024
+ return false;
2025
+ }
2026
+
2027
+ const connectionPayload = toChatConnectionPayload(connection, settings);
2028
+ const requiresApiKey =
2029
+ connectionPayload.kind === "builtin" &&
2030
+ (connectionPayload.provider === "openai" ||
2031
+ connectionPayload.provider === "anthropic" ||
2032
+ connectionPayload.provider === "google");
2033
+ if (requiresApiKey && !connectionPayload.apiKey) {
2034
+ setError("Add an API key in Settings before sending a message.");
2035
+ onOpenSettings();
2036
+ return false;
2037
+ }
2038
+
2039
+ const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
2040
+ const currentPathMessages = buildPath(messageId, messageMap);
2041
+ const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
2042
+ for (const message of currentPathMessages) {
2043
+ const attachments = normalizeChatAttachments(message.attachments);
2044
+ for (const attachment of attachments) {
2045
+ existingAttachmentProviders.add(attachment.provider);
2046
+ }
2047
+ }
2048
+ if (existingAttachmentProviders.size > 1) {
2049
+ setError("This thread has mixed attachment providers. Start a new chat to continue.");
2050
+ return false;
2051
+ }
2052
+ const existingProvider = [...existingAttachmentProviders][0] ?? null;
2053
+ if (existingProvider && connectionAttachmentProvider !== existingProvider) {
2054
+ setError(
2055
+ `This thread has ${existingProvider} attachments. Switch back to that provider or start a new chat.`,
2056
+ );
2057
+ return false;
2058
+ }
2059
+ if (existingAttachments.length > 0 && !connectionAttachmentProvider) {
2060
+ setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
2061
+ return false;
2062
+ }
2063
+
2064
+ if (!model) {
2065
+ setError("Select a model before sending a message.");
2066
+ return false;
2067
+ }
2068
+
2069
+ const threadLocked =
2070
+ Boolean(streamingByThreadRef.current[resolvedThread.id]) ||
2071
+ (inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) > 0;
2072
+ if (threadLocked || isUploadingAttachments) {
2073
+ setError("This thread is already responding.");
2074
+ return false;
2075
+ }
2076
+
2077
+ void handleSend(trimmedContent, {
2078
+ parentMessageId: targetMessage.parentId,
2079
+ historyHeadMessageId: messageId,
2080
+ preUploadedAttachments: existingAttachments,
2081
+ suppressComposerReset: true,
2082
+ });
2083
+ return true;
2084
+ },
2085
+ [
2086
+ thread,
2087
+ activeThreadId,
2088
+ settings,
2089
+ connection,
2090
+ model,
2091
+ onOpenSettings,
2092
+ messageMap,
2093
+ threadMessages,
2094
+ isUploadingAttachments,
2095
+ handleSend,
2096
+ ],
2097
+ );
2098
+
1969
2099
  useEffect(() => {
1970
2100
  handleSendRef.current = handleSend;
1971
2101
  }, [handleSend]);
@@ -2054,6 +2184,7 @@ export default function ChatView({
2054
2184
  approvals={messageApprovals}
2055
2185
  onResolveApproval={handleApprovalDecision}
2056
2186
  approvalBusyIds={approvalBusyIds}
2187
+ onResendEditedUserMessage={handleResendEditedUserMessage}
2057
2188
  />
2058
2189
  </div>
2059
2190
  );
@@ -2072,6 +2203,7 @@ export default function ChatView({
2072
2203
  toolApprovalsByMessage,
2073
2204
  handleApprovalDecision,
2074
2205
  approvalBusyIds,
2206
+ handleResendEditedUserMessage,
2075
2207
  streamDraftByMessageId,
2076
2208
  ]
2077
2209
  );
@@ -14,7 +14,7 @@ import {
14
14
  PlusCircle,
15
15
  X,
16
16
  } from "lucide-react";
17
- import { memo, useMemo, useRef, useState } from "react";
17
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
18
18
  import type {
19
19
  ChatCitationSource,
20
20
  MessageNode,
@@ -118,6 +118,10 @@ function normalizeMarkdownStructure(content: string) {
118
118
  return normalized.join("\n").replace(/\n{3,}/g, "\n\n").trim();
119
119
  }
120
120
 
121
+ function normalizeUserTextForEditor(content: string): string {
122
+ return content.replace(/\s+/g, " ").trim();
123
+ }
124
+
121
125
  function stabilizeStreamingMarkdown(content: string) {
122
126
  let stable = content;
123
127
  const fenceMatches = stable.match(/```/g);
@@ -1242,6 +1246,7 @@ function MessageCard({
1242
1246
  approvals,
1243
1247
  onResolveApproval,
1244
1248
  approvalBusyIds,
1249
+ onResendEditedUserMessage,
1245
1250
  }: {
1246
1251
  message: MessageNode;
1247
1252
  onAddThread: (message: MessageNode) => void;
@@ -1259,12 +1264,20 @@ function MessageCard({
1259
1264
  argsOverride?: Record<string, unknown>,
1260
1265
  ) => void;
1261
1266
  approvalBusyIds?: Record<string, boolean>;
1267
+ onResendEditedUserMessage?: (messageId: string, content: string) => Promise<boolean>;
1262
1268
  }) {
1263
1269
  const [copied, setCopied] = useState(false);
1264
1270
  const [threadAdded, setThreadAdded] = useState(false);
1265
1271
  const [assistantCollapsed, setAssistantCollapsed] = useState(false);
1266
1272
  const [threadEditMode, setThreadEditMode] = useState(false);
1273
+ const [isEditingUserMessage, setIsEditingUserMessage] = useState(false);
1274
+ const [editedUserContent, setEditedUserContent] = useState("");
1275
+ const [isSubmittingUserEdit, setIsSubmittingUserEdit] = useState(false);
1276
+ const [userEditWidthPx, setUserEditWidthPx] = useState<number | null>(null);
1277
+ const userMessageCardRef = useRef<HTMLDivElement | null>(null);
1278
+ const userEditTextareaRef = useRef<HTMLTextAreaElement | null>(null);
1267
1279
  const isAssistant = message.role === "assistant";
1280
+ const canEditUserMessage = !isAssistant && Boolean(onResendEditedUserMessage);
1268
1281
  const canAddThread = threads.length < 4;
1269
1282
  const shelfThreads = [
1270
1283
  ...(baseThreadId ? [{ id: baseThreadId, title: "Thread 1", isBase: true }] : []),
@@ -1284,6 +1297,8 @@ function MessageCard({
1284
1297
  () => (message.role === "user" ? normalizeChatAttachments(message.attachments) : []),
1285
1298
  [message.role, message.attachments],
1286
1299
  );
1300
+ const canSubmitUserEdit =
1301
+ editedUserContent.trim().length > 0 || userAttachments.length > 0;
1287
1302
  const sourcesOrderedByCitation = useMemo(
1288
1303
  () => reorderSourcesByCitationOrder(messageTextContent, messageSources),
1289
1304
  [messageTextContent, messageSources],
@@ -1342,9 +1357,65 @@ function MessageCard({
1342
1357
  return "Working on your request.";
1343
1358
  }, [messageTextContent, timelineItems, approvalItems]);
1344
1359
 
1360
+ useEffect(() => {
1361
+ if (!isEditingUserMessage) {
1362
+ return;
1363
+ }
1364
+ requestAnimationFrame(() => {
1365
+ const element = userEditTextareaRef.current;
1366
+ if (!element) {
1367
+ return;
1368
+ }
1369
+ element.focus();
1370
+ const end = element.value.length;
1371
+ element.setSelectionRange(end, end);
1372
+ });
1373
+ }, [isEditingUserMessage]);
1374
+
1375
+ const startUserMessageEdit = () => {
1376
+ const cardWidth = userMessageCardRef.current?.getBoundingClientRect().width ?? null;
1377
+ setUserEditWidthPx(cardWidth && Number.isFinite(cardWidth) ? Math.max(0, Math.round(cardWidth)) : null);
1378
+ setEditedUserContent(normalizeUserTextForEditor(messageTextContent));
1379
+ setIsEditingUserMessage(true);
1380
+ };
1381
+
1382
+ const cancelUserMessageEdit = () => {
1383
+ if (isSubmittingUserEdit) {
1384
+ return;
1385
+ }
1386
+ setIsEditingUserMessage(false);
1387
+ setEditedUserContent("");
1388
+ setUserEditWidthPx(null);
1389
+ };
1390
+
1391
+ const submitUserMessageEdit = async () => {
1392
+ if (!onResendEditedUserMessage || isSubmittingUserEdit || !canSubmitUserEdit) {
1393
+ return;
1394
+ }
1395
+ setIsSubmittingUserEdit(true);
1396
+ try {
1397
+ const success = await onResendEditedUserMessage(message.id, editedUserContent);
1398
+ if (success) {
1399
+ setIsEditingUserMessage(false);
1400
+ setEditedUserContent("");
1401
+ setUserEditWidthPx(null);
1402
+ }
1403
+ } finally {
1404
+ setIsSubmittingUserEdit(false);
1405
+ }
1406
+ };
1407
+
1345
1408
  return (
1346
1409
  <div className="message-stack group">
1347
- <div className={`${isAssistant ? "assistant-card" : "message-card user"} group`}>
1410
+ <div
1411
+ ref={!isAssistant ? userMessageCardRef : null}
1412
+ className={`${isAssistant ? "assistant-card" : "message-card user"} group`}
1413
+ style={
1414
+ !isAssistant && isEditingUserMessage && userEditWidthPx
1415
+ ? { width: `${userEditWidthPx}px` }
1416
+ : undefined
1417
+ }
1418
+ >
1348
1419
  {isAssistant ? (
1349
1420
  <div className="assistant-collapse-row">
1350
1421
  <button
@@ -1441,7 +1512,52 @@ function MessageCard({
1441
1512
  ))}
1442
1513
  </div>
1443
1514
  ) : null}
1444
- {messageTextContent ? <p>{messageTextContent}</p> : null}
1515
+ {isEditingUserMessage ? (
1516
+ <div className="user-edit-shell">
1517
+ <textarea
1518
+ ref={userEditTextareaRef}
1519
+ value={editedUserContent}
1520
+ onChange={(event) => setEditedUserContent(event.target.value)}
1521
+ className="user-edit-textarea"
1522
+ placeholder="Edit your message"
1523
+ rows={4}
1524
+ disabled={isSubmittingUserEdit}
1525
+ onKeyDown={(event) => {
1526
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
1527
+ event.preventDefault();
1528
+ void submitUserMessageEdit();
1529
+ return;
1530
+ }
1531
+ if (event.key === "Escape") {
1532
+ event.preventDefault();
1533
+ cancelUserMessageEdit();
1534
+ }
1535
+ }}
1536
+ />
1537
+ <div className="user-edit-actions">
1538
+ <button
1539
+ type="button"
1540
+ className="user-edit-cancel"
1541
+ onClick={cancelUserMessageEdit}
1542
+ disabled={isSubmittingUserEdit}
1543
+ >
1544
+ Cancel
1545
+ </button>
1546
+ <button
1547
+ type="button"
1548
+ className="user-edit-send"
1549
+ onClick={() => {
1550
+ void submitUserMessageEdit();
1551
+ }}
1552
+ disabled={isSubmittingUserEdit || !canSubmitUserEdit}
1553
+ >
1554
+ Send
1555
+ </button>
1556
+ </div>
1557
+ </div>
1558
+ ) : messageTextContent ? (
1559
+ <p>{messageTextContent}</p>
1560
+ ) : null}
1445
1561
  </div>
1446
1562
  )}
1447
1563
 
@@ -1594,61 +1710,73 @@ function MessageCard({
1594
1710
  </div>
1595
1711
  ) : null}
1596
1712
  </div>
1597
- <div
1598
- className={`message-actions ${isAssistant ? "assistant opacity-0 transition-opacity duration-150 hover:opacity-100" : "user"}`}
1599
- >
1600
- {isAssistant ? (
1713
+ {isAssistant || !isEditingUserMessage ? (
1714
+ <div
1715
+ className={`message-actions ${isAssistant ? "assistant opacity-0 transition-opacity duration-150 hover:opacity-100" : "user"}`}
1716
+ >
1717
+ {isAssistant ? (
1718
+ <button
1719
+ className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${canAddThread
1720
+ ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1721
+ : "opacity-60 cursor-not-allowed"
1722
+ }`}
1723
+ onClick={async () => {
1724
+ if (!canAddThread) return;
1725
+ await onAddThread(message);
1726
+ setThreadAdded(true);
1727
+ window.setTimeout(() => setThreadAdded(false), 1200);
1728
+ }}
1729
+ disabled={!canAddThread}
1730
+ >
1731
+ {threadAdded ? (
1732
+ <Check className="h-4 w-4 text-[var(--accent)]" />
1733
+ ) : (
1734
+ <>
1735
+ <PlusCircle className="h-4 w-4" />
1736
+ Thread
1737
+ </>
1738
+ )}
1739
+ </button>
1740
+ ) : null}
1601
1741
  <button
1602
- className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${canAddThread
1603
- ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1604
- : "opacity-60 cursor-not-allowed"
1742
+ className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${isAssistant ? "" : "opacity-0 group-hover:opacity-100"
1605
1743
  }`}
1606
1744
  onClick={async () => {
1607
- if (!canAddThread) return;
1608
- await onAddThread(message);
1609
- setThreadAdded(true);
1610
- window.setTimeout(() => setThreadAdded(false), 1200);
1745
+ await navigator.clipboard.writeText(messageTextContent);
1746
+ setCopied(true);
1747
+ window.setTimeout(() => setCopied(false), 1200);
1611
1748
  }}
1612
- disabled={!canAddThread}
1613
1749
  >
1614
- {threadAdded ? (
1750
+ {copied ? (
1615
1751
  <Check className="h-4 w-4 text-[var(--accent)]" />
1616
1752
  ) : (
1617
- <>
1618
- <PlusCircle className="h-4 w-4" />
1619
- Thread
1620
- </>
1753
+ <Copy className="h-4 w-4" />
1621
1754
  )}
1622
1755
  </button>
1623
- ) : null}
1624
- <button
1625
- className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${isAssistant ? "" : "opacity-0 group-hover:opacity-100"
1626
- }`}
1627
- onClick={async () => {
1628
- await navigator.clipboard.writeText(messageTextContent);
1629
- setCopied(true);
1630
- window.setTimeout(() => setCopied(false), 1200);
1631
- }}
1632
- >
1633
- {copied ? (
1634
- <Check className="h-4 w-4 text-[var(--accent)]" />
1635
- ) : (
1636
- <Copy className="h-4 w-4" />
1637
- )}
1638
- </button>
1639
- {canEditThreads ? (
1640
- <button
1641
- className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
1642
- ? "border-[var(--accent-ring)] text-[var(--text-primary)]"
1643
- : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1644
- }`}
1645
- onClick={() => setThreadEditMode((prev) => !prev)}
1646
- aria-label="Edit threads"
1647
- >
1648
- <Pencil className="h-4 w-4" />
1649
- </button>
1650
- ) : null}
1651
- </div>
1756
+ {canEditUserMessage ? (
1757
+ <button
1758
+ type="button"
1759
+ className="flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] opacity-0 transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] group-hover:opacity-100"
1760
+ onClick={startUserMessageEdit}
1761
+ aria-label="Edit message"
1762
+ >
1763
+ <Pencil className="h-4 w-4" />
1764
+ </button>
1765
+ ) : null}
1766
+ {canEditThreads ? (
1767
+ <button
1768
+ className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
1769
+ ? "border-[var(--accent-ring)] text-[var(--text-primary)]"
1770
+ : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1771
+ }`}
1772
+ onClick={() => setThreadEditMode((prev) => !prev)}
1773
+ aria-label="Edit threads"
1774
+ >
1775
+ <Pencil className="h-4 w-4" />
1776
+ </button>
1777
+ ) : null}
1778
+ </div>
1779
+ ) : null}
1652
1780
  </div>
1653
1781
  );
1654
1782
  }
@@ -294,10 +294,11 @@ export async function appendUserAndAssistant(params: {
294
294
  thread: Thread;
295
295
  content: string;
296
296
  attachments?: ChatAttachment[];
297
+ parentId?: string | null;
297
298
  provider: string;
298
299
  model: string;
299
300
  }) {
300
- const { thread, content, attachments, provider, model } = params;
301
+ const { thread, content, attachments, parentId = thread.headMessageId, provider, model } = params;
301
302
  const now = Date.now();
302
303
  const userId = nanoid();
303
304
  const assistantId = nanoid();
@@ -305,7 +306,7 @@ export async function appendUserAndAssistant(params: {
305
306
  const userMessage: MessageNode = {
306
307
  id: userId,
307
308
  conversationId: thread.conversationId,
308
- parentId: thread.headMessageId,
309
+ parentId,
309
310
  role: "user",
310
311
  content,
311
312
  ...(attachments?.length ? { attachments } : {}),