diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..4138be8af8fa 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { getWindowFullscreenState, openExternal, openSystemSettings, + pasteAsText, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); + yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..105bbb70265b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..94210f8d1ac5 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -13,6 +13,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, } from "./window.ts"; @@ -153,6 +154,29 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect("pastes only after the main renderer acknowledges the menu action", () => { + const paste = vi.fn(); + const window = { + webContents: { id: 42, paste }, + } as unknown as Electron.BrowserWindow; + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(paste.mock.calls.length, 1); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..57352852dfc0 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -333,6 +333,24 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const pasteAsText = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.webContents.id !== event.sender.id + ) { + return; + } + window.value.webContents.paste(); + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..af6617bdeb91 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -155,6 +155,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index eeb0c86f031c..bf0c4c3eff6e 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("owns Paste as Text and routes it through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + assert.isDefined(pasteAsTextItem); + assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); + if (typeof pasteAsTextItem.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index bf7634981f86..80ca4b5987a5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -135,6 +135,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const pasteAsTextClick = () => { + runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); + }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; @@ -184,7 +187,34 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { + label: "Paste as Text", + accelerator: "CmdOrCtrl+Shift+V", + click: pasteAsTextClick, + }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(environment.platform === "darwin" + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], + }, + ] + : []), + ], + }, { label: "View", submenu: [ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index e11181e81a9f..8bcecb9e3776 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -48,6 +48,9 @@ class T3ComposerEditorModule : Module() { Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> view.setSpellCheck(spellCheck) } + Prop("interceptTextPastes") { view: T3ComposerEditorView, intercept: Boolean -> + view.setInterceptTextPastes(intercept) + } Events( "onComposerChange", @@ -55,6 +58,7 @@ class T3ComposerEditorModule : Module() { "onComposerFocus", "onComposerBlur", "onComposerPasteImages", + "onComposerPasteText", "onComposerContentSizeChange", ) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 48423ef48ff3..3d03ec388f90 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -1,7 +1,8 @@ package expo.modules.t3composereditor -import android.content.Context +import android.content.ClipData import android.content.ClipboardManager +import android.content.Context import android.graphics.Color import android.graphics.Canvas import android.graphics.Paint @@ -15,6 +16,7 @@ import android.text.TextWatcher import android.text.style.ReplacementSpan import android.util.TypedValue import android.view.Gravity +import android.view.KeyEvent import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.EditText @@ -39,6 +41,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerFocus by EventDispatcher() private val onComposerBlur by EventDispatcher() private val onComposerPasteImages by EventDispatcher() + private val onComposerPasteText by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false private var desiredLineHeightPx = 0 @@ -72,6 +75,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } + editor.pasteTextListener = { text, start, end -> + onComposerPasteText( + mapOf( + "text" to text, + "selection" to mapOf("start" to start, "end" to end), + ), + ) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -244,6 +255,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( updateInputFlags() } + fun setInterceptTextPastes(intercept: Boolean) { + editor.interceptTextPastes = intercept + } + fun focusEditor() { editor.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager @@ -496,6 +511,8 @@ private fun parseTokens(value: String): List = try { private class SelectionAwareEditText(context: Context) : EditText(context) { var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null + var pasteTextListener: ((String, Int, Int) -> Unit)? = null + var interceptTextPastes = false override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) @@ -503,24 +520,54 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } override fun onTextContextMenuItem(id: Int): Boolean { - if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - val clip = clipboard?.primaryClip - val imageUris = buildList { - if (clip != null) { - for (index in 0 until clip.itemCount) { - clip.getItemAt(index).uri?.let { uri -> - val mimeType = context.contentResolver.getType(uri) - if (mimeType?.startsWith("image/") == true) add(uri.toString()) - } + if (id != android.R.id.paste && id != android.R.id.pasteAsPlainText) { + return super.onTextContextMenuItem(id) + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = clipboard?.primaryClip + val imageUris = buildList { + if (clip != null) { + for (index in 0 until clip.itemCount) { + clip.getItemAt(index).uri?.let { uri -> + val mimeType = context.contentResolver.getType(uri) + if (mimeType?.startsWith("image/") == true) add(uri.toString()) } } } - if (imageUris.isNotEmpty()) { + } + val text = if (interceptTextPastes) clip?.plainText() else null + return when { + imageUris.isNotEmpty() -> { pasteImagesListener?.invoke(imageUris) - return true + true + } + !text.isNullOrEmpty() -> { + pasteTextListener?.invoke( + text, + selectionStart.coerceAtLeast(0), + selectionEnd.coerceAtLeast(0), + ) + true } + else -> super.onTextContextMenuItem(id) + } + } + + // coerceToText opens content: URIs synchronously. Leave URI-backed + // clipboard items to Android's normal paste path so the UI thread never + // reads an arbitrary provider just to measure a text paste. + private fun ClipData.plainText(): String? = + takeIf { itemCount > 0 } + ?.getItemAt(0) + ?.takeIf { it.uri == null } + ?.coerceToText(context) + ?.toString() + ?.takeIf(String::isNotEmpty) + + override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { + return super.onTextContextMenuItem(android.R.id.pasteAsPlainText) } - return super.onTextContextMenuItem(id) + return super.onKeyShortcut(keyCode, event) } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 06dab5e074d4..142b6ebcff54 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -44,6 +44,9 @@ public class T3ComposerEditorModule: Module { Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in view.setSpellCheck(spellCheck) } + Prop("interceptTextPastes") { (view: T3ComposerEditorView, intercept: Bool) in + view.setInterceptTextPastes(intercept) + } Events( "onComposerChange", @@ -52,6 +55,7 @@ public class T3ComposerEditorModule: Module { "onComposerBlur", "onComposerSubmit", "onComposerPasteImages", + "onComposerPasteText", "onComposerContentSizeChange" ) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index fe63acc8eb94..50b78b09a651 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -72,9 +72,12 @@ private final class ComposerTextView: UITextView { ]) var onPasteImages: (([String]) -> Void)? + var onPasteText: ((String, NSRange) -> Void)? var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? var isReadOnly = false + var interceptTextPastes = false + private var bypassTextPasteInterception = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -86,6 +89,16 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + if interceptTextPastes { + let pasteAsText = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteInline(_:)) + ) + pasteAsText.discoverabilityTitle = "Paste as Text" + pasteAsText.wantsPriorityOverSystemBehavior = true + commands.append(pasteAsText) + } return commands } @@ -93,6 +106,15 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func pasteInline(_ sender: UIKeyCommand) { + guard !isReadOnly else { + return + } + bypassTextPasteInterception = true + defer { bypassTextPasteInterception = false } + paste(sender) + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false @@ -130,6 +152,11 @@ private final class ComposerTextView: UITextView { return } } + if interceptTextPastes, !bypassTextPasteInterception, + let text = pasteboard.string, !text.isEmpty { + onPasteText?(text, selectedRange) + return + } super.paste(sender) } @@ -346,6 +373,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro let onComposerBlur = EventDispatcher() let onComposerSubmit = EventDispatcher() let onComposerPasteImages = EventDispatcher() + let onComposerPasteText = EventDispatcher() let onComposerContentSizeChange = EventDispatcher() public required init(appContext: AppContext? = nil) { @@ -364,6 +392,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.onPasteImages = { [weak self] urls in self?.onComposerPasteImages(["uris": urls]) } + textView.onPasteText = { [weak self] text, _ in + guard let self else { return } + let selection = self.sourceSelection() + self.onComposerPasteText([ + "text": text, + "selection": ["start": selection.start, "end": selection.end], + ]) + } textView.onAttributedMutation = { [weak self] in self?.emitTextChange() } @@ -499,6 +535,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.spellCheckingType = spellCheck ? .yes : .no } + func setInterceptTextPastes(_ intercept: Bool) { + textView.interceptTextPastes = intercept + } + func focusEditor() { textView.becomeFirstResponder() } diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 0c596e29232f..3537c3164ec5 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -3,4 +3,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5c05d7482cc4..b3af47d726df 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,4 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { + nextPastedTextFileName, + pastedTextDisposition, + replaceTextSelection, +} from "@t3tools/client-runtime/text-paste"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { CommonActions, @@ -22,10 +28,15 @@ import { useFontFamily } from "../../lib/useFontFamily"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerTextPaste, +} from "../../components/ComposerEditor"; import { ComposerActionButton, ComposerInlineControl, @@ -66,8 +77,10 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; @@ -289,6 +302,12 @@ export function NewTaskDraftScreen(props: { const shareImportDraftBackupRef = useRef(new Map()); const activeShareImportTokenRef = useRef(null); const shareImportMountedRef = useRef(true); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); + const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set }>({ + draftKey: null, + names: new Set(), + }); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; @@ -939,8 +958,104 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: flow.prompt, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + flow.setPrompt(insertion.value); + composerMenu.onSelectionChange(selection); + requestAnimationFrame(() => promptInputRef.current?.setSelection(selection)); + }; + const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + flow.prompt.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + flow.attachments.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach && maxBytes !== null) { + const draftKey = flow.draftKey; + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + try { + if (pastedTextFileNamesRef.current.draftKey !== draftKey) { + pastedTextFileNamesRef.current = { draftKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const attachment of flow.attachments) reservedNames.add(attachment.name); + const name = nextPastedTextFileName([...reservedNames]); + reservedNames.add(name); + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if (latestDraftKeyRef.current !== draftKey) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + return; + } + if (flow.appendAttachments([attachment]) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + Alert.alert( + "Could not attach pasted text", + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + Alert.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } finally { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + } + } else if (maxBytes === null && !wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + + insertPaste(); + }, + [composerMenu, flow, selectedEnvironmentServerConfig], + ); + async function handleStart(): Promise { - if (voiceInput.blocksSubmission) return; + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey) { @@ -1102,6 +1217,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + pendingPastedTextAttachmentCount === 0 && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const promptEditor = ( @@ -1122,6 +1238,7 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onPasteText={(paste) => void handleNativePasteText(paste)} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -1396,13 +1513,15 @@ export function NewTaskDraftScreen(props: { 0 + ? "Attaching pasted text" + : flow.submitting + ? "Starting task" + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task") } disabled={!canStart} icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bdc26628dd1c..e92b997764be 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,14 +1,18 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - MessageId, - ModelSelection, - OrchestrationThreadShell, - ProviderInteractionMode, - RuntimeMode, - ServerConfig as T3ServerConfig, - UsageLimitsReport, +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type EnvironmentId, + type MessageId, + type ModelSelection, + type OrchestrationThreadShell, + type ProviderInteractionMode, + type RuntimeMode, + type ServerConfig as T3ServerConfig, + type UsageLimitsReport, } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -128,6 +132,7 @@ export interface ThreadComposerProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (text: string) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -246,6 +251,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, @@ -359,7 +366,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer serverConfig: props.serverConfig, states: uploadStates, }); - const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const sendBlockedReason = + props.sendBlockedReason ?? + (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? + attachmentBlockReason; const canSend = hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable; @@ -411,6 +421,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); const handleSend = useCallback(async () => { + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( @@ -421,7 +432,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (openUsageLimits()) onChangeDraftMessage(""); return; } - if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -653,6 +663,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} + onPasteText={(paste) => { + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: props.draftMessage, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + props.onChangeDraftMessage(insertion.value); + composerMenu.onSelectionChange(selection); + requestAnimationFrame(() => inputRef.current?.setSelection(selection)); + }; + const capabilities = props.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined + ? null + : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + props.draftMessage.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + props.draftAttachments.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + const finishAttachment = () => { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + }; + void props + .onNativePasteText(paste.text) + .then(finishAttachment, finishAttachment); + } else if (maxBytes === null && !wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + insertPaste(); + }} placeholder={props.placeholder} onFocus={handleFocus} onBlur={handleBlur} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3310edc2b3e5..af95f77fafea 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -151,6 +151,7 @@ export interface ThreadDetailScreenProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (text: string) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -1028,6 +1029,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onPickDraftMedia={props.onPickDraftMedia} onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages} + onNativePasteText={props.onNativePasteText} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={handleSendMessage} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ff42a077b04..1b22c3e40f82 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -891,6 +891,7 @@ function ThreadRouteContent( onPickDraftMedia={composer.onPickDraftMedia} onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} + onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index c7289d8cb693..225e33003e4d 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -326,7 +326,14 @@ describe("prepareTurnAttachments", () => { }); it("uploads generic file bytes directly and keeps mixed attachment order", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + const pastedFile = { + ...file, + source: { _tag: "pasted-text" as const }, + }; + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [pastedFile, image], + }); expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", @@ -345,11 +352,12 @@ describe("prepareTurnAttachments", () => { name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, + source: { _tag: "pasted-text" }, }); expect(prepared.attachments[1]?.type).toBe("image"); expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); expect(prepared.draftAttachments[0]).toEqual({ - ...file, + ...pastedFile, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId, }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index b6b5e3a8d635..7213ffceff85 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -165,7 +165,13 @@ function uploadedReference( mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, }; - return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields }; + return attachment.type === "image" + ? { type: "image", ...fields } + : { + type: "file", + ...fields, + ...(attachment.source ? { source: attachment.source } : {}), + }; } function attachmentUploadInput(attachment: DraftComposerAttachment) { diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 41aced8c98e5..bf87e1e000da 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts"; export const DraftComposerImageAttachmentSchema = Schema.Struct({ id: Schema.String, @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, fileUri: Schema.String, + source: Schema.optional(PastedTextAttachmentSource), uploadedAttachmentId: Schema.optional(Schema.String), uploadEnvironmentId: Schema.optional(EnvironmentId), }); diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f76..7d84866e643e 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,14 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; -const files = new Map(); +const files = new Map(); vi.mock("expo-file-system", () => ({ File: class { readonly uri: string; - - constructor(uri: string) { - this.uri = uri; + readonly name: string; + readonly parentDirectory: { readonly uri: string }; + + constructor(parent: string | { readonly uri: string }, name?: string) { + const parentUri = typeof parent === "string" ? parent : parent.uri; + this.uri = name ? `${parentUri}/${name}` : parentUri; + this.name = name ?? this.uri.split("/").at(-1) ?? "file"; + this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) }; } get exists(): boolean { @@ -29,14 +34,43 @@ vi.mock("expo-file-system", () => ({ entry.deleted = true; } } + + create(): void { + files.set(this.uri, { base64: "", deleted: false }); + } + + write(text: string): void { + files.set(this.uri, { base64: "", deleted: false, text }); + } + + moveSync(destination: { readonly uri: string }): void { + const entry = files.get(this.uri); + if (!entry) throw new Error("missing staged file"); + files.set(destination.uri, entry); + files.delete(this.uri); + } + }, + Directory: class { + readonly uri: string; + + constructor(parent: string, name: string) { + this.uri = `${parent}/${name}`; + } + + create(): void {} }, + Paths: { document: "file:///documents" }, })); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + createPastedTextComposerAttachment, + isOwnedPastedImageUri, +} from "./composerImages"; describe("native pasted image cleanup", () => { beforeEach(() => { @@ -91,4 +125,24 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); + + it("persists folded text unchanged in the app-owned attachment directory", async () => { + const text = "first line\nUnicode: 🙂\n"; + const attachment = await createPastedTextComposerAttachment({ + text, + name: "pasted-text.txt", + maxBytes: 1024, + }); + + expect(attachment).toEqual({ + id: "attachment-id", + type: "file", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: new TextEncoder().encode(text).byteLength, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt", + source: { _tag: "pasted-text" }, + }); + expect(files.get(attachment.fileUri)?.text).toBe(text); + }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 16faaedd96c2..0c760701f64d 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -8,6 +8,7 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type EnvironmentId, + type PastedTextAttachmentSource, type UploadChatImageAttachment, } from "@t3tools/contracts"; import type { DocumentPickerResult } from "expo-document-picker"; @@ -19,6 +20,7 @@ import { } from "./composerAttachmentFiles"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; +import { writeFileAtomically } from "./atomic-file"; export interface DraftComposerImageAttachment extends Omit { readonly id: string; @@ -38,10 +40,40 @@ export interface DraftComposerFileAttachment { readonly mimeType: string; readonly sizeBytes: number; readonly fileUri: string; + readonly source?: PastedTextAttachmentSource; readonly uploadedAttachmentId?: string; readonly uploadEnvironmentId?: EnvironmentId; } +export async function createPastedTextComposerAttachment(input: { + readonly text: string; + readonly name: string; + readonly maxBytes: number; +}): Promise { + const bytes = new TextEncoder().encode(input.text).byteLength; + if (bytes <= 0) { + throw new Error("Clipboard is empty."); + } + if (bytes > input.maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); + } + + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, `${uuidv4()}-${input.name}`); + await writeFileAtomically(file, input.text); + return { + id: uuidv4(), + type: "file", + name: input.name, + mimeType: "text/plain;charset=utf-8", + sizeBytes: bytes, + fileUri: file.uri, + source: { _tag: "pasted-text" }, + }; +} + export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; /** Any composer attachment whose bytes live in the app-owned attachment directory. */ diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 85decebe9ed0..8bba57846bda 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -46,6 +46,11 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -70,6 +75,8 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerChange: (event: NativeEditorEvent) => void; readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly interceptTextPastes: boolean; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: () => void; @@ -95,6 +102,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, onSubmit, @@ -249,6 +257,7 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + interceptTextPastes={onPasteText !== undefined} style={style as StyleProp} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -281,6 +290,7 @@ export function ComposerEditor({ forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerPasteText={(event) => onPasteText?.(event.nativeEvent)} onComposerFocus={onFocus} onComposerBlur={onBlur} onComposerSubmit={onSubmit} @@ -292,4 +302,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 129e1af2a92b..f877c52cffbf 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -49,6 +49,11 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -73,6 +78,8 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerChange: (event: NativeEditorEvent) => void; readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly interceptTextPastes: boolean; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; } @@ -97,6 +104,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, contentInsetVertical = 0, @@ -256,6 +264,7 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + interceptTextPastes={onPasteText !== undefined} style={{ flex: 1, minHeight: 0 }} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -289,6 +298,7 @@ export function ComposerEditor({ forceNativeEventRender((sequence) => sequence + 1); }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerPasteText={(event) => onPasteText?.(event.nativeEvent)} onComposerFocus={onFocus} onComposerBlur={onBlur} /> @@ -300,4 +310,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 07a409c9a48f..9ff7f41a6eba 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -12,6 +12,7 @@ export function ComposerEditor({ skills: _skills, selection, onPasteImages, + onPasteText: _onPasteText, style, textStyle, contentInsetVertical = 0, @@ -65,4 +66,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index c8833bb4cb61..59966ecc2fed 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -7,6 +7,11 @@ export type ComposerEditorSelection = { readonly end: number; }; +export type ComposerTextPaste = { + readonly text: string; + readonly selection: ComposerEditorSelection; +}; + export interface ComposerEditorHandle { focus: () => void; blur: () => void; @@ -37,6 +42,7 @@ export interface ComposerEditorProps { readonly onChangeText: (value: string) => void; readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; readonly onPasteImages?: (uris: ReadonlyArray) => void; + readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 8f10bc92acf8..62b92fb2e888 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { @@ -7,6 +7,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -14,6 +15,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -26,9 +29,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -118,6 +123,23 @@ export function useThreadComposerState() { const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set }>({ + threadKey: null, + names: new Set(), + }); + const reservePastedTextFileName = useCallback( + (threadKey: string, existingNames: ReadonlyArray) => { + if (pastedTextFileNamesRef.current.threadKey !== threadKey) { + pastedTextFileNamesRef.current = { threadKey, names: new Set() }; + } + const names = pastedTextFileNamesRef.current.names; + for (const name of existingNames) names.add(name); + const nextName = nextPastedTextFileName([...names]); + names.add(nextName); + return nextName; + }, + [], + ); useEffect(() => { ensureComposerDraftsLoaded(); @@ -516,7 +538,59 @@ export function useThreadComposerState() { }); const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images); if (result.text) { - appendComposerDraftText(threadKey, result.text); + const currentAttachments = composerDrafts[threadKey]?.attachments ?? []; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + (composerDrafts[threadKey]?.text.length ?? 0) + result.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const shouldFold = + pastedTextDisposition({ + text: result.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + const canAttach = + maxBytes !== null && + currentAttachments.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(result.text).byteLength <= maxBytes; + if (shouldFold && canAttach && maxBytes !== null) { + try { + const attachment = await createPastedTextComposerAttachment({ + text: result.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes, + }); + if (appendComposerDraftAttachments(threadKey, [attachment]) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + } else if (shouldFold && maxBytes === null && !wouldExceedInputLimit) { + appendComposerDraftText(threadKey, result.text); + } else if (shouldFold) { + setPendingConnectionError( + wouldExceedInputLimit + ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.", + ); + } else { + appendComposerDraftText(threadKey, result.text); + } } if (result.error) { setPendingConnectionError(result.error); @@ -525,7 +599,12 @@ export function useThreadComposerState() { `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, ); } - }, [composerDrafts, selectedThreadShell]); + }, [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray) => { @@ -554,6 +633,48 @@ export function useThreadComposerState() { [composerDrafts, selectedThreadShell], ); + const onNativePasteText = useCallback( + async (text: string) => { + if (!selectedThreadShell) return; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + if (advertisedMax === undefined) return; + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const currentAttachments = composerDrafts[threadKey]?.attachments ?? []; + try { + const attachment = await createPastedTextComposerAttachment({ + text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes: clampFileAttachmentUploadBytes(advertisedMax), + }); + const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment]); + if (rejectedCount > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + }, + [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ], + ); + const onRemoveDraftImage = useCallback( (imageId: string) => { if (!selectedThreadShell) { @@ -631,6 +752,7 @@ export function useThreadComposerState() { onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, + onNativePasteText, onRemoveDraftImage, onSendMessage, onUpdateModelSelection, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..f5f9422772e4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -2132,6 +2132,25 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + const pastedTextAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "Investigate this crash", + attachments: [pastedTextAttachment], + }); + const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); + assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -4587,6 +4606,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects input that leaves no room for pasted-text attachment context", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + validation.codex.sendTurn.mockClear(); + + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [attachment], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 016bcd5c4a28..7a9eddadcad3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1515,30 +1515,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Every attachment gets an on-disk path in the prompt so the model's tools // can dereference the actual file. All attachments then go to the adapter, - // and each adapter decides what its provider ingests natively: OpenCode - // sends generic files as file parts, the others send images only and rely - // on the path line for everything else. Unresolvable ids are skipped here - // and surface as adapter errors when the file is read. + // and each adapter decides what its provider ingests natively. Folded + // clipboard text remains path-only everywhere: eagerly embedding it would + // spend the same context the client deliberately preserved by folding it. + // Unresolvable ids are skipped here and surface as adapter errors when the + // file is read. let inputTextWithAttachmentContext = inputTextWithCitations; const appendAttachmentContext = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputTextWithAttachmentContext ? `${inputTextWithAttachmentContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputTextWithAttachmentContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); - appendAttachmentContext( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = appendAttachmentContext( attachmentPath === null ? undefined - : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + : isPastedText + ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` + : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); + if (isPastedText && !appended) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + ); + } } for (const attachment of attachments) { const source = diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b95..aee10782a613 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect("keeps folded clipboard text out of native context", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + yield* fixture.write(pastedText, "A very large crash report"); + + const prompt = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); + }), + ); + + it.effect("rejects missing folded clipboard text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + + const error = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'pasted-text.txt'.", + }); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 6e625127cb18..a768401c7153 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -262,6 +262,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi let totalBytes = 0; for (const attachment of input.attachments ?? []) { + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + // ProviderService has already put the file path in the text block. Keep a + // folded clipboard paste lazy so the agent can search or sample it rather + // than paying to embed the entire resource in context immediately. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -294,6 +301,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); + if (isPastedText) { + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); + } + continue; + } const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f0..c66a2f36a207 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 6d7187792a99..a79eff843cc7 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } if (!isOpenCodeNativeFilePart(attachment)) { continue; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..87d2425bdb92 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,6 +45,7 @@ import { } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -93,6 +94,7 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -1569,6 +1571,7 @@ export default function ChatView(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -6292,19 +6295,36 @@ export default function ChatView(props: ChatViewProps) { // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. useEffect(() => { + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + } + }; const handler = (event: ClipboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) return; if (getTerminalFocusOwner() !== null) return; if (composerRef.current?.isModelPickerOpen()) return; const text = pasteTextToFocusComposer(event); if (text === null) return; - if (composerRef.current?.insertTextAtEnd(text)) { + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + if ( + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment }) || + composerRef.current?.insertTextAtEnd(text) + ) { event.preventDefault(); event.stopPropagation(); } }; + window.addEventListener("keydown", keyHandler, true); window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; }, [activeThreadId, composerRef]); const onRevertToTurnCount = useCallback( @@ -6834,6 +6854,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6b49ef884734..86806faee040 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -895,6 +895,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1864,6 +1865,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2025,7 +2030,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..45831b7dc411 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -25,8 +25,15 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { + isPasteAsTextShortcut, + nextPastedTextFileName, + pastedTextDisposition, + wouldTextPasteExceedLimit, +} from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -222,7 +229,7 @@ import { } from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { basenameOfPath } from "../../pierre-icons"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, @@ -1178,6 +1185,8 @@ export interface ChatComposerHandle { collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; + /** Apply large-paste folding for text redirected from a blurred composer. */ + pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; citeAssistantText: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -1937,6 +1946,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); + const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set }>({ + targetKey: "", + names: new Set(), + }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerFooterControlsRef = useRef(null); @@ -1970,6 +1984,53 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) */ const pendingImageCompressionsRef = useRef>(new Map()); + useEffect(() => { + const armPasteAsTextShortcut = () => { + // Electron can deliver its native menu action just before the paste + // event, while browsers normally deliver keydown first. A short deadline + // bridges both event paths without leaving later pastes in bypass mode. + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Node && + composerFormRef.current?.contains(event.target) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + armPasteAsTextShortcut(); + } + }; + const onBlur = () => { + pasteAsTextShortcutUntilRef.current = 0; + }; + const unsubscribeMenuAction = window.desktopBridge?.onMenuAction?.((action) => { + if (action === "paste-as-text") { + const activeElement = document.activeElement; + const blocksPasteToFocus = + activeElement instanceof Element && + activeElement.closest( + 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', + ) !== null; + if ( + (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || + !blocksPasteToFocus + ) { + armPasteAsTextShortcut(); + } + // The native menu owns Cmd+Shift+V in Desktop. Only a focused composer + // arms the bypass, so other editable controls cannot affect its next paste. + void window.desktopBridge?.pasteAsText?.(); + } + }); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", onBlur); + return () => { + unsubscribeMenuAction?.(); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", onBlur); + }; + }, []); + // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ @@ -3380,6 +3441,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // An expired upload carries no ids, so it hydrates as a // needs-reattach row and the "Attach again" flow takes over. ...(expired @@ -3627,6 +3689,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sizeBytes: file.sizeBytes, attachmentId: upload.attachmentId, environmentId, + ...(file.source ? { source: file.source } : {}), }); } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it @@ -4314,7 +4377,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerAttachments = async (files: File[]) => { + const addComposerAttachments = async ( + files: File[], + options?: { readonly source?: ChatFileAttachment["source"] }, + ) => { if (!activeThreadId || files.length === 0) return; if ( pendingUserInputs.length > 0 && @@ -4427,6 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: fileMimeType, sizeBytes: attachmentFile.size, file: attachmentFile, + ...(options?.source ? { source: options.source } : {}), }); } if (!matchingReattachMarker) { @@ -4436,6 +4503,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError(threadId, error); if (acceptedFiles.length > 0) { addComposerFilesToDraft(acceptedFiles); + if (options?.source?._tag === "pasted-text") { + const attached = acceptedFiles[0]!; + toastManager.add({ + type: "info", + title: `Large paste attached as ${attached.name}`, + description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${ + isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V" + } to keep a large paste inline.`, + data: { hideCopyButton: true }, + }); + } } if (acceptedImages.length === 0) return; @@ -4513,24 +4591,107 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ + const foldPastedText = ( + plainText: string, + bypassAutoAttachment: boolean, + selectionOverride?: { start: number; end: number }, + ): boolean => { + const questionCanAttach = + !activePendingProgress || + (supportsQuestionAttachments && + activePendingProgress.activeQuestion?.allowCustomAnswer !== false && + !activePendingIsResponding); + const hasAttachmentSlot = + composerImagesRef.current.length + + composerFilesRef.current.length + + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS; + const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); + const wouldExceedInputLimit = wouldTextPasteExceedLimit({ + valueLength: promptRef.current.length, + selection: selection ?? { start: 0, end: 0 }, + textLength: plainText.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }); + const shouldFold = + pastedTextDisposition({ + text: plainText, + bypassAutoAttachment, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + if (!shouldFold) { + return false; + } + + const canStageAttachment = + Boolean(activeThreadId) && + questionCanAttach && + hasAttachmentSlot && + fileStagingLimit !== null; + if (!canStageAttachment) { + if (!wouldExceedInputLimit) { + return false; + } + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Remove some text or an attachment, then paste again.", + data: { hideCopyButton: true }, + }); + return true; + } + + if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) { + pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const file of composerFilesRef.current) reservedNames.add(file.name); + const foldedFileName = nextPastedTextFileName([...reservedNames]); + reservedNames.add(foldedFileName); + const foldedFile = new File([plainText], foldedFileName, { + type: "text/plain;charset=utf-8", + }); + if (foldedFile.size > fileStagingLimit) { + toastManager.add({ + type: "error", + title: "Pasted text is too large to attach", + description: "Reduce the clipboard contents or save a smaller excerpt as a file.", + data: { hideCopyButton: true }, + }); + return true; + } + + void addComposerAttachments([foldedFile], { source: { _tag: "pasted-text" } }); + return true; + }; + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); + const plainText = event.clipboardData.getData("text/plain"); + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; // Claimable pastes go through even when agent questions are pending or the // composer is at its attachment limit: `addComposerAttachments` surfaces // those as a toast and a thread error. An early return here would swallow // the paste with no feedback. if ( - files.length === 0 || - !activeThreadId || - !shouldHandleComposerAttachmentPaste({ - files, - plainText: event.clipboardData.getData("text/plain"), - }) + files.length > 0 && + activeThreadId && + shouldHandleComposerAttachmentPaste({ files, plainText }) ) { + event.preventDefault(); + event.stopPropagation(); + void addComposerAttachments(files); + return; + } + + if (!foldPastedText(plainText, bypassAutoAttachment)) { return; } + event.preventDefault(); - void addComposerAttachments(files); + event.stopPropagation(); }; const insertComposerText = useCallback( @@ -4731,6 +4892,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, + pasteTextAtEnd: (text: string, options) => { + const bypassAutoAttachment = + options?.bypassAutoAttachment === true || + Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + const promptLength = promptRef.current.length; + if ( + !foldPastedText(text, bypassAutoAttachment, { + start: promptLength, + end: promptLength, + }) + ) { + return false; + } + focusComposer(); + return true; + }, citeAssistantText: (citation, sourceAnchor) => insertComposerText( formatAssistantCitationForComposer(citation, citation.comment), @@ -4835,6 +5013,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + foldPastedText, composerDraftTarget, composerCursor, composerTerminalContexts, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 919342adbfc2..4a7a8d14202d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -400,7 +400,9 @@ describe("composerDraftStore file attachments", () => { it("persists uploaded file references without including file contents", () => { const store = useComposerDraftStore.getState(); - store.addFiles(threadRef, [makeFile("file-1")]); + store.addFiles(threadRef, [ + { ...makeFile("file-1"), source: { _tag: "pasted-text" as const } }, + ]); store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); const persistApi = useComposerDraftStore.persist as unknown as { @@ -423,6 +425,7 @@ describe("composerDraftStore file attachments", () => { sizeBytes: 6, attachmentId: "pending-report-pdf", environmentId: TEST_ENVIRONMENT_ID, + source: { _tag: "pasted-text" }, }, ], ); @@ -438,6 +441,7 @@ describe("composerDraftStore file attachments", () => { file: null, uploadedAttachmentId: "pending-report-pdf", uploadEnvironmentId: TEST_ENVIRONMENT_ID, + source: { _tag: "pasted-text" }, }, ]); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f4ad48e16ab..c8ae73919ebb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -11,6 +11,7 @@ import { ProviderOptionSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, + PastedTextAttachmentSource, type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, @@ -176,6 +177,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({ sizeBytes: Schema.Number, attachmentId: Schema.String, environmentId: EnvironmentId, + source: Schema.optional(PastedTextAttachmentSource), }); export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; @@ -192,6 +194,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({ sizeBytes: Schema.Number, attachmentId: Schema.optionalKey(Schema.String), environmentId: Schema.optionalKey(EnvironmentId), + source: Schema.optional(PastedTextAttachmentSource), }); export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); @@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState( name: file.name, mimeType: file.mimeType, sizeBytes: file.sizeBytes, + ...(file.source ? { source: file.source } : {}), ...(file.uploadedAttachmentId && file.uploadEnvironmentId ? { attachmentId: file.uploadedAttachmentId, @@ -2443,6 +2447,7 @@ function toHydratedThreadDraft( mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // A marker without an attachment id hydrates as needs-reattach: no // bytes, no server-side upload, only the metadata to tell the user // what to attach again. diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index a38e107a7d7d..f376203cce6b 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => { }); it("uploads generic files and sends file attachment references", async () => { - const file = makeFile("report"); + const file = { + ...makeFile("report"), + source: { _tag: "pasted-text" as const }, + }; startAttachmentUpload({ environmentId: firstEnvironment, image: file }); await Promise.resolve(); @@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => { name: "report.pdf", mimeType: "application/pdf", sizeBytes: 3, + source: { _tag: "pasted-text" }, }, ]); }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index a309b95b6a41..2a98f38133ac 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -563,7 +563,7 @@ export function getUploadedAttachments(input: { name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, - ...(image.type === "image" && image.source ? { source: image.source } : {}), + ...(image.source ? { source: image.source } : {}), }); } return attachments; diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..23ed457d0cdf 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -6,6 +6,12 @@ include a skill when the task needs more context. Messages can contain up to 120,000 characters. Longer drafts stay in the composer so you can shorten them or split them into several messages. +Pasting 32 KiB or more of text adds that fragment as a text-file attachment so +the agent can inspect it without filling the model context. A smaller paste also +becomes an attachment when inserting it would exceed the message limit. On a +hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere +to keep a large paste editable in the composer instead. + ## Attach files Attach up to eight files per message. Images can be up to 10 MB; other files can diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 368585556cfe..5222ec00a939 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -103,6 +103,10 @@ "types": "./src/state/attachments.ts", "default": "./src/state/attachments.ts" }, + "./text-paste": { + "types": "./src/textPaste.ts", + "default": "./src/textPaste.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" diff --git a/packages/client-runtime/src/textPaste.test.ts b/packages/client-runtime/src/textPaste.test.ts new file mode 100644 index 000000000000..e5f5b06c4795 --- /dev/null +++ b/packages/client-runtime/src/textPaste.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + nextPastedTextFileName, + PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES, + isPasteAsTextShortcut, + pastedTextDisposition, + replaceTextSelection, + wouldTextPasteExceedLimit, +} from "./textPaste.ts"; + +describe("pasted text disposition", () => { + it("keeps ordinary text inline and folds at the 32 KiB boundary", () => { + expect( + pastedTextDisposition({ + text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES - 1), + canAttach: true, + }), + ).toBe("inline"); + expect( + pastedTextDisposition({ + text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES), + canAttach: true, + }), + ).toBe("attachment"); + }); + + it("measures UTF-8 bytes instead of UTF-16 characters", () => { + const text = "🙂".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES / 4); + expect(text.length).toBeLessThan(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); + expect(pastedTextDisposition({ text, canAttach: true })).toBe("attachment"); + }); + + it("keeps text inline for the explicit bypass or without attachment support", () => { + const text = "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); + expect(pastedTextDisposition({ text, canAttach: true, bypassAutoAttachment: true })).toBe( + "inline", + ); + expect(pastedTextDisposition({ text, canAttach: false })).toBe("inline"); + }); + + it("folds a smaller paste when the resulting prompt would exceed the input limit", () => { + expect( + pastedTextDisposition({ + text: "small paste", + canAttach: true, + wouldExceedInputLimit: true, + }), + ).toBe("attachment"); + }); +}); + +describe("pasted text attachment names", () => { + it("uses the first available readable sequence", () => { + expect(nextPastedTextFileName([])).toBe("pasted-text.txt"); + expect(nextPastedTextFileName(["PASTED-TEXT.TXT", "pasted-text-2.txt"])).toBe( + "pasted-text-3.txt", + ); + }); +}); + +describe("paste-as-text shortcut", () => { + it.each([ + { metaKey: true, ctrlKey: false, macPlatform: true }, + { metaKey: false, ctrlKey: true, macPlatform: false }, + ])("accepts the platform modifier with Shift", ({ metaKey, ctrlKey, macPlatform }) => { + expect( + isPasteAsTextShortcut( + { + key: "V", + metaKey, + ctrlKey, + shiftKey: true, + altKey: false, + }, + macPlatform, + ), + ).toBe(true); + }); + + it("does not claim ordinary or alternate paste chords", () => { + expect( + isPasteAsTextShortcut( + { + key: "v", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + true, + ), + ).toBe(false); + expect( + isPasteAsTextShortcut( + { + key: "v", + metaKey: true, + ctrlKey: false, + shiftKey: true, + altKey: true, + }, + true, + ), + ).toBe(false); + }); + + it("rejects the other platform's modifier", () => { + const event = { + key: "v", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + }; + expect(isPasteAsTextShortcut(event, true)).toBe(false); + expect(isPasteAsTextShortcut({ ...event, metaKey: true, ctrlKey: false }, false)).toBe(false); + }); +}); + +describe("text paste insertion", () => { + it("replaces the selected range and returns the collapsed cursor", () => { + expect( + replaceTextSelection({ + value: "before old after", + selection: { start: 7, end: 10 }, + text: "new", + }), + ).toEqual({ value: "before new after", cursor: 10 }); + }); + + it("clamps stale native selections", () => { + expect( + replaceTextSelection({ value: "abc", selection: { start: 20, end: 30 }, text: "!" }), + ).toEqual({ value: "abc!", cursor: 4 }); + }); + + it("measures the replacement value after removing the selected range", () => { + expect( + wouldTextPasteExceedLimit({ + valueLength: 100, + selection: { start: 40, end: 80 }, + textLength: 30, + maxLength: 100, + }), + ).toBe(false); + expect( + wouldTextPasteExceedLimit({ + valueLength: 100, + selection: { start: 40, end: 80 }, + textLength: 41, + maxLength: 100, + }), + ).toBe(true); + }); +}); diff --git a/packages/client-runtime/src/textPaste.ts b/packages/client-runtime/src/textPaste.ts new file mode 100644 index 000000000000..4155f0baad78 --- /dev/null +++ b/packages/client-runtime/src/textPaste.ts @@ -0,0 +1,76 @@ +export const PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES = 32 * 1024; + +const textEncoder = new TextEncoder(); + +export type PastedTextDisposition = "attachment" | "inline"; + +export function isPasteAsTextShortcut( + event: Pick, + macPlatform: boolean, +): boolean { + return ( + event.key.toLowerCase() === "v" && + event.shiftKey && + !event.altKey && + (macPlatform ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) + ); +} + +/** + * Large clipboard text becomes a file so an agent can inspect it selectively. + * The threshold is byte-based: character counts substantially understate the + * context cost of some Unicode-heavy clipboard contents. + */ +export function pastedTextDisposition(input: { + readonly text: string; + readonly canAttach: boolean; + readonly bypassAutoAttachment?: boolean; + readonly wouldExceedInputLimit?: boolean; +}): PastedTextDisposition { + if (input.bypassAutoAttachment || !input.canAttach || input.text.length === 0) { + return "inline"; + } + return input.wouldExceedInputLimit || + input.text.length >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES || + textEncoder.encode(input.text).byteLength >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES + ? "attachment" + : "inline"; +} + +/** Stable, human-readable names when a draft contains several folded pastes. */ +export function nextPastedTextFileName(existingNames: ReadonlyArray): string { + const names = new Set(existingNames.map((name) => name.toLowerCase())); + if (!names.has("pasted-text.txt")) { + return "pasted-text.txt"; + } + for (let sequence = 2; ; sequence += 1) { + const candidate = `pasted-text-${sequence}.txt`; + if (!names.has(candidate)) { + return candidate; + } + } +} + +export function replaceTextSelection(input: { + readonly value: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly text: string; +}): { readonly value: string; readonly cursor: number } { + const start = Math.max(0, Math.min(input.value.length, input.selection.start)); + const end = Math.max(start, Math.min(input.value.length, input.selection.end)); + return { + value: `${input.value.slice(0, start)}${input.text}${input.value.slice(end)}`, + cursor: start + input.text.length, + }; +} + +export function wouldTextPasteExceedLimit(input: { + readonly valueLength: number; + readonly selection: { readonly start: number; readonly end: number }; + readonly textLength: number; + readonly maxLength: number; +}): boolean { + const start = Math.max(0, Math.min(input.valueLength, input.selection.start)); + const end = Math.max(start, Math.min(input.valueLength, input.selection.end)); + return input.valueLength - (end - start) + input.textLength > input.maxLength; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..13453e38305c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1307,6 +1307,8 @@ export interface DesktopBridge { * builds lack it; callers fall back to VS Code only. */ probeRemoteEditors?: () => Promise; + /** Present when the desktop shell can perform an ordered plain-text paste. */ + pasteAsText?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; /** diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4d2f80a1101a..0d770eedc570 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -302,6 +302,9 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; +export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {}); +export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type; + export const ChatFileAttachment = Schema.Struct({ type: Schema.Literal("file"), id: ChatAttachmentId, @@ -311,6 +314,10 @@ export const ChatFileAttachment = Schema.Struct({ Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), ), + /** Clipboard text folded by a client. Providers keep these path-only so the + agent can inspect the file selectively instead of eagerly spending the + same context the fold is intended to preserve. */ + source: Schema.optional(PastedTextAttachmentSource), }); export type ChatFileAttachment = typeof ChatFileAttachment.Type;