| ... | @@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle"; | ... | @@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle"; |
| 53 | import { | 53 | import { |
| 54 | DEFAULT_THREAD_TERMINAL_ID, | 54 | DEFAULT_THREAD_TERMINAL_ID, |
| 55 | MAX_THREAD_TERMINAL_COUNT, | 55 | MAX_THREAD_TERMINAL_COUNT, |
| | 56 | type ChatMessage, |
| 56 | type ChatImageAttachment, | 57 | type ChatImageAttachment, |
| 57 | type TurnDiffSummary, | 58 | type TurnDiffSummary, |
| 58 | } from "../types"; | 59 | } from "../types"; |
| ... | @@ -344,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -344,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 344 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | 345 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 345 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 346 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 346 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 347 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| | 348 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 347 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | 349 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 348 | const [isConnecting, setIsConnecting] = useState(false); | 350 | const [isConnecting, _setIsConnecting] = useState(false); |
| 349 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 351 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 350 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); | 352 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 351 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); | 353 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| ... | @@ -421,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -421,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 421 | () => derivePendingApprovals(threadActivities), | 423 | () => derivePendingApprovals(threadActivities), |
| 422 | [threadActivities], | 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 | const timelineEntries = useMemo( | 438 | const timelineEntries = useMemo( |
| 425 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | 439 | () => deriveTimelineEntries(timelineMessages, workLogEntries), |
| 426 | [activeThread?.messages, workLogEntries], | 440 | [timelineMessages, workLogEntries], |
| 427 | ); | 441 | ); |
| 428 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 442 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 429 | useTurnDiffSummaries(activeThread); | 443 | useTurnDiffSummaries(activeThread); |
| ... | @@ -964,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -964,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 964 | }, [lastInvokedScriptByProjectId]); | 978 | }, [lastInvokedScriptByProjectId]); |
| 965 | | 979 | |
| 966 | // Auto-scroll on new messages | 980 | // Auto-scroll on new messages |
| 967 | const messageCount = activeThread?.messages.length ?? 0; | 981 | const messageCount = timelineMessages.length; |
| 968 | const workLogCount = workLogEntries.length; | 982 | const workLogCount = workLogEntries.length; |
| 969 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { | 983 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { |
| 970 | const scrollContainer = messagesScrollRef.current; | 984 | const scrollContainer = messagesScrollRef.current; |
| ... | @@ -1023,11 +1037,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1023,11 +1037,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1023 | composerImagesRef.current = composerImages; | 1037 | composerImagesRef.current = composerImages; |
| 1024 | }, [composerImages]); | 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 | useEffect(() => { | 1055 | useEffect(() => { |
| 1027 | setComposerImages((existing) => { | 1056 | setComposerImages((existing) => { |
| 1028 | revokePreviewUrls(existing); | 1057 | revokePreviewUrls(existing); |
| 1029 | return []; | 1058 | return []; |
| 1030 | }); | 1059 | }); |
| | 1060 | setOptimisticUserMessages([]); |
| 1031 | setPrompt(""); | 1061 | setPrompt(""); |
| 1032 | promptRef.current = ""; | 1062 | promptRef.current = ""; |
| 1033 | setSendPhase("idle"); | 1063 | setSendPhase("idle"); |
| ... | @@ -1370,6 +1400,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1370,6 +1400,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1370 | ? activeThread.branch | 1400 | ? activeThread.branch |
| 1371 | : null; | 1401 | : null; |
| 1372 | const composerImagesSnapshot = [...composerImages]; | 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 | setThreadError(threadIdForSend, null); | 1425 | setThreadError(threadIdForSend, null); |
| 1375 | promptRef.current = ""; | 1426 | promptRef.current = ""; |
| ... | @@ -1448,14 +1499,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1448,14 +1499,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1448 | commandId: newCommandId(), | 1499 | commandId: newCommandId(), |
| 1449 | threadId: threadIdForSend, | 1500 | threadId: threadIdForSend, |
| 1450 | message: { | 1501 | message: { |
| 1451 | messageId: newMessageId(), | 1502 | messageId: messageIdForSend, |
| 1452 | role: "user", | 1503 | role: "user", |
| 1453 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, | 1504 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 1454 | attachments: turnAttachments, | 1505 | attachments: turnAttachments, |
| 1455 | }, | 1506 | }, |
| 1456 | model: selectedModel || undefined, | 1507 | model: selectedModel || undefined, |
| 1457 | effort: selectedEffort || undefined, | 1508 | effort: selectedEffort || undefined, |
| 1458 | createdAt: new Date().toISOString(), | 1509 | createdAt: messageCreatedAt, |
| 1459 | }); | 1510 | }); |
| 1460 | } catch (err) { | 1511 | } catch (err) { |
| 1461 | if ( | 1512 | if ( |
| ... | @@ -1463,6 +1514,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1463,6 +1514,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1463 | promptRef.current.length === 0 && | 1514 | promptRef.current.length === 0 && |
| 1464 | composerImagesRef.current.length === 0 | 1515 | composerImagesRef.current.length === 0 |
| 1465 | ) { | 1516 | ) { |
| | 1517 | setOptimisticUserMessages((existing) => |
| | 1518 | existing.filter((message) => message.id !== messageIdForSend), |
| | 1519 | ); |
| 1466 | promptRef.current = trimmed; | 1520 | promptRef.current = trimmed; |
| 1467 | setPrompt(trimmed); | 1521 | setPrompt(trimmed); |
| 1468 | setComposerImages(composerImagesSnapshot); | 1522 | setComposerImages(composerImagesSnapshot); |
| ... | @@ -1740,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1740,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1740 | onScroll={onMessagesScroll} | 1794 | onScroll={onMessagesScroll} |
| 1741 | > | 1795 | > |
| 1742 | <MessagesTimeline | 1796 | <MessagesTimeline |
| 1743 | hasMessages={activeThread.messages.length > 0} | 1797 | hasMessages={timelineMessages.length > 0} |
| 1744 | isWorking={isWorking} | 1798 | isWorking={isWorking} |
| 1745 | scrollContainerRef={messagesScrollRef} | 1799 | scrollContainerRef={messagesScrollRef} |
| 1746 | timelineEntries={timelineEntries} | 1800 | timelineEntries={timelineEntries} |