| ... | ... | @@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle"; |
| 53 | 53 | import { |
| 54 | 54 | DEFAULT_THREAD_TERMINAL_ID, |
| 55 | 55 | MAX_THREAD_TERMINAL_COUNT, |
| 56 | type ChatMessage, |
| 56 | 57 | type ChatImageAttachment, |
| 57 | 58 | type TurnDiffSummary, |
| 58 | 59 | } from "../types"; |
| ... | ... | @@ -344,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 344 | 345 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 345 | 346 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 346 | 347 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 348 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 347 | 349 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 348 | | const [isConnecting, setIsConnecting] = useState(false); |
| 350 | const [isConnecting, _setIsConnecting] = useState(false); |
| 349 | 351 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 350 | 352 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 351 | 353 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| ... | ... | @@ -421,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 421 | 423 | () => derivePendingApprovals(threadActivities), |
| 422 | 424 | [threadActivities], |
| 423 | 425 | ); |
| 426 | const timelineMessages = useMemo(() => { |
| 427 | const serverMessages = activeThread?.messages ?? []; |
| 428 | if (optimisticUserMessages.length === 0) { |
| 429 | return serverMessages; |
| 430 | } |
| 431 | const serverIds = new Set(serverMessages.map((message) => message.id)); |
| 432 | const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); |
| 433 | if (pendingMessages.length === 0) { |
| 434 | return serverMessages; |
| 435 | } |
| 436 | return [...serverMessages, ...pendingMessages]; |
| 437 | }, [activeThread?.messages, optimisticUserMessages]); |
| 424 | 438 | const timelineEntries = useMemo( |
| 425 | | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), |
| 426 | | [activeThread?.messages, workLogEntries], |
| 439 | () => deriveTimelineEntries(timelineMessages, workLogEntries), |
| 440 | [timelineMessages, workLogEntries], |
| 427 | 441 | ); |
| 428 | 442 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 429 | 443 | useTurnDiffSummaries(activeThread); |
| ... | ... | @@ -964,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 964 | 978 | }, [lastInvokedScriptByProjectId]); |
| 965 | 979 | |
| 966 | 980 | // Auto-scroll on new messages |
| 967 | | const messageCount = activeThread?.messages.length ?? 0; |
| 981 | const messageCount = timelineMessages.length; |
| 968 | 982 | const workLogCount = workLogEntries.length; |
| 969 | 983 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { |
| 970 | 984 | const scrollContainer = messagesScrollRef.current; |
| ... | ... | @@ -1023,11 +1037,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1023 | 1037 | composerImagesRef.current = composerImages; |
| 1024 | 1038 | }, [composerImages]); |
| 1025 | 1039 | |
| 1040 | useEffect(() => { |
| 1041 | if (!activeThread?.id) { |
| 1042 | setOptimisticUserMessages([]); |
| 1043 | return; |
| 1044 | } |
| 1045 | if (activeThread.messages.length === 0) { |
| 1046 | return; |
| 1047 | } |
| 1048 | const serverIds = new Set(activeThread.messages.map((message) => message.id)); |
| 1049 | setOptimisticUserMessages((existing) => { |
| 1050 | const next = existing.filter((message) => !serverIds.has(message.id)); |
| 1051 | return next.length === existing.length ? existing : next; |
| 1052 | }); |
| 1053 | }, [activeThread?.id, activeThread?.messages]); |
| 1054 | |
| 1026 | 1055 | useEffect(() => { |
| 1027 | 1056 | setComposerImages((existing) => { |
| 1028 | 1057 | revokePreviewUrls(existing); |
| 1029 | 1058 | return []; |
| 1030 | 1059 | }); |
| 1060 | setOptimisticUserMessages([]); |
| 1031 | 1061 | setPrompt(""); |
| 1032 | 1062 | promptRef.current = ""; |
| 1033 | 1063 | setSendPhase("idle"); |
| ... | ... | @@ -1370,6 +1400,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1370 | 1400 | ? activeThread.branch |
| 1371 | 1401 | : null; |
| 1372 | 1402 | const composerImagesSnapshot = [...composerImages]; |
| 1403 | const messageIdForSend = newMessageId(); |
| 1404 | const messageCreatedAt = new Date().toISOString(); |
| 1405 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ |
| 1406 | type: "image" as const, |
| 1407 | id: image.id, |
| 1408 | name: image.name, |
| 1409 | mimeType: image.mimeType, |
| 1410 | sizeBytes: image.sizeBytes, |
| 1411 | previewUrl: image.previewUrl, |
| 1412 | })); |
| 1413 | setOptimisticUserMessages((existing) => [ |
| 1414 | ...existing, |
| 1415 | { |
| 1416 | id: messageIdForSend, |
| 1417 | role: "user", |
| 1418 | text: trimmed, |
| 1419 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 1420 | createdAt: messageCreatedAt, |
| 1421 | streaming: false, |
| 1422 | }, |
| 1423 | ]); |
| 1373 | 1424 | |
| 1374 | 1425 | setThreadError(threadIdForSend, null); |
| 1375 | 1426 | promptRef.current = ""; |
| ... | ... | @@ -1448,14 +1499,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1448 | 1499 | commandId: newCommandId(), |
| 1449 | 1500 | threadId: threadIdForSend, |
| 1450 | 1501 | message: { |
| 1451 | | messageId: newMessageId(), |
| 1502 | messageId: messageIdForSend, |
| 1452 | 1503 | role: "user", |
| 1453 | 1504 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 1454 | 1505 | attachments: turnAttachments, |
| 1455 | 1506 | }, |
| 1456 | 1507 | model: selectedModel || undefined, |
| 1457 | 1508 | effort: selectedEffort || undefined, |
| 1458 | | createdAt: new Date().toISOString(), |
| 1509 | createdAt: messageCreatedAt, |
| 1459 | 1510 | }); |
| 1460 | 1511 | } catch (err) { |
| 1461 | 1512 | if ( |
| ... | ... | @@ -1463,6 +1514,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1463 | 1514 | promptRef.current.length === 0 && |
| 1464 | 1515 | composerImagesRef.current.length === 0 |
| 1465 | 1516 | ) { |
| 1517 | setOptimisticUserMessages((existing) => |
| 1518 | existing.filter((message) => message.id !== messageIdForSend), |
| 1519 | ); |
| 1466 | 1520 | promptRef.current = trimmed; |
| 1467 | 1521 | setPrompt(trimmed); |
| 1468 | 1522 | setComposerImages(composerImagesSnapshot); |
| ... | ... | @@ -1740,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1740 | 1794 | onScroll={onMessagesScroll} |
| 1741 | 1795 | > |
| 1742 | 1796 | <MessagesTimeline |
| 1743 | | hasMessages={activeThread.messages.length > 0} |
| 1797 | hasMessages={timelineMessages.length > 0} |
| 1744 | 1798 | isWorking={isWorking} |
| 1745 | 1799 | scrollContainerRef={messagesScrollRef} |
| 1746 | 1800 | timelineEntries={timelineEntries} |