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
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import { useEffect } from "react";
import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts";
import {
type ComposerContextRecord,
ComposerContextId,
EnvironmentId,
ThreadId,
} from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";

import { act, render, stubGlobal } from "../../../test/render";
Expand Down Expand Up @@ -186,3 +192,70 @@ it.each([
: [],
);
});

it.each(["same", "other-environment"] as const)(
"preserves thread identity across repeated pastes: %s",
async (variant) => {
const otherEnvironment = variant === "other-environment";
const target = DraftId.make(`thread-collision-${variant}`);
const store = useComposerDraftStore.getState();
using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) };
const environmentId = EnvironmentId.make("thread-source");
const otherEnvironmentId = EnvironmentId.make("other-thread-source");
const first = threadContextRecord(
{ environmentId, threadId: ThreadId.make("same-id") },
"First",
);
const pasted = otherEnvironment
? threadContextRecord(
{ environmentId: otherEnvironmentId, threadId: first.threadId },
"Second",
)
: first;
store.addThreadContexts(target, [first], { appendReference: false });
let importer!: Importer;
await using _screen = await render(
<Harness
input={{
environmentId,
linkedEnvironments: new Map([[otherEnvironmentId, "Other machine"]]),
activeThread: undefined,
activeThreadId: null,
composerDraftTarget: target,
attachmentDraftTarget: target,
attachmentTargetKey: target,
attachmentTargetKeyRef: { current: target },
questionAttachmentTarget: null,
pendingUserInputs: [],
composerContextRecords: new Map([[first.contextId, { kind: "thread", record: first }]]),
composerImages: [],
composerFilesRef: { current: [] },
pendingThreadImportsRef: { current: new Map() },
promptRef: { current: "" },
addComposerFilesToDraft: () => [],
}}
ready={(value) => {
importer = value;
}}
/>,
);
let importedId: string | undefined;
await act(() => {
importedId = importer
.importContextRecords([pasted], pasted.environmentId, target)
.get(pasted.contextId);
});
const records = store.getComposerDraft(target)!.threadContexts;
expect(records.find((record) => record.contextId === importedId)).toMatchObject({
environmentId: pasted.environmentId,
threadId: pasted.threadId,
});
expect(records).toHaveLength(otherEnvironment ? 2 : 1);
expect(records[0]).toEqual(first);
await act(() => {
const repeated = importer.importContextRecords([pasted], pasted.environmentId, target);
expect(repeated.get(pasted.contextId)).toBe(importedId);
});
expect(store.getComposerDraft(target)!.threadContexts).toEqual(records);
},
);
19 changes: 15 additions & 4 deletions apps/web/src/components/chat/useComposerContextImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export function useComposerContextImport(input: {
for (const candidate of orderedRecords) {
// Producer ids fold into context ids, so two different excerpts can collide. Only skip
// when the draft already holds the same payload; a colliding but different record is
// re-minted under a fresh id so both survive the paste.
// re-minted under a fresh id so both survive the paste. Threads resolve below.
const record = asKnownContextRecord(candidate);
if (!record) continue;
// A reminted annotation needs its screenshot under the new annotation id.
Expand Down Expand Up @@ -345,7 +345,7 @@ export function useComposerContextImport(input: {
if (
existingRecord &&
isSameComposerContextPayload(existingRecord, record) &&
(record.kind !== "thread" || threadDraftTarget === attachmentDraftTarget)
record.kind !== "thread"
) {
if (record.kind === "preview-annotation" && record.screenshotContextId) {
skippedDependentAttachmentIds.add(record.screenshotContextId);
Expand Down Expand Up @@ -398,10 +398,21 @@ export function useComposerContextImport(input: {
!linkedEnvironments.has(record.environmentId)
)
break;
addComposerDraftThreadContexts(threadDraftTarget, [record], {
// Threads reuse their destination record by identity, reminting only on an ID collision.
const threads = getComposerDraft(threadDraftTarget)?.threadContexts ?? [];
const attached = threads.find(
(thread) =>
thread.environmentId === record.environmentId && thread.threadId === record.threadId,
);
const imported =
attached ??
(threads.some((thread) => thread.contextId === record.contextId)
? { ...record, contextId: toKindScopedComposerContextId("thread", randomUUID()) }
: record);
addComposerDraftThreadContexts(threadDraftTarget, [imported], {
appendReference: false,
});
rewritten.set(record.contextId, record.contextId);
rewritten.set(record.contextId, imported.contextId);
break;
}
case "environment": {
Expand Down
Loading