diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..f04d028af0985 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -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"; @@ -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: "", @@ -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) => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..f4e2ce151dd42 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -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, @@ -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"; @@ -160,9 +155,8 @@ export interface ComposerPromptEditorProps { buildContextClipboardFragment?: | ((contextIds: ReadonlyArray) => string | null) | undefined; - importContextFragment?: - | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) - | undefined; + /** Imports a paste's context records and returns its text with references rewritten. */ + importPastedText?: ((clipboardData: DataTransfer) => string) | undefined; skills: ReadonlyArray; disabled: boolean; placeholder: string; @@ -924,7 +918,7 @@ function ComposerPromptEditorTiptapInner( literalText = false, contextRecords, buildContextClipboardFragment, - importContextFragment, + importPastedText, skills, disabled, placeholder, @@ -966,7 +960,7 @@ function ComposerPromptEditorTiptapInner( onVisibleSelectionChange, onCommandKeyDown, isPathQueryActive, - importContextFragment, + importPastedText, })); const { environmentId } = use(ComposerContextActionsContext); @@ -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" || diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cba7c434b1bab..f0365ba8eefd3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -59,7 +59,6 @@ import { memo, use, useEffect, - useEffectEvent, useId, useImperativeHandle, useLayoutEffect, @@ -111,6 +110,7 @@ import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { + heldContextIds, reconcileAttachmentContextReferences, useRetainedContextPayloads, } from "./composerContextUndo"; @@ -1328,7 +1328,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) countReservedAttachmentsStable, importContextRecords, importContextFragment, - importContextFragmentStable, } = useComposerContextImport({ environmentId, linkedEnvironments, @@ -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 @@ -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", @@ -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; @@ -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", @@ -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, @@ -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( @@ -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" @@ -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 []; @@ -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} diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index fd2a0cde59341..fb6ba9da2cd6c 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -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, + importing: ReadonlySet, + retained: RetainedContextPayloads, +): Pick, "has"> { + const retainedMaps: ReadonlyArray> = Object.values(retained); + return { + has: (contextId) => + live.has(contextId) || + importing.has(contextId) || + retainedMaps.some((payloads) => payloads.has(contextId)), + }; +} + export function reconcileAttachmentContextReferences(input: { referencedContextIds: ReadonlySet; files: ReadonlyArray; diff --git a/apps/web/src/components/chat/pendingDraftWork.ts b/apps/web/src/components/chat/pendingDraftWork.ts index f004e5c4e65d2..6a0b380aeda4a 100644 --- a/apps/web/src/components/chat/pendingDraftWork.ts +++ b/apps/web/src/components/chat/pendingDraftWork.ts @@ -1,20 +1,33 @@ // Count per draft so overlapping pastes cannot release each other and strand chips. class PendingDraftWork { private readonly counts = new Map(); + // Chips whose bytes are still downloading; a paste of one has no record yet but will. + private readonly contextIds = new Map>(); - 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 { + return this.contextIds.get(draftKey) ?? new Set(); + } } // Keep claims across composer remounts. diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index a050b0f51edf0..66f192c41927c 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -1,11 +1,26 @@ import { useEffect } from "react"; -import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { + type ComposerContextRecord, + ComposerContextId, + EnvironmentId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ThreadId, +} from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { hostedThreadLink } from "@t3tools/shared/threadLinks"; import { expect, it, vi } from "vite-plus/test"; import { act, render, stubGlobal } from "../../../test/render"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { configuredHostedAppUrl } from "../../hostedPairing"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { threadContextReference } from "../../lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "../../lib/copiedComposerAttachments"; +import { importPastedComposerText } from "../composerInlineTokenPaste"; import { pendingDraftWork } from "./pendingDraftWork"; import { useComposerContextImport } from "./useComposerContextImport"; @@ -19,7 +34,8 @@ vi.mock("../../state/use-atom-command", async (importOriginal) => ({ })); vi.mock("~/state/session", async (importOriginal) => ({ ...(await importOriginal()), - readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }), + readPreparedConnection: (environmentId: string) => + environmentId === "disconnected-machine" ? null : { httpBaseUrl: "http://localhost" }, })); type ImportInput = Parameters[0]; @@ -31,6 +47,30 @@ function Harness({ input, ready }: { input: ImportInput; ready: (value: Importer return null; } +function importInput(target: DraftId, promptRef: { current: string }) { + const store = useComposerDraftStore.getState(); + return { + environmentId: EnvironmentId.make("clipboard-target"), + linkedEnvironments: new Map(), + activeThread: undefined, + activeThreadId: null, + composerDraftTarget: target, + attachmentDraftTarget: target, + attachmentTargetKey: target, + attachmentTargetKeyRef: { current: target }, + questionAttachmentTarget: null, + pendingUserInputs: [], + composerContextRecords: new Map(), + composerImages: [], + composerFilesRef: { current: [] }, + pendingThreadImportsRef: { current: new Map() }, + promptRef, + addComposerFilesToDraft: (files: Parameters[1]) => + store.addFiles(target, files), + importThreadTranscript: () => {}, + } satisfies ImportInput; +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((fulfill) => { @@ -50,6 +90,10 @@ it.each([ { kind: "image", edit: "question" }, { kind: "image", edit: "annotation" }, { kind: "image", edit: "delete-annotation" }, + // Copying a pending chip carries no record; pasting it over itself must keep its link. + { kind: "file", edit: "repaste" }, + // The download outlives the composer that started it, so a remounted one must see it. + { kind: "file", edit: "remount-repaste" }, ] as const)("settles a delayed $kind import after $edit", async ({ kind, edit }) => { const target = DraftId.make(`clipboard-${kind}-${edit}`); const store = useComposerDraftStore.getState(); @@ -69,36 +113,25 @@ it.each([ const requested = deferred(); const settled = deferred(); const input = { - environmentId: EnvironmentId.make("clipboard-target"), - linkedEnvironments: new Map(), - activeThread: undefined, - activeThreadId: null, - composerDraftTarget: target, - attachmentDraftTarget: target, - attachmentTargetKey: target, - attachmentTargetKeyRef: { current: target }, + ...importInput(target, promptRef), questionAttachmentTarget: edit === "question" ? target : null, - pendingUserInputs: [], - composerContextRecords: new Map(), - composerImages: [], - composerFilesRef: { current: [] }, - pendingThreadImportsRef: { current: new Map() }, - promptRef, addComposerFilesToDraft: (files: Parameters[1]) => store.addFiles(target, files, { appendReference: edit !== "question" }), - } satisfies ImportInput & { promptRef: { current: string } }; + } satisfies ImportInput; let importer!: Importer; - await using _screen = await render( + const harness = (key: string) => ( { importer = value; }} - />, + /> ); + await using screen = await render(harness("first")); const end = pendingDraftWork.end.bind(pendingDraftWork); - using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { - end(key); + using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((...args) => { + end(...args); settled.resolve(); }); using _fetch = stubGlobal("fetch", () => { @@ -151,13 +184,24 @@ it.each([ if (edit !== "cached") await requested.promise; }); if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); + if (edit === "remount-repaste") await screen.rerender(harness("second")); await act(() => { + if (edit === "repaste" || edit === "remount-repaste") { + promptRef.current = importPastedComposerText( + { getData: (format) => (format === "text/plain" ? pastedPrompt : "") }, + importer.importContextFragment, + pendingDraftWork.importingContextIds(target), + ); + store.setPrompt(target, promptRef.current); + } if ( edit !== "annotation" && edit !== "retain" && edit !== "cached" && edit !== "question" && - edit !== "switch" + edit !== "switch" && + edit !== "repaste" && + edit !== "remount-repaste" ) { promptRef.current = "Keep this edit"; store.setPrompt(target, promptRef.current); @@ -179,6 +223,8 @@ it.each([ expect(attachments.map((file) => file.name)).toEqual( edit === "undo" || edit === "retain" || + edit === "repaste" || + edit === "remount-repaste" || edit === "annotation" || edit === "cached" || edit === "question" @@ -186,3 +232,144 @@ it.each([ : [], ); }); + +// Its chip used to resolve to nothing; now it keeps a link naming the thread and its machine. +it.each([ + { machine: "unlinked-machine", transcript: true }, + // Nothing can load a machine this client is not connected to, so only the link remains. + { machine: "disconnected-machine", transcript: false }, +])( + "pastes a thread from $machine, importing its transcript: $transcript", + async ({ machine, transcript }) => { + const target = DraftId.make(`clipboard-${machine}-thread`); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const ref = { + environmentId: EnvironmentId.make(machine), + threadId: ThreadId.make("remote"), + }; + const record = threadContextRecord(ref, "Remote thread"); + const importThreadTranscript = vi.fn(); + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + const fragment = encodeComposerContextFragment({ + version: 1, + source: { environmentId: ref.environmentId }, + records: [record], + })!; + let pasted = ""; + await act(() => { + pasted = importPastedComposerText( + { + getData: (format) => + format === "text/plain" + ? `See ${formatInlineContextReference(threadContextReference(record))}` + : format === COMPOSER_CONTEXT_CLIPBOARD_MIME + ? fragment + : "", + }, + importer.importContextFragment, + ); + }); + expect(pasted).toBe(`See [Remote thread](${hostedThreadLink(ref, configuredHostedAppUrl())})`); + expect(importThreadTranscript.mock.calls).toEqual(transcript ? [[ref, target]] : []); + expect(store.getComposerDraft(target)?.threadContexts ?? []).toEqual([]); + }, +); + +it("points a pasted duplicate at the id the draft holds it under", async () => { + const target = DraftId.make("clipboard-alias-duplicate"); + const held = threadContextRecord( + { environmentId: EnvironmentId.make("clipboard-target"), threadId: ThreadId.make("alias") }, + "Alias thread", + ); + const pasted = { ...held, contextId: ComposerContextId.make("alias") }; + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + expect(importer.importContextRecords([pasted], null).get("alias")).toBe(held.contextId); +}); + +// A stash restores into the prompt behind an open question, not into the question's answer. +it("imports a restored thread's transcript into the draft the restore names", async () => { + const target = DraftId.make("stash-unlinked-thread"); + const question = DraftId.make("stash-unlinked-thread-question"); + const ref = { + environmentId: EnvironmentId.make("unlinked-machine"), + threadId: ThreadId.make("remote"), + }; + const importThreadTranscript = vi.fn(); + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + const rewritten = importer.importContextRecords( + [threadContextRecord(ref, "Remote thread")], + null, + target, + ); + expect([...rewritten.values()]).toEqual([null]); + expect(importThreadTranscript).toHaveBeenCalledWith(ref, target); +}); + +it("counts the attachments of the draft a restore names, not the open question's", async () => { + const target = DraftId.make("stash-limit-target"); + const question = DraftId.make("stash-limit-question"); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(question) }; + store.addFiles( + question, + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }, (_, index) => { + const file = new File([`${index}`], `answer-${index}.txt`, { type: "text/plain" }); + return { + type: "file" as const, + id: `answer-${index}`, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + }), + ); + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + expect(importer.countReservedAttachments()).toBe(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(importer.countReservedAttachments(target)).toBe(0); +}); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 74e97a5076d7d..f7476ee3c1d17 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -12,6 +12,7 @@ import { type ComposerContextRecord, type EnvironmentId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + type ScopedThreadRef, } from "@t3tools/contracts"; import { type ComposerFileAttachment, @@ -19,6 +20,7 @@ import { type DraftId, composerFileMatchesReattachMarker, composerFileNeedsReattach, + composerTargetKey, useComposerDraftStore, } from "../../composerDraftStore"; import { type PendingUserInput } from "../../session-logic"; @@ -91,6 +93,11 @@ export function useComposerContextImport(input: { readonly promptRef: RefObject; readonly pendingThreadImportsRef: RefObject>; readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[]; + /** Attaches a thread's transcript to a draft, as dropping it does. */ + readonly importThreadTranscript: ( + ref: ScopedThreadRef, + draftTarget: ChatComposerProps["composerDraftTarget"], + ) => void; }) { const { environmentId, @@ -109,6 +116,7 @@ export function useComposerContextImport(input: { promptRef, pendingThreadImportsRef, addComposerFilesToDraft, + importThreadTranscript, } = input; const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); @@ -127,10 +135,14 @@ export function useComposerContextImport(input: { const loadThreadTranscript = useAtomCommand(orchestrationEnvironment.threadTranscript, { reportFailure: false, }); - const countReservedAttachments = () => { + const countReservedAttachments = (draftTarget = attachmentDraftTarget) => { + const targetKey = composerTargetKey(draftTarget); const questionRequest = pendingUserInputs[0]; const otherQuestionKeys = - questionAttachmentTarget && questionRequest && activeThreadId + questionAttachmentTarget && + targetKey === attachmentTargetKey && + questionRequest && + activeThreadId ? questionRequest.questions .map((question) => questionAttachmentDraftId( @@ -142,12 +154,12 @@ export function useComposerContextImport(input: { ) .filter((key) => key !== questionAttachmentTarget) : []; - const draft = getComposerDraft(attachmentDraftTarget); + const draft = getComposerDraft(draftTarget); return ( (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + - countPreparingComposerImages(attachmentTargetKey) + - (pendingThreadImportsRef.current.get(attachmentTargetKey) ?? 0) + + countPreparingComposerImages(targetKey) + + (pendingThreadImportsRef.current.get(targetKey) ?? 0) + countQuestionAttachments(otherQuestionKeys) ); }; @@ -279,17 +291,19 @@ export function useComposerContextImport(input: { // the transfer against its own draft so a send cannot snapshot a message whose chip has no // attachment behind it, and so bytes for an abandoned draft never enter the next one. const importTargetKey = attachmentTargetKey; - pendingDraftWork.begin(importTargetKey); + const contextId = toKindScopedComposerContextId(record.kind, localId); + pendingDraftWork.begin(importTargetKey, contextId); await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey).finally(() => - pendingDraftWork.end(importTargetKey), + pendingDraftWork.end(importTargetKey, contextId), ); }; const importContextRecords = ( records: ReadonlyArray, sourceEnvironmentId: EnvironmentId | null, threadDraftTarget = attachmentDraftTarget, - ): ReadonlyMap => { - const rewritten = new Map(); + ): ReadonlyMap => { + // A null id means the reference becomes a link to a thread the agent cannot read. + const rewritten = new Map(); const dependentAttachmentLocalIds = new Map(); const skippedDependentAttachmentIds = new Set(); // Resolve annotations before their dependent screenshot records even if a foreign @@ -308,12 +322,13 @@ export function useComposerContextImport(input: { const record = asKnownContextRecord(candidate); if (!record) continue; // A reminted annotation needs its screenshot under the new annotation id. - const existing = dependentAttachmentLocalIds.has(record.contextId) + const existingId = dependentAttachmentLocalIds.has(record.contextId) ? undefined - : composerContextImportLookupIds(record).flatMap((contextId) => { - const found = composerContextRecords.get(contextId); - return found ? [found] : []; - })[0]; + : composerContextImportLookupIds(record).find((contextId) => + composerContextRecords.has(contextId), + ); + const existing = + existingId === undefined ? undefined : composerContextRecords.get(existingId); // Upload completion can change the server id since copy; the local bytes still match. if ( sourceEnvironmentId !== null && @@ -322,8 +337,10 @@ export function useComposerContextImport(input: { existing.record.file !== null && existing.record.file === copiedComposerAttachmentFile(sourceEnvironmentId, record.attachmentId) - ) + ) { + rewritten.set(record.contextId, existingId ?? record.contextId); continue; + } const existingRecord = existing?.kind === "terminal" ? terminalContextRecord(existing.record) @@ -350,6 +367,8 @@ export function useComposerContextImport(input: { if (record.kind === "preview-annotation" && record.screenshotContextId) { skippedDependentAttachmentIds.add(record.screenshotContextId); } + // The draft may hold the record under its kind-scoped alias. + rewritten.set(record.contextId, existingId ?? record.contextId); continue; } const conflicts = existing !== undefined; @@ -391,13 +410,21 @@ export function useComposerContextImport(input: { break; } case "thread": { - // The agent reads threads on its own server and the machines it links to; a pasted - // thread from any other machine is dropped. + // The agent reads threads on its own server and the machines it links to; any other + // machine this client is connected to sends its transcript, as a dropped one does. if ( record.environmentId !== environmentId && !linkedEnvironments.has(record.environmentId) - ) + ) { + if (readPreparedConnection(record.environmentId)) { + importThreadTranscript( + { environmentId: record.environmentId, threadId: record.threadId }, + threadDraftTarget, + ); + } + rewritten.set(record.contextId, null); break; + } addComposerDraftThreadContexts(threadDraftTarget, [record], { appendReference: false, }); @@ -435,9 +462,8 @@ export function useComposerContextImport(input: { }; const importContextFragment = ( fragment: ComposerContextClipboardFragment, - ): ReadonlyMap => + ): ReadonlyMap => importContextRecords(fragment.records, fragment.source.environmentId); - const importContextFragmentStable = useStableCallback(importContextFragment); return { loadThreadTranscript, @@ -445,6 +471,5 @@ export function useComposerContextImport(input: { countReservedAttachmentsStable, importContextRecords, importContextFragment, - importContextFragmentStable, }; } diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 1d480dcf150e1..08dce33556bdc 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -32,6 +32,7 @@ import { compressImageForStash } from "../../lib/imageCompression"; import { formatInlineContextReference } from "~/lib/composerContextReferences"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { pendingDraftWork } from "./pendingDraftWork"; +import { unreadableThreadLink } from "../composerInlineTokenPaste"; import { previewAnnotationContextRecord, reviewCommentContextRecord, @@ -95,7 +96,7 @@ export function useComposerStash(input: { records: ReadonlyArray, sourceEnvironmentId: EnvironmentId | null, threadDraftTarget?: ChatComposerProps["composerDraftTarget"], - ) => ReadonlyMap; + ) => ReadonlyMap; readonly expandMobileComposer: () => void; }) { const { @@ -206,9 +207,10 @@ export function useComposerStash(input: { const rewrittenContextIds = entry.records ? importContextRecords(entry.records, null, composerDraftTarget) - : new Map(); + : new Map(); const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => { const contextId = rewrittenContextIds.get(reference.contextId); + if (contextId === null) return unreadableThreadLink(entry.records ?? [], reference); return contextId ? formatInlineContextReference({ ...reference, diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts index 1ab28a5b7ff51..932a93112ff0a 100644 --- a/apps/web/src/components/composerInlineTokenPaste.test.ts +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -1,21 +1,41 @@ import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { type ComposerContextClipboardFragment, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + type ComposerContextClipboardFragment, + ComposerContextId, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; -import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { + formatComposerContextReference, + threadContextRecord, +} from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { importPastedComposerText } from "./composerInlineTokenPaste"; +const sourceThread = threadContextRecord( + { + environmentId: EnvironmentId.make("environment-elsewhere"), + threadId: ThreadId.make("thread-source"), + }, + "Source Thread Ready", +); +const pastedText = `Read this: ${formatComposerContextReference(sourceThread)} and continue.`; + +function clipboard(data: Record) { + return { getData: (format: string) => data[format] ?? "" }; +} + +const importNothing = () => new Map(); + const thread = { environmentId: EnvironmentId.make("env-1"), threadId: ThreadId.make("thread-1"), }; -const clipboard = (data: Record) => ({ - getData: (type: string) => data[type] ?? "", -}); const chip = (title: string) => `[${title}](t3-context://v1/thread/${threadContextRecord(thread, title).contextId})`; @@ -28,6 +48,90 @@ const importAll = }; describe("importPastedComposerText", () => { + it("turns a context reference without a fragment into its plain label", () => { + expect(importPastedComposerText(clipboard({ "text/plain": pastedText }), importNothing)).toBe( + "Read this: Source Thread Ready and continue.", + ); + }); + + it("turns a context reference whose record the import dropped into its plain label", () => { + const fragment = encodeComposerContextFragment({ + version: 1, + source: { environmentId: sourceThread.environmentId }, + records: [sourceThread], + }); + expect( + importPastedComposerText( + clipboard({ "text/plain": pastedText, [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment ?? "" }), + importNothing, + ), + ).toBe("Read this: Source Thread Ready and continue."); + }); + + it("keeps context references inside pasted code as written", () => { + const reference = formatComposerContextReference(sourceThread); + const code = `\`\`\`md\n${reference}\n\`\`\`\nInline \`${reference}\` and ${reference}`; + expect(importPastedComposerText(clipboard({ "text/plain": code }), importNothing)).toBe( + `\`\`\`md\n${reference}\n\`\`\`\nInline \`${reference}\` and Source Thread Ready`, + ); + }); + + it("keeps a reference in a fence whose body holds a fence marker mid-line", () => { + const reference = formatComposerContextReference(sourceThread); + const code = `\`\`\`js\nconsole.log("\`\`\`");\n${reference}\n\`\`\``; + expect(importPastedComposerText(clipboard({ "text/plain": code }), importNothing)).toBe(code); + }); + + it("imports a reference between escaped backticks", () => { + const fragment = encodeComposerContextFragment({ + version: 1, + source: { environmentId: sourceThread.environmentId }, + records: [sourceThread], + }); + const localId = ComposerContextId.make("thread-local"); + const text = `\\\`${formatComposerContextReference(sourceThread)}\\\``; + expect( + importPastedComposerText( + clipboard({ "text/plain": text, [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment ?? "" }), + () => new Map([[sourceThread.contextId, localId]]), + ), + ).toBe(`\\\`${formatComposerContextReference({ ...sourceThread, contextId: localId })}\\\``); + }); + + it("imports a reference whose label holds backticks", () => { + const thread = threadContextRecord( + { environmentId: sourceThread.environmentId, threadId: ThreadId.make("thread-code") }, + "Fix `foo` bug", + ); + const fragment = encodeComposerContextFragment({ + version: 1, + source: { environmentId: thread.environmentId }, + records: [thread], + }); + const localId = ComposerContextId.make("thread-local"); + expect( + importPastedComposerText( + clipboard({ + "text/plain": `Quote with \`, see ${formatComposerContextReference(thread)}`, + [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment ?? "", + }), + (pasted) => new Map(pasted.records.map((record) => [record.contextId, localId])), + ), + ).toBe( + `Quote with \`, see ${formatComposerContextReference({ ...thread, contextId: localId })}`, + ); + }); + + it("keeps a context reference whose record the draft already holds", () => { + expect( + importPastedComposerText( + clipboard({ "text/plain": pastedText }), + importNothing, + new Set([sourceThread.contextId]), + ), + ).toBe(pastedText); + }); + it("pastes a copied thread link back as the chip it was copied from", () => { const target = threadChipCopyTarget(thread, "Fix the login flow"); const imported: Array = []; diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 0862f81ee8355..6b082d08654c4 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -13,13 +13,104 @@ import { collectComposerContextReferences, formatComposerContextReference, replaceComposerContextReferences, + sanitizeComposerContextLabel, threadLinkContextReferences, } from "@t3tools/shared/composerContextReferences"; - +import { hostedThreadLink } from "@t3tools/shared/threadLinks"; +import { isClosingFence, parseOpeningFence } from "~/composer-rich-text-doc"; +import { configuredHostedAppUrl } from "~/hostedPairing"; import { readThreadShell } from "~/state/entities"; const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title; +/** + * Code ranges by the composer's rules: fences open and close only on their own line, and a + * backtick run spans to the next run of its length on that line. Linear in the text length. + */ +function markdownCodeRanges(text: string): Array { + const ranges: Array = []; + const lines = text.split("\n"); + let lineStart = 0; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const opening = parseOpeningFence(line); + if (opening) { + const fenceStart = lineStart; + do { + lineStart += lines[index]!.length + 1; + index += 1; + } while (index < lines.length && !isClosingFence(lines[index]!, opening.fence)); + ranges.push([fenceStart, Math.min(text.length, lineStart + (lines[index]?.length ?? 0))]); + } else { + pushCodeSpans(line, lineStart, ranges); + } + lineStart += (lines[index]?.length ?? 0) + 1; + } + return ranges; +} + +function pushCodeSpans(line: string, offset: number, ranges: Array) { + const runs = Array.from(line.matchAll(/`+/g), (match) => ({ + start: match.index, + end: match.index + match[0].length, + })); + // Run indexes by length, each with a cursor that only moves forward. + const byLength = new Map(); + runs.forEach((run, index) => { + const entry = byLength.get(run.end - run.start) ?? { indexes: [], next: 0 }; + entry.indexes.push(index); + byLength.set(run.end - run.start, entry); + }); + let codeEnd = 0; + for (let index = 0; index < runs.length; index += 1) { + const run = runs[index]!; + let backslashes = 0; + while (run.start - backslashes > codeEnd && line[run.start - backslashes - 1] === "\\") { + backslashes += 1; + } + // An escaped backtick is literal and the rest of its run may still open. + const start = run.start + (backslashes % 2); + const candidates = byLength.get(run.end - start); + if (!candidates) continue; + while ( + candidates.next < candidates.indexes.length && + candidates.indexes[candidates.next]! <= index + ) { + candidates.next += 1; + } + const closeIndex = candidates.indexes[candidates.next]; + if (closeIndex === undefined) continue; + ranges.push([offset + start, offset + runs[closeIndex]!.end]); + codeEnd = runs[closeIndex]!.end; + index = closeIndex; + } +} + +/** Whether an offset is inside code; call it with ascending offsets. */ +function codeOffsetTest(text: string): (offset: number) => boolean { + let ranges: Array | null = null; + let next = 0; + return (offset) => { + // The editor holds each reference as an atom, so backticks in its label never open code. + ranges ??= markdownCodeRanges( + replaceComposerContextReferences(text, (occurrence) => " ".repeat(occurrence.source.length)), + ); + while (next < ranges.length && ranges[next]![1] <= offset) next += 1; + return next < ranges.length && ranges[next]![0] <= offset; + }; +} + +/** A thread the agent cannot read stays a link to its machine, whether or not its transcript arrives. */ +export function unreadableThreadLink( + records: ReadonlyArray, + reference: { readonly contextId: string; readonly label: string }, +): string { + const record = records.find((candidate) => candidate.contextId === reference.contextId); + if (record?.kind !== "thread" || !("threadId" in record)) return reference.label; + const link = hostedThreadLink(record, configuredHostedAppUrl()); + return `[${sanitizeComposerContextLabel(reference.label, "thread")}](${link})`; +} + /** Pasted thread links count as context too, so they become chips like a copied chip does. */ export function readPastedComposerContext( clipboardData: Pick, @@ -37,8 +128,11 @@ export function readPastedComposerContext( decodedFragment?.source ?? (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null); if (source === null) return null; + const inCode = codeOffsetTest(threads.text); const pastedIds = new Set( - collectComposerContextReferences(threads.text).map((occurrence) => occurrence.contextId), + collectComposerContextReferences(threads.text) + .filter((occurrence) => !inCode(occurrence.start)) + .map((occurrence) => occurrence.contextId), ); const records = new Map(); for (const record of [...(decodedFragment?.records ?? []), ...threads.records]) { @@ -59,30 +153,40 @@ export function readPastedComposerContext( }; } +/** + * References with no record behind them, in the paste or the draft, paste as their label. + * References inside code stay as written. + */ export function importPastedComposerText( clipboardData: Pick, importContextFragment?: ( fragment: ComposerContextClipboardFragment, - ) => ReadonlyMap, + ) => ReadonlyMap, + heldContextIds: Pick, "has"> = new Set(), ): string { const pastedText = clipboardData.getData("text/plain"); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; - if (!rewrittenIds || rewrittenIds.size === 0) return pastedText; const nextId = (contextId: string) => { - const id = rewrittenIds.get(contextId); + const id = rewrittenIds?.get(contextId); return id ? ComposerContextId.make(id) : undefined; }; + const inCode = codeOffsetTest(pastedText); const text = replaceComposerContextReferences(pastedText, (occurrence) => { + if (inCode(occurrence.start)) return occurrence.source; const contextId = nextId(occurrence.contextId); - return contextId - ? formatComposerContextReference({ - ...occurrence, - contextId, - kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, - }) - : occurrence.source; + if (contextId) { + return formatComposerContextReference({ + ...occurrence, + contextId, + kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind, + }); + } + if (rewrittenIds?.get(occurrence.contextId) === null) { + return unreadableThreadLink(fragment?.records ?? [], occurrence); + } + return heldContextIds.has(occurrence.contextId) ? occurrence.source : occurrence.label; }); // A thread the import refused, on a machine this agent cannot read, stays the link it was. return threadLinkContextReferences(text, fragment?.records ?? [], threadTitle, (record) => diff --git a/docs/user/composer.md b/docs/user/composer.md index df880e49c6ca5..3ec4ec9d1dcde 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -348,10 +348,10 @@ history on demand, so attaching a long thread costs nothing until the agent look thread does not change it, and the agent cannot send messages to it unless you ask. A thread from a linked machine, picked, pasted or dropped, stays a reference that the agent reads -on that machine. A pasted link to a thread on any other machine stays a link. Dropping a thread -from any other environment attaches a saved transcript file. The source +on that machine. Pasting or dropping a thread from any other environment attaches a saved +transcript file, and a pasted one also stays a link to the thread. The source environment must be connected and up to date. The transcript includes the visible history and -saved text context at the time of the drop; later messages and the contents of attached files +saved text context at the time you attached it; later messages and the contents of attached files are not copied. The agent can read the saved transcript after the source disconnects. Normal file attachment limits apply.