diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 62223ba82096a..9410ecb7c1262 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -480,10 +480,12 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const threadStatus = useThreadStatus(routeThreadDetailRef); const queuedRunEdit = useQueuedRunEdit({ environmentId, routeThreadRef, threadDraftTarget: baseComposerDraftTarget, + threadIsLive: threadStatus === "live", activeThreadId: serverThread !== null || draftThread !== null ? threadId : null, serverProjection, promptRef, @@ -493,7 +495,6 @@ export default function ChatView(props: ChatViewProps) { const reportedModelSelection = serverProjection ? deriveReportedModelSelection(serverProjection) : null; - const threadStatus = useThreadStatus(routeThreadDetailRef); const threadSyncPhase = resolveThreadSyncPhase({ detailExists: serverProjection !== null, shellExists: serverThread !== null, diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index fd5cc6ac17062..dbd3e25e834e4 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { @@ -86,6 +86,29 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)).toBeNull(); }); + it.each(["", "Separate draft"])( + "handles attachment-only removals without overwriting composer %j", + (separateDraft) => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadTarget, separateDraft); + store.setPrompt(editTarget, "Original message"); + store.setQueuedMessageEdit(editTarget, { + messageId: MessageId.make("message:queued"), + originalText: "Original message", + removedAttachmentIds: ["attachment:removed"], + }); + expect( + recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), + ).toBe(separateDraft ? "discarded" : "kept"); + expect(store.getComposerDraft(threadTarget)?.prompt).toBe( + separateDraft || "Original message", + ); + expect(store.getComposerDraft(threadTarget)?.files).toEqual([]); + expect(store.getComposerDraft(threadTarget)?.queuedMessageEdit).toBeUndefined(); + expect(store.getComposerDraft(editTarget)).toBeNull(); + }, + ); + it("does not overwrite a separate draft when the queued run leaves", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadTarget, "Separate draft"); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 238534d91de47..5692868690151 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -16,9 +16,14 @@ export function recoverQueuedMessageEdit(input: { const draft = store.getComposerDraft(input.editTarget); const dirty = draft !== null && - (draft.prompt !== input.originalText || draft.images.length > 0 || draft.files.length > 0); + (draft.prompt !== input.originalText || + draft.images.length > 0 || + draft.files.length > 0 || + (draft.queuedMessageEdit?.removedAttachmentIds.length ?? 0) > 0); if (dirty && !composerDraftHasUserContent(store.getComposerDraft(input.threadTarget))) { store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); + // A new composer draft keeps the edited content, not IDs belonging to the old queued message. + store.setQueuedMessageEdit(input.editTarget, undefined); return "kept"; } store.clearComposerContent(input.editTarget); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx new file mode 100644 index 0000000000000..0cb4a5cea8ac7 --- /dev/null +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -0,0 +1,329 @@ +import { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { useRef } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { render } from "../../../test/render"; +import { + partializeComposerDraftStoreState, + DraftId, + useComposerDraftStore, + useComposerThreadDraft, +} from "../../composerDraftStore"; +import { useQueuedRunEdit } from "./useQueuedRunEdit"; + +const commands = vi.hoisted(() => ({ + save: vi.fn(async (_input: unknown) => ({ _tag: "Success" })), +})); +vi.mock(import("../../state/use-atom-command"), () => ({ + useAtomCommand: (() => commands.save) as never, +})); +vi.mock(import("../../assets/assetUrls"), () => ({ useAssetUrls: () => [] })); + +function Editor({ + text = "Original message", + queueState = null, + threadIsLive = true, +}: { + readonly text?: string; + readonly queueState?: "queued" | "running" | "missing" | null; + readonly threadIsLive?: boolean; +}) { + const environmentId = EnvironmentId.make("environment:queue-edit"); + const threadId = ThreadId.make("thread:queue-edit"); + const threadRef = scopeThreadRef(environmentId, threadId); + const promptRef = useRef(""); + const edit = useQueuedRunEdit({ + environmentId, + routeThreadRef: threadRef, + threadDraftTarget: threadRef, + activeThreadId: threadId, + threadIsLive, + // This hook only reads run membership and message context from the projection. + serverProjection: + queueState === null + ? null + : ({ + runs: + queueState === "missing" + ? [] + : [{ id: RunId.make("run:queued"), status: queueState }], + messages: [], + } as unknown as NonNullable[0]["serverProjection"]>), + promptRef, + composerRef: { current: null }, + }); + const draft = useComposerThreadDraft(edit.draftTarget); + return ( + <> + +