authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 20:22:03-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 20:25:27-08:00
logc7a029a2bd9d4ece94a19aa614545cbfbb315864
tree80d328ff61342b09b383858eaf795355db9e2237
parent4fac2d203bc99f6ee7102e878e33a4aba6ccf1de

Show optimistic user messages in chat timeline

- Append pending user messages to the timeline before server echo arrives - Reuse message ID/timestamp for optimistic and sent payloads to avoid duplicates - Remove optimistic entries on sync or send failure

1 files changed, 61 insertions(+), 7 deletions(-)

apps/web/src/components/ChatView.tsx+61-7
...@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";...@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle";
53import {53import {
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]);
965979
966 // Auto-scroll on new messages980 // 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]);
10251039
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.branch1400 ? 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 ]);
13731424
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 === 01515 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 <MessagesTimeline1796 <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}