Skip to content
Open
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
Expand Up @@ -2,10 +2,12 @@ import type { ComposerFileAttachment } from "../composerDraftStore";
import { formatInlineContextReference } from "../lib/composerContextReferences";
import { fileContextReference } from "../lib/composerContextRecords";
import {
heldContextIds,
reconcileAttachmentContextReferences,
type RetainedContextPayloads,
useRetainedContextPayloads,
} from "./chat/composerContextUndo";
import { importPastedComposerText } from "./composerInlineTokenPaste";

import type { ServerProviderSkill } from "@t3tools/contracts";
import type { Editor } from "@tiptap/core";
Expand Down Expand Up @@ -619,17 +621,24 @@ describe("composer context undo lifetime", () => {
current = draft;
retained = payloads;
});
const contextRecords = new Map(
draft.files.map((entry) => [
fileContextReference(entry).contextId,
{ kind: "file" as const, record: entry },
]),
);
const editorProps = {
draftKey: targetKey,
editorRef,
value: draft.value,
cursor: 0,
contextRecords: new Map(
draft.files.map((entry) => [
fileContextReference(entry).contextId,
{ kind: "file" as const, record: entry },
]),
),
contextRecords,
importPastedText: (clipboardData) =>
importPastedComposerText(
clipboardData,
() => new Map(),
heldContextIds(contextRecords, new Set(), payloads),
),
skills: [],
disabled: false,
placeholder: "",
Expand Down Expand Up @@ -702,6 +711,22 @@ describe("composer context undo lifetime", () => {
expect(retained.files.size).toBe(0);
});

// A plain-text paste carries no record, so the cut chip used to come back as its label.
it("restores a cut chip and its file bytes when its text is pasted back as plain text", async () => {
await using composer = await mountComposer();
await composer.deleteDraft();
expect(composer.draft()).toEqual({ value: "", files: [] });
await act(async () => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", composer.initialValue);
composer
.editor()
.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true }));
});
expect(composer.draft().value.trimEnd()).toBe(composer.initialValue);
expect(composer.draft().files[0]?.file).toBe(file.file);
});

it.each(["", "Draft B"])(
"releases deleted payloads and prevents undo into the previous draft when switching to %j",
async (destinationValue) => {
Expand Down
23 changes: 7 additions & 16 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ import {
} from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import type { NodeType, ResolvedPos } from "@tiptap/pm/model";
import type {
AssistantCitation,
ComposerContextClipboardFragment,
ServerProviderSkill,
} from "@t3tools/contracts";
import type { AssistantCitation, ServerProviderSkill } from "@t3tools/contracts";
import {
serializeAssistantCitation,
withAssistantCitationComment,
Expand Down Expand Up @@ -131,7 +127,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
import { importPastedComposerText } from "./composerInlineTokenPaste";
import { didComposerSelectionChangeVisibly } from "./composerSelection";
import type { ComposerDraftContextRecords } from "./composerContextPresentation";

Expand Down Expand Up @@ -160,9 +155,8 @@ export interface ComposerPromptEditorProps {
buildContextClipboardFragment?:
| ((contextIds: ReadonlyArray<string>) => string | null)
| undefined;
importContextFragment?:
| ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
| undefined;
/** Imports a paste's context records and returns its text with references rewritten. */
importPastedText?: ((clipboardData: DataTransfer) => string) | undefined;
skills: ReadonlyArray<ServerProviderSkill>;
disabled: boolean;
placeholder: string;
Expand Down Expand Up @@ -924,7 +918,7 @@ function ComposerPromptEditorTiptapInner(
literalText = false,
contextRecords,
buildContextClipboardFragment,
importContextFragment,
importPastedText,
skills,
disabled,
placeholder,
Expand Down Expand Up @@ -966,7 +960,7 @@ function ComposerPromptEditorTiptapInner(
onVisibleSelectionChange,
onCommandKeyDown,
isPathQueryActive,
importContextFragment,
importPastedText,
}));

const { environmentId } = use(ComposerContextActionsContext);
Expand Down Expand Up @@ -1394,11 +1388,8 @@ function ComposerPromptEditorTiptapInner(
);
return true;
}
const importFragment = readCallbacks().importContextFragment;
let text =
!literalText && importFragment
? importPastedComposerText(clipboardData, importFragment)
: pastedText;
const importText = readCallbacks().importPastedText;
let text = !literalText && importText ? importText(clipboardData) : pastedText;
const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`);
const isChip = (token: (typeof tokens)[number] | undefined) =>
token?.type === "mention" ||
Expand Down
71 changes: 58 additions & 13 deletions apps/web/src/components/chat/ChatComposer.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import {
memo,
use,
useEffect,
useEffectEvent,
useId,
useImperativeHandle,
useLayoutEffect,
Expand Down Expand Up @@ -111,6 +110,7 @@ import { useComposerTriggerState } from "./useComposerTriggerState";
import { useComposerMenuItems } from "./useComposerMenuItems";
import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
import {
heldContextIds,
reconcileAttachmentContextReferences,
useRetainedContextPayloads,
} from "./composerContextUndo";
Expand Down Expand Up @@ -1328,7 +1328,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
countReservedAttachmentsStable,
importContextRecords,
importContextFragment,
importContextFragmentStable,
} = useComposerContextImport({
environmentId,
linkedEnvironments,
Expand All @@ -1346,6 +1345,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptRef,
pendingThreadImportsRef,
addComposerFilesToDraft,
importThreadTranscript: (ref, draftTarget) => {
const targetKey = composerTargetKey(draftTarget);
void importThreadTranscript(
ref,
draftTarget,
() =>
attachmentTargetKeyRef.current === targetKey ||
composerDraftTargetKeyRef.current === targetKey,
);
},
});

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the shared prompt ref in step with the committed draft prompt for imperative handlers
Expand Down Expand Up @@ -2478,6 +2487,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)
: bannerItems;

const importPastedText = (clipboardData: DataTransfer) =>
importPastedComposerText(
clipboardData,
importContextFragment,
heldContextIds(
composerContextRecords,
pendingDraftWork.importingContextIds(attachmentTargetKey),
retainedContextPayloads,
),
);
const importPastedTextStable = useStableCallback(importPastedText);

const insertComposerText = (
text: string,
position: "cursor" | "end",
Expand All @@ -2496,8 +2517,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) {
return false;
}
if (options?.clipboardData) {
text = importPastedComposerText(options.clipboardData, importContextFragment);
if (options?.clipboardData && !isLiteralPendingAnswer) {
text = importPastedText(options.clipboardData);
}
const prompt = promptRef.current;
const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length;
Expand Down Expand Up @@ -2705,10 +2726,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
},
});

const attachDroppedThreadFile = useEffectEvent((file: File) => addComposerAttachments([file]));
const importDroppedThread = useEffectEvent(
async (ref: ScopedThreadRef, isActive: () => boolean) => {
const targetKey = attachmentTargetKey;
// The editor shows the attachment draft; a stash restore can name the draft behind a question.
const attachThreadTranscript = useStableCallback(
async (draftTarget: ChatComposerProps["composerDraftTarget"], file: File) =>
composerTargetKey(draftTarget) === attachmentTargetKey
? addComposerAttachments([file])
: addComposerDraftFiles(
draftTarget,
[
{
type: "file",
id: randomUUID(),
name: file.name,
mimeType: file.type,
sizeBytes: file.size,
file,
},
],
{ appendReference: true },
).length > 0,
);
const importThreadTranscript = useStableCallback(
async (
ref: ScopedThreadRef,
draftTarget: ChatComposerProps["composerDraftTarget"],
isActive: () => boolean,
) => {
const targetKey = composerTargetKey(draftTarget);
pendingDraftWork.begin(targetKey);
const loadingToast = toastManager.add({
type: "loading",
Expand All @@ -2720,7 +2764,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
importComposerThreadAttachment({
targetKey,
pendingImports: pendingThreadImportsRef.current,
countReservedAttachments: countReservedAttachmentsStable,
countReservedAttachments: () => countReservedAttachmentsStable(draftTarget),
load: async () => {
const result = await loadThreadTranscript({
environmentId: ref.environmentId,
Expand All @@ -2732,7 +2776,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return threadContextAttachment(ref.environmentId, result.value);
},
isActive,
attach: attachDroppedThreadFile,
attach: (file) => attachThreadTranscript(draftTarget, file),
onLimitReached: () => {
if (activeThreadId) {
setThreadError(
Expand All @@ -2747,7 +2791,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const error = squashAtomCommandFailure(importResult);
toastManager.add({
type: "error",
title: "Unable to read the dropped thread",
title: "Unable to read the attached thread",
description:
isEnvironmentRequestInvalidError(error) &&
error.reason === "thread_transcript_too_large"
Expand All @@ -2765,8 +2809,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// The agent reads a linked machine's thread itself, like the `@` picker's; any other
// machine's comes in as its transcript.
if (ref.environmentId !== environmentId && !linkedEnvironments.has(ref.environmentId)) {
void importDroppedThread(
void importThreadTranscript(
ref,
attachmentDraftTarget,
() => isActive() && attachmentTargetKeyRef.current === attachmentTargetKey,
);
return [];
Expand Down Expand Up @@ -3465,7 +3510,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
cursor={composerCursor}
contextRecords={composerContextRecords}
buildContextClipboardFragment={buildContextClipboardFragmentStable}
importContextFragment={importContextFragmentStable}
importPastedText={importPastedTextStable}
skills={selectedProviderSkills}
className={cn(showMobilePendingAnswerActions && "max-sm:pb-12")}
onChange={onPromptChangeStable}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/composerContextUndo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ export function useRetainedContextPayloads(draftTargetKey: string): RetainedCont
return retained;
}

/** Context ids a pasted reference keeps its link for: live, still importing, or cut and restorable. */
export function heldContextIds(
live: ReadonlyMap<string, unknown>,
importing: ReadonlySet<string>,
retained: RetainedContextPayloads,
): Pick<ReadonlySet<string>, "has"> {
const retainedMaps: ReadonlyArray<ReadonlyMap<string, unknown>> = Object.values(retained);
return {
has: (contextId) =>
live.has(contextId) ||
importing.has(contextId) ||
retainedMaps.some((payloads) => payloads.has(contextId)),
};
}

export function reconcileAttachmentContextReferences(input: {
referencedContextIds: ReadonlySet<string>;
files: ReadonlyArray<ComposerFileAttachment>;
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/chat/pendingDraftWork.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
// Count per draft so overlapping pastes cannot release each other and strand chips.
class PendingDraftWork {
private readonly counts = new Map<string, number>();
// Chips whose bytes are still downloading; a paste of one has no record yet but will.
private readonly contextIds = new Map<string, Set<string>>();

begin(draftKey: string): void {
begin(draftKey: string, contextId?: string): void {
this.counts.set(draftKey, (this.counts.get(draftKey) ?? 0) + 1);
if (contextId !== undefined) {
this.contextIds.set(draftKey, (this.contextIds.get(draftKey) ?? new Set()).add(contextId));
}
}

end(draftKey: string): void {
end(draftKey: string, contextId?: string): void {
const remaining = (this.counts.get(draftKey) ?? 0) - 1;
if (remaining > 0) this.counts.set(draftKey, remaining);
else this.counts.delete(draftKey);
const contextIds = this.contextIds.get(draftKey);
if (contextId !== undefined && contextIds?.delete(contextId) && contextIds.size === 0) {
this.contextIds.delete(draftKey);
}
}

has(draftKey: string): boolean {
return (this.counts.get(draftKey) ?? 0) > 0;
}

importingContextIds(draftKey: string): ReadonlySet<string> {
return this.contextIds.get(draftKey) ?? new Set();
}
}

// Keep claims across composer remounts.
Expand Down
Loading
Loading