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";
5353import {
5454 DEFAULT_THREAD_TERMINAL_ID,
5555 MAX_THREAD_TERMINAL_COUNT,
56 type ChatMessage,
5657 type ChatImageAttachment,
5758 type TurnDiffSummary,
5859} from "../types";
......@@ -344,8 +345,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
344345 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
345346 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
346347 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
348 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
347349 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
348 const [isConnecting, setIsConnecting] = useState(false);
350 const [isConnecting, _setIsConnecting] = useState(false);
349351 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
350352 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
351353 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
......@@ -421,9 +423,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
421423 () => derivePendingApprovals(threadActivities),
422424 [threadActivities],
423425 );
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]);
424438 const timelineEntries = useMemo(
425 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
426 [activeThread?.messages, workLogEntries],
439 () => deriveTimelineEntries(timelineMessages, workLogEntries),
440 [timelineMessages, workLogEntries],
427441 );
428442 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
429443 useTurnDiffSummaries(activeThread);
......@@ -964,7 +978,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
964978 }, [lastInvokedScriptByProjectId]);
965979
966980 // Auto-scroll on new messages
967 const messageCount = activeThread?.messages.length ?? 0;
981 const messageCount = timelineMessages.length;
968982 const workLogCount = workLogEntries.length;
969983 const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
970984 const scrollContainer = messagesScrollRef.current;
......@@ -1023,11 +1037,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
10231037 composerImagesRef.current = composerImages;
10241038 }, [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
10261055 useEffect(() => {
10271056 setComposerImages((existing) => {
10281057 revokePreviewUrls(existing);
10291058 return [];
10301059 });
1060 setOptimisticUserMessages([]);
10311061 setPrompt("");
10321062 promptRef.current = "";
10331063 setSendPhase("idle");
......@@ -1370,6 +1400,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
13701400 ? activeThread.branch
13711401 : null;
13721402 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
13741425 setThreadError(threadIdForSend, null);
13751426 promptRef.current = "";
......@@ -1448,14 +1499,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
14481499 commandId: newCommandId(),
14491500 threadId: threadIdForSend,
14501501 message: {
1451 messageId: newMessageId(),
1502 messageId: messageIdForSend,
14521503 role: "user",
14531504 text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
14541505 attachments: turnAttachments,
14551506 },
14561507 model: selectedModel || undefined,
14571508 effort: selectedEffort || undefined,
1458 createdAt: new Date().toISOString(),
1509 createdAt: messageCreatedAt,
14591510 });
14601511 } catch (err) {
14611512 if (
......@@ -1463,6 +1514,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
14631514 promptRef.current.length === 0 &&
14641515 composerImagesRef.current.length === 0
14651516 ) {
1517 setOptimisticUserMessages((existing) =>
1518 existing.filter((message) => message.id !== messageIdForSend),
1519 );
14661520 promptRef.current = trimmed;
14671521 setPrompt(trimmed);
14681522 setComposerImages(composerImagesSnapshot);
......@@ -1740,7 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17401794 onScroll={onMessagesScroll}
17411795 >
17421796 <MessagesTimeline
1743 hasMessages={activeThread.messages.length > 0}
1797 hasMessages={timelineMessages.length > 0}
17441798 isWorking={isWorking}
17451799 scrollContainerRef={messagesScrollRef}
17461800 timelineEntries={timelineEntries}