History

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

Julius Marminge committed 8 months ago

Commit c7a029a1 parent 4fac2d21 file · +61 −7Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+61−7
@@ -53,6 +53,7 @@ import { truncateTitle } from "../truncateTitle"; · 52 unmodified lines
5353 import {
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) { · 285 unmodified lines
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) { · 69 unmodified lines
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) { · 534 unmodified lines
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) { · 52 unmodified lines
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) { · 336 unmodified lines
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) { · 72 unmodified lines
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) { · 1 unmodified line
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) { · 271 unmodified lines
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}