Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,13 +667,6 @@ export function resolveTerminalWorktreePath(input: {
return input.threadWorktreePath;
}

export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
return;
}
URL.revokeObjectURL(previewUrl);
}

export async function resolveFileAttachmentUrl(input: {
attachment: ChatFileAttachment;
environmentId: EnvironmentId;
Expand Down Expand Up @@ -733,18 +726,6 @@ export async function prepareRevertedMessageAttachments(input: {
);
}

export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
if (message.role !== "user" || !message.attachments) {
return;
}
for (const attachment of message.attachments) {
if (!isImageAttachment(attachment)) {
continue;
}
revokeBlobPreviewUrl(attachment.previewUrl);
}
}

export function timelineHasEphemeralPreviewUrls(
entries: ReadonlyArray<Pick<TimelineEntry, "kind"> & { message?: ChatMessage }>,
): boolean {
Expand Down
98 changes: 41 additions & 57 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread";
import { useComposerSelection } from "./chat/useComposerSelection";
import { useProjectCloneBanner } from "./chat/useProjectCloneBanner";
import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff";
import {
applyOptimisticUserMessagesAction,
createOptimisticBlobPreviews,
emptyOptimisticUserMessages,
reconcileOptimisticUserMessages,
} from "./chat/optimisticUserMessages";
import { useTimelineRewind } from "./chat/useTimelineRewind";
import { useThreadLifecycle } from "./chat/useThreadLifecycle";
import { useRightPanelActions } from "./chat/useRightPanelActions";
Expand Down Expand Up @@ -322,7 +328,6 @@ import {
} from "./chat/draftHeroTransition";
import {
buildLocalDraftThread,
collectUserMessageBlobPreviewUrls,
deriveCommittedServerUserMessageIds,
dismissBranchMismatchForSession,
shouldShowPlanFollowUpPrompt,
Expand All @@ -333,7 +338,6 @@ import {
resolveComposerProviderSelection,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
revokeUserMessagePreviewUrls,
startNewThreadForProject,
codexArtifactTemplatePromptToAppend,
shouldRefocusComposerOnWindowFocus,
Expand Down Expand Up @@ -632,7 +636,26 @@ export default function ChatView(props: ChatViewProps) {
};
const citeAssistantTextStable = useStableCallback(citeAssistantText);
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const [optimisticBlobPreviews] = useState(createOptimisticBlobPreviews);
const [heldOptimisticUserMessages, setHeldOptimisticUserMessages] = useState(() =>
emptyOptimisticUserMessages(threadId),
);
// Acknowledged messages leave the timeline in the same render the server's copy enters it.
const optimisticState = reconcileOptimisticUserMessages(
heldOptimisticUserMessages,
threadId,
serverAcknowledgedUserMessageIds,
);
if (optimisticState !== heldOptimisticUserMessages) {
// Idempotent, and a discarded render only delays revocation to the thread's end.
optimisticBlobPreviews.handOff(optimisticState);
setHeldOptimisticUserMessages(optimisticState);
}
const optimisticUserMessages = optimisticState.messages;
const setOptimisticUserMessages = (action: SetStateAction<ChatMessage[]>) =>
setHeldOptimisticUserMessages((state) =>
applyOptimisticUserMessagesAction(state, threadId, action),
);
// Draft ids survive promotion and expire on navigation, keeping the pending resend scoped correctly.
const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState<DraftId | null>(null);
const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
Expand Down Expand Up @@ -1401,12 +1424,14 @@ export default function ChatView(props: ChatViewProps) {
}
};
const openFileAttachmentStable = useStableCallback(openFileAttachment);
const { handoffAttachmentPreviewsStable, timelineAttachmentUrlById } =
useAttachmentPreviewHandoff({
environmentId,
serverVisibleTurnItems,
optimisticUserMessages,
});
const { timelineAttachmentUrlById } = useAttachmentPreviewHandoff({
environmentId,
threadId,
serverVisibleTurnItems,
handoff: optimisticState.handoff,
blobPreviews: optimisticBlobPreviews,
setOptimisticUserMessages: setHeldOptimisticUserMessages,
});
const anchoredTimelineMessages = feedbackSubmissions.flatMap((submission) =>
submission.status === "interrupted"
? []
Expand Down Expand Up @@ -2040,57 +2065,12 @@ export default function ChatView(props: ChatViewProps) {
};
}, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]);

useEffect(() => {
if (!activeThread?.id) return;
if (activeMessageCount === 0) {
return;
}
const removedMessages = optimisticUserMessages.filter((message) =>
serverAcknowledgedUserMessageIds.has(message.id),
);
if (removedMessages.length === 0) {
return;
}
const timer = window.setTimeout(() => {
setOptimisticUserMessages((existing) =>
existing.filter((message) => !serverAcknowledgedUserMessageIds.has(message.id)),
);
}, 0);
for (const removedMessage of removedMessages) {
const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
if (previewUrls.length > 0) {
handoffAttachmentPreviewsStable(removedMessage.id, previewUrls);
continue;
}
revokeUserMessagePreviewUrls(removedMessage);
}
return () => {
window.clearTimeout(timer);
};
}, [
activeMessageCount,
activeThread?.id,
serverAcknowledgedUserMessageIds,
handoffAttachmentPreviewsStable,
optimisticUserMessages,
]);

// Promotion drops draftId while a send may still be dispatching, so key by thread.
const [optimisticThreadId, setOptimisticThreadId] = useState(threadId);
const [retiredOptimisticMessages, setRetiredOptimisticMessages] = useState<
ReadonlyArray<ChatMessage>
>([]);
if (optimisticThreadId !== threadId) {
setOptimisticThreadId(threadId);
setRetiredOptimisticMessages(optimisticUserMessages);
setOptimisticUserMessages([]);
const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId);
if (localDispatchThreadId !== threadId) {
setLocalDispatchThreadId(threadId);
resetLocalDispatch();
}
useEffect(() => {
for (const message of retiredOptimisticMessages) {
revokeUserMessagePreviewUrls(message);
}
}, [retiredOptimisticMessages]);

const activeWorktreePath = activeThread?.worktreePath ?? null;
const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
Expand Down Expand Up @@ -2817,6 +2797,10 @@ export default function ChatView(props: ChatViewProps) {
setDockedDraftHeroThreadKey,
setSetupQueueSendThreadKey,
setOptimisticUserMessages,
optimisticBlobPreviews: {
track: (message) => optimisticBlobPreviews.track(threadId, message),
release: (message) => optimisticBlobPreviews.release(threadId, message),
},
setFeedbackSubmissionsByThreadKey,
setThreadError,
setKeepFullHistory,
Expand Down
173 changes: 173 additions & 0 deletions apps/web/src/components/chat/optimisticUserMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { MessageId, ThreadId } from "@t3tools/contracts";
import { AtomRegistry } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

import type { ChatMessage } from "../../types";
import {
applyOptimisticUserMessagesAction,
createOptimisticBlobPreviews,
dropOptimisticHandoff,
emptyOptimisticUserMessages,
loadedHandoffMessageIdsAtom,
reconcileOptimisticUserMessages,
} from "./optimisticUserMessages";

const threadA = ThreadId.make("thread-a");
const threadB = ThreadId.make("thread-b");

function message(id: string, previewUrl?: string): ChatMessage {
return {
id: MessageId.make(id),
role: "user",
text: id,
runId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
streaming: false,
...(previewUrl
? {
attachments: [
{
type: "image",
id: `${id}-image`,
name: "image.png",
mimeType: "image/png",
sizeBytes: 1,
previewUrl,
},
],
}
: {}),
} as ChatMessage;
}

const ids = (...values: string[]) => new Set(values.map((value) => MessageId.make(value)));

describe("optimistic user messages", () => {
it("an acknowledged message stays gone after the server drops its row", () => {
let state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [
message("m1"),
message("m2", "blob:two"),
]);
state = reconcileOptimisticUserMessages(state, threadA, ids("m1", "m2"));
expect(state.messages).toEqual([]);
expect(state.handoff).toEqual({ m2: ["blob:two"] });

// A cancelled queued run or a rewind removes the server's copy again.
state = reconcileOptimisticUserMessages(state, threadA, ids());
expect(state.messages).toEqual([]);

state = dropOptimisticHandoff(state, threadA, MessageId.make("m2"));
expect(state.handoff).toEqual({});
});

it("releases the previous thread's messages when the thread changes", () => {
let state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [
message("a1"),
]);
state = reconcileOptimisticUserMessages(state, threadB, ids());
state = reconcileOptimisticUserMessages(state, threadA, ids());
expect(state).toEqual(emptyOptimisticUserMessages(threadA));
});

it("drops a late update for a thread the view has left", () => {
const state = reconcileOptimisticUserMessages(
applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [
message("a1"),
]),
threadB,
ids(),
);
expect(applyOptimisticUserMessagesAction(state, threadA, [message("a2")])).toBe(state);
});

it("revokes a thread's blob previews once, when their use ends or the thread is left", () => {
using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const previews = createOptimisticBlobPreviews();
previews.track(threadA, message("a1", "blob:a1"));
previews.track(threadA, message("a2", "blob:a2"));
previews.track(threadB, message("b1", "blob:b1"));

previews.revoke(threadA, ["blob:a1"]);
previews.revokeThread(threadA);
expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:a1", "blob:a2"]);
});

it("a send that fails after the server acknowledged it leaves the handed-off previews shown", () => {
using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const previews = createOptimisticBlobPreviews();
const sent = message("m1", "blob:m1");
previews.track(threadA, sent);
let state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [
sent,
]);
state = reconcileOptimisticUserMessages(state, threadA, ids("m1"));
previews.handOff(state);

previews.release(threadA, sent);
expect(revoke).not.toHaveBeenCalled();

previews.revokeThread(threadA);
expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:m1"]);
});

it("a send that fails before acknowledgement revokes its previews", () => {
using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const previews = createOptimisticBlobPreviews();
const sent = message("m1", "blob:m1");
previews.track(threadA, sent);
const state = applyOptimisticUserMessagesAction(emptyOptimisticUserMessages(threadA), threadA, [
sent,
]);
previews.handOff(reconcileOptimisticUserMessages(state, threadA, ids()));

previews.release(threadA, sent);
expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:m1"]);
});
});

class FakeImage {
static readonly created: FakeImage[] = [];
src = "";
private readonly listeners = new Map<string, () => void>();
constructor() {
FakeImage.created.push(this);
}
addEventListener(type: string, listener: () => void) {
this.listeners.set(type, listener);
}
fire(type: "load" | "error") {
this.listeners.get(type)?.();
}
}

describe("handoff preloads", () => {
it("reports a handoff loaded, and revokes its blobs, once every server preview loads", async () => {
vi.stubGlobal("Image", FakeImage);
try {
using revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
FakeImage.created.length = 0;
const registry = AtomRegistry.make();
const atom = loadedHandoffMessageIdsAtom(
JSON.stringify([
["m-loaded", ["https://server/a.png", "https://server/b.png"], ["blob:a", "blob:b"]],
["m-failed", ["https://server/c.png"], ["blob:c"]],
]),
);
const release = registry.mount(atom);
const image = (src: string) => FakeImage.created.find((candidate) => candidate.src === src);

image("https://server/a.png")?.fire("load");
image("https://server/c.png")?.fire("error");
await Promise.resolve();
expect(registry.get(atom)).toEqual([]);

image("https://server/b.png")?.fire("load");
await vi.waitFor(() => expect(registry.get(atom)).toEqual(["m-loaded"]));
expect(revoke.mock.calls.map(([url]) => url)).toEqual(["blob:a", "blob:b"]);
release();
} finally {
vi.unstubAllGlobals();
}
});
});
Loading
Loading