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
5 changes: 5 additions & 0 deletions apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ function acceptAttachmentUploads(): Disposable {
const mint = answerRpc(WS_METHODS.attachmentsCreateUploadUrl, () =>
Effect.succeed({ attachmentId: "upload-1", relativeUrl: "/upload", expiresAt: 0 }),
);
// Sending verifies each ready upload still exists on the server.
const verify = answerRpc(WS_METHODS.assetsCreateUrl, () =>
Effect.succeed({ relativeUrl: "/upload-1", expiresAt: 0 }),
);
const status = vi.spyOn(XMLHttpRequest.prototype, "status", "get").mockReturnValue(200);
const send = vi.spyOn(XMLHttpRequest.prototype, "send").mockImplementation(function (
this: XMLHttpRequest,
Expand All @@ -71,6 +75,7 @@ function acceptAttachmentUploads(): Disposable {
send.mockRestore();
status.mockRestore();
mint[Symbol.dispose]();
verify[Symbol.dispose]();
},
};
}
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/chat/chatViewSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import { readLocalApi } from "../../localApi";
import { awaitAttachmentUploads, getUploadedAttachments } from "../../lib/attachmentUploadQueue";
import {
getUploadedAttachments,
refreshExpiredAttachmentUploads,
} from "../../lib/attachmentUploadQueue";
import { findRevertCheckpoint } from "../../session-logic";
import { type ChatMessage } from "../../types";
import { randomUUID } from "~/lib/utils";
Expand All @@ -25,6 +28,7 @@ import { newThreadId } from "~/lib/utils";
import {
type ComposerFileAttachment,
type ComposerImageAttachment,
type ComposerThreadTarget,
useComposerDraftStore,
DraftId,
} from "../../composerDraftStore";
Expand Down Expand Up @@ -328,12 +332,17 @@ export function prepareTurnAttachments(input: {
readonly environmentId: EnvironmentId;
readonly attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>;
readonly usesAttachmentUploads: boolean;
readonly draftTarget: ComposerThreadTarget;
}) {
const { environmentId, usesAttachmentUploads } = input;
const { environmentId, usesAttachmentUploads, draftTarget } = input;
return Promise.all(
input.attachments.map(async (attachment) => {
if (usesAttachmentUploads) {
await awaitAttachmentUploads([attachment.id]);
await refreshExpiredAttachmentUploads({
environmentId,
images: [attachment],
draftTarget,
});
const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
if (!uploaded) {
throw new Error(`Attachment '${attachment.name}' failed to upload.`);
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/chat/composerSend.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { describe, expect, it } from "vite-plus/test";
import type { ComposerThreadDraftState } from "../../composerDraftStore";
import { formatInlineContextReference } from "../../lib/composerContextReferences";
import { threadContextReference } from "../../lib/composerContextRecords";
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import {
approvalRedirectNotice,
composerDraftContentChanged,
loadBalancingSendWarning,
multipleModelSendBlock,
referencedThreadContexts,
Expand All @@ -16,6 +18,46 @@ const remote = EnvironmentId.make("remote");
const localThread = threadContextRecord(scopeThreadRef(local, ThreadId.make("a")), "Local");
const remoteThread = threadContextRecord(scopeThreadRef(remote, ThreadId.make("b")), "Remote");

it("detects draft edits during upload preparation without treating upload metadata as an edit", () => {
const draft: ComposerThreadDraftState = {
prompt: "before",
images: [],
files: [
{
type: "file",
id: "file",
name: "a.pdf",
mimeType: "application/pdf",
sizeBytes: 1,
file: null,
},
],
nonPersistedImageIds: [],
persistedAttachments: [],
terminalContexts: [],
previewAnnotations: [],
reviewComments: [],
threadContexts: [],
environmentContexts: [],
replyTo: null,
modelSelectionByProvider: {},
activeProvider: null,
runtimeMode: null,
interactionMode: null,
};
expect(
composerDraftContentChanged(draft, {
...draft,
files: [{ ...draft.files[0]!, uploadedAttachmentId: "upload", uploadEnvironmentId: local }],
}),
).toBe(false);
expect(composerDraftContentChanged(draft, { ...draft, prompt: "after" })).toBe(true);
expect(composerDraftContentChanged(draft, { ...draft, files: [] })).toBe(true);
expect(composerDraftContentChanged(draft, { ...draft, threadContexts: [localThread] })).toBe(
true,
);
});

describe("referencedThreadContexts", () => {
it("keeps only the attached threads the prompt still links", () => {
const prompt = `see ${formatInlineContextReference(threadContextReference(remoteThread))}`;
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/chat/composerSend.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts";
import { collectInlineContextIds } from "../../lib/composerContextReferences";
import type { ComposerThreadDraftState } from "../../composerDraftStore";

/** Compare immutable user content without copying image bytes or upload metadata. */
export function composerDraftContentChanged(
before: ComposerThreadDraftState | null,
after: ComposerThreadDraftState | null,
): boolean {
if (before === after) return false;
if (!before || !after) return true;
const sameIds = (left: ReadonlyArray<{ id: string }>, right: ReadonlyArray<{ id: string }>) =>
left.length === right.length && left.every((item, index) => item.id === right[index]?.id);
return (
before.prompt !== after.prompt ||
!sameIds(before.images, after.images) ||
!sameIds(before.files, after.files) ||
before.terminalContexts !== after.terminalContexts ||
before.previewAnnotations !== after.previewAnnotations ||
before.reviewComments !== after.reviewComments ||
before.threadContexts !== after.threadContexts ||
before.environmentContexts !== after.environmentContexts ||
before.replyTo !== after.replyTo
);
}

/** Attached threads the prompt still links to; replacing linked text leaves stale records. */
export function referencedThreadContexts(draft: {
Expand Down
41 changes: 39 additions & 2 deletions apps/web/src/components/chat/useComposerSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,16 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { environmentPresentations } from "../../state/presentation";
import {
approvalRedirectNotice,
composerDraftContentChanged,
loadBalancingSendWarning,
multipleModelSendBlock,
referencedThreadContexts,
} from "./composerSend.logic";
import { useMultipleModelSend } from "./useMultipleModelSend";
import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch";
import {
awaitAttachmentUploads,
getUploadedAttachments,
refreshExpiredAttachmentUploads,
readAttachmentUpload,
releaseDraftAttachments,
retryAttachmentUpload,
Expand Down Expand Up @@ -872,6 +873,14 @@ export function useComposerSend(input: ComposerSendInput) {
multipleTargets.push(resolved);
}

const draftBeforePreparation = useComposerDraftStore
.getState()
.getComposerDraft(composerDraftTarget);
const draftChangedDuringPreparation = () => {
if (composerRef.current?.hasPendingAttachments() === true) return true;
const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
return composerDraftContentChanged(draftBeforePreparation, draft);
};
sendInFlightRef.current = true;
const sendGeneration = ++composerSendGenerationRef.current;
const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
Expand Down Expand Up @@ -919,6 +928,17 @@ export function useComposerSend(input: ComposerSendInput) {
}

const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities();
if (draftChangedDuringPreparation()) {
sendInFlightRef.current = false;
setThreadError(
threadIdForSend,
"Draft changed while preparing this message. Review it and send again.",
);
setDockedDraftHeroThreadKey((currentThreadKey) =>
currentThreadKey === activeThreadKey ? null : currentThreadKey,
);
return;
}
if (
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null
Expand All @@ -940,7 +960,22 @@ export function useComposerSend(input: ComposerSendInput) {
composerAttachmentsSnapshot.length > 0 &&
(sendCtx?.shownPendingApprovalIds.length ?? 0) > 0
) {
await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id));
await refreshExpiredAttachmentUploads({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
environmentId,
images: composerAttachmentsSnapshot,
draftTarget: composerDraftTarget,
});
if (draftChangedDuringPreparation()) {
sendInFlightRef.current = false;
setThreadError(
threadIdForSend,
"Draft changed while preparing this message. Review it and send again.",
);
setDockedDraftHeroThreadKey((currentThreadKey) =>
currentThreadKey === activeThreadKey ? null : currentThreadKey,
);
return;
}
const uploadBlockReason =
readLiveAttachmentCapabilities().fileBlockReason ??
(getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null
Expand Down Expand Up @@ -1103,6 +1138,7 @@ export function useComposerSend(input: ComposerSendInput) {
environmentId,
attachments: composerAttachmentsSnapshot,
usesAttachmentUploads: turnUsesAttachmentUploads,
draftTarget: composerDraftTarget,
});
if (multipleModelSelections !== null) {
await sendToMultipleModels({
Expand All @@ -1118,6 +1154,7 @@ export function useComposerSend(input: ComposerSendInput) {
readFileBlockReason: () => readLiveAttachmentCapabilities().fileBlockReason,
buildOutgoingMessageContext,
isCurrentSend: () => composerSendGenerationRef.current === sendGeneration,
draftChangedDuringPreparation,
});
return;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/useMultipleModelSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function useMultipleModelSend(input: MultipleModelSendInput) {
readFileBlockReason,
buildOutgoingMessageContext,
isCurrentSend,
draftChangedDuringPreparation,
}: {
readonly threadId: ThreadId;
readonly project: EnvironmentProject;
Expand All @@ -113,6 +114,7 @@ export function useMultipleModelSend(input: MultipleModelSendInput) {
) => ReturnType<typeof buildMessageContext>;
/** False once a newer send from this composer started. */
readonly isCurrentSend: () => boolean;
readonly draftChangedDuringPreparation: () => boolean;
}) => {
const { attachments: attachmentsSnapshot, messageText } = sendSnapshot;
const failedSelections: ModelSelection[] = [];
Expand All @@ -124,6 +126,8 @@ export function useMultipleModelSend(input: MultipleModelSendInput) {
// fails every selection; the draft restore below still runs.
const sendResult = await settlePromise(async () => {
const attachments = await attachmentsPromise;
if (draftChangedDuringPreparation())
throw new Error("Draft changed while preparing this message. Review it and send again.");
const fileBlockReason = readFileBlockReason();
if (fileBlockReason !== null) throw new Error(fileBlockReason);
const context = buildOutgoingMessageContext(
Expand Down
Loading
Loading