diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 489641ec6c..0a3e7ed7e1 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3composereditor' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -17,4 +21,12 @@ android { dependencies { implementation project(':expo-modules-core') implementation project(':t3tools-mobile-markdown-text') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } 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 ef79a33d63..3ce8b10852 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 @@ -2,6 +2,7 @@ package expo.modules.t3composereditor import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.views.ViewDefinitionBuilder import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -61,9 +62,22 @@ internal object T3ComposerClipboard { } } +private fun ViewDefinitionBuilder.registerPasteConfiguration() { + Prop("clipboardFragment") { view: T3ComposerEditorView, fragment: String -> + view.setClipboardFragment(fragment) + } + Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int -> + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int -> + view.setMaxInputChars(maxInputChars) + } +} + class T3ComposerEditorModule : Module() { override fun definition() = ModuleDefinition { Name("T3ComposerEditor") + Constants("textPasteAttachmentRevision" to 1) AsyncFunction("writeContextClipboard") { text: String, fragment: String -> T3ComposerClipboard.write(requireNotNull(appContext.reactContext), text, fragment) @@ -76,9 +90,7 @@ class T3ComposerEditorModule : Module() { Prop("themeJson") { view: T3ComposerEditorView, themeJson: String -> view.setThemeJson(themeJson) } - Prop("clipboardFragment") { view: T3ComposerEditorView, fragment: String -> - view.setClipboardFragment(fragment) - } + registerPasteConfiguration() Prop("placeholder") { view: T3ComposerEditorView, placeholder: String -> view.setPlaceholder(placeholder) } @@ -116,7 +128,6 @@ class T3ComposerEditorModule : Module() { Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> view.setSpellCheck(spellCheck) } - Events( "onComposerChange", "onComposerSelectionChange", @@ -125,6 +136,7 @@ class T3ComposerEditorModule : Module() { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "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 ece2ce0d6f..e7ad853592 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 @@ -52,6 +53,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerPasteImages by EventDispatcher() private val onComposerContextPress by EventDispatcher() private val onComposerPasteContext by EventDispatcher() + private val onComposerPasteText by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false private var desiredLineHeightPx = 0 @@ -90,7 +92,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } - editor.pasteContextListener = { payload -> onComposerPasteContext(payload) } + editor.pasteContextListener = { payload -> + nativeEventCount += 1 + onComposerPasteContext( + payload + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "selection" to currentSelectionPayload(), + ) + ) + } val contextGestures = GestureDetector( context, @@ -113,6 +124,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( contextGestures.onTouchEvent(event) false } + editor.pasteTextListener = { text, start, end -> + nativeEventCount += 1 + onComposerPasteText( + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "text" to text, + "selection" to currentSelectionPayload(start, end), + ), + ) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -300,6 +322,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( updateInputFlags() } + fun setTextPasteThresholdBytes(threshold: Int) { + editor.textPasteThresholdBytes = threshold + } + + fun setMaxInputChars(maxInputChars: Int) { + editor.maxInputChars = maxInputChars + } + fun focusEditor() { editor.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager @@ -360,10 +390,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.highlightColor = defaultHighlightColor } - private fun currentSelectionPayload(): Map = + private fun currentSelectionPayload( + start: Int = editor.selectionStart, + end: Int = editor.selectionEnd + ): Map = mapOf( - "start" to editor.selectionStart.coerceAtLeast(0), - "end" to editor.selectionEnd.coerceAtLeast(0), + "start" to minOf(start, end).coerceAtLeast(0), + "end" to maxOf(start, end).coerceAtLeast(0), ) private fun emitSelectionChange(start: Int, end: Int) { @@ -374,7 +407,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( onComposerSelectionChange( mapOf( "value" to editor.text.toString(), - "selection" to mapOf("start" to start, "end" to end), + "selection" to currentSelectionPayload(start, end), "eventCount" to nativeEventCount, ), ) @@ -591,11 +624,14 @@ private fun parseTokens(value: String): List = try { emptyList() } -private class SelectionAwareEditText(context: Context) : EditText(context) { +internal class SelectionAwareEditText(context: Context) : EditText(context) { var readOnly = false var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null var pasteContextListener: ((Map) -> Unit)? = null + var pasteTextListener: ((String, Int, Int) -> Unit)? = null + var textPasteThresholdBytes = 0 + var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" /** @@ -680,7 +716,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) } } - override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) @@ -693,6 +728,9 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } val handled = when { id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut) + // Explicit Paste as Text keeps Android's native insertion and undo, even above the + // message limit. The send guard reports an over-limit draft without changing its bytes. + id == android.R.id.pasteAsPlainText -> false pasting -> pasteContextOrImages() else -> false } @@ -714,6 +752,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { pasteContextListener?.invoke(payload) return true } + return pasteImagesOrInterceptedText() + } + + private fun pasteImagesOrInterceptedText(): Boolean { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager val clip = clipboard?.primaryClip val imageUris = buildList { @@ -726,7 +768,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } } } - if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris) - return imageUris.isNotEmpty() + return when { + imageUris.isNotEmpty() -> { + pasteImagesListener?.invoke(imageUris) + true + } + else -> pasteInterceptedText(clip) + } + } + + private fun pasteInterceptedText(clip: ClipData?): Boolean { + val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null + if (text.isNullOrEmpty()) return false + val start = minOf(selectionStart, selectionEnd).coerceIn(0, length()) + val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length()) + val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars + val shouldFold = ( + text.length >= textPasteThresholdBytes || + text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes + ) + val shouldIntercept = exceedsInputLimit || shouldFold + if (shouldIntercept) { + pasteTextListener?.invoke(text, start, end) + } + // Let EditText perform ordinary pastes, retaining its native undo history. + return shouldIntercept + } + + // 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 onTextContextMenuItem(android.R.id.pasteAsPlainText) + } + return super.onKeyShortcut(keyCode, event) } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt new file mode 100644 index 0000000000..f695558f04 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -0,0 +1,122 @@ +package expo.modules.t3composereditor + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class ComposerPasteTest { + private val context = RuntimeEnvironment.getApplication() + private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val editor = SelectionAwareEditText(context).apply { + textPasteThresholdBytes = 32 * 1024 + maxInputChars = 120_000 + } + + private fun pasteAsText(): Boolean = editor.onKeyShortcut( + KeyEvent.KEYCODE_V, + KeyEvent( + 0, + 0, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_V, + 0, + KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON + ) + ) + + @Test + fun shortcutKeepsLargeTextInlineAndUndoable() { + val pasted = "x".repeat(32 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("before old after") + editor.setSelection(10, 7) + editor.pasteTextListener = { _, _, _ -> error("Inline paste must stay native") } + + assertTrue(pasteAsText()) + assertEquals("before $pasted after", editor.text.toString()) + assertTrue(editor.onTextContextMenuItem(android.R.id.undo)) + assertEquals("before old after", editor.text.toString()) + } + + @Test + fun shortcutKeepsOverLimitTextInlineForExplicitSendValidation() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(119_999)) + editor.setSelection(10, 7) + var intercepted: Triple? = null + editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) } + + assertTrue(pasteAsText()) + assertNull(intercepted) + assertEquals(119_999 + "hello".length - 3, editor.length()) + assertEquals("hello", editor.text.substring(7, 12)) + } + + @Test + fun shortcutAllowsReplacementAtTheInputLimit() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(120_000)) + editor.setSelection(12, 7) + editor.pasteTextListener = { _, _, _ -> error("Replacement fits the input limit") } + + assertTrue(pasteAsText()) + assertEquals("hello", editor.text.substring(7, 12)) + assertEquals(120_000, editor.length()) + } + + @Test + fun regularPasteStillFoldsAtTheUtf8Threshold() { + val pasted = "é".repeat(16 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("old") + editor.setSelection(0, 3) + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(pasted, intercepted) + assertEquals("old", editor.text.toString()) + } + + @Test + fun shortcutPastesStructuredClipboardAsText() { + clipboard.setPrimaryClip( + ClipData.newHtmlText( + "test", + "plain text", + "
plain text
" + ) + ) + editor.setSelection(0) + editor.pasteContextListener = { error("Paste as Text must not import structured context") } + + assertTrue(pasteAsText()) + assertEquals("plain text", editor.text.toString()) + } + + @Test + fun readOnlyShortcutDoesNotPasteOrEmitAnEvent() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "x".repeat(120_001))) + editor.setText("unchanged") + editor.setSelection(editor.length()) + editor.readOnly = true + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertFalse(pasteAsText()) + assertEquals("unchanged", editor.text.toString()) + assertNull(intercepted) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 4f0ead66e5..2fa1266554 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -39,6 +39,7 @@ enum T3ComposerClipboard { public class T3ComposerEditorModule: Module { public func definition() -> ModuleDefinition { Name("T3ComposerEditor") + Constants(["textPasteAttachmentRevision": 1]) AsyncFunction("writeContextClipboard") { (text: String, fragment: String) in T3ComposerClipboard.write(text: text, fragment: fragment) @@ -87,6 +88,12 @@ public class T3ComposerEditorModule: Module { Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { (view: T3ComposerEditorView, maxInputChars: Int) in + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -97,6 +104,7 @@ public class T3ComposerEditorModule: Module { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "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 11c62f36f2..10bf582582 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -86,10 +86,13 @@ private final class ComposerTextView: UITextView { var onPasteImages: (([String]) -> Void)? var onPasteContext: (([String: String]) -> Void)? + var onPasteText: ((String, NSRange) -> Void)? var clipboardFragment = "" var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? var isReadOnly = false + var textPasteThresholdBytes = 0 + var maxInputChars = Int.max override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -101,6 +104,16 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + if textPasteThresholdBytes > 0 { + let pasteAsText = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteInline(_:)) + ) + pasteAsText.discoverabilityTitle = "Paste as Text" + pasteAsText.wantsPriorityOverSystemBehavior = true + commands.append(pasteAsText) + } return commands } @@ -108,6 +121,17 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func pasteInline(_ sender: UIKeyCommand) { + guard !isReadOnly else { + return + } + // A structured Pylon clipboard also has a plain-text representation. + // Insert that text directly so Paste as Text never imports its context + // fragment or selects the image/HTML path in paste(_:). + guard let text = UIPasteboard.general.string, !text.isEmpty else { return } + insertText(text) + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false @@ -150,9 +174,27 @@ private final class ComposerTextView: UITextView { return } } + if let text = pasteboard.string, shouldInterceptTextPaste(text) { + onPasteText?(text, selectedRange) + return + } super.paste(sender) } + private func shouldInterceptTextPaste(_ text: String) -> Bool { + guard textPasteThresholdBytes > 0, !text.isEmpty else { return false } + let pastedLength = (text as NSString).length + if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes { + return true + } + // Chips occupy one display character but expand to their source in the + // submitted message. Measure that source, including the replaced selection. + let sourceLength = sourceOffset(forDisplayOffset: attributedText.length) + let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) - + sourceOffset(forDisplayOffset: selectedRange.location) + return sourceLength - selectedLength + pastedLength > maxInputChars + } + override func deleteBackward() { guard !isReadOnly else { return @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro let onComposerPasteImages = EventDispatcher() let onComposerContextPress = EventDispatcher() let onComposerPasteContext = EventDispatcher() + let onComposerPasteText = EventDispatcher() let onComposerContentSizeChange = EventDispatcher() public required init(appContext: AppContext? = nil) { @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro self?.onComposerPasteImages(["uris": urls]) } textView.onPasteContext = { [weak self] context in - self?.onComposerPasteContext(context) + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + var payload: [String: Any] = context + payload["value"] = self.textView.serializedText() + payload["eventCount"] = self.nativeEventCount + payload["selection"] = ["start": selection.start, "end": selection.end] + self.onComposerPasteContext(payload) + } + textView.onPasteText = { [weak self] text, _ in + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + self.onComposerPasteText([ + "value": self.textView.serializedText(), + "eventCount": self.nativeEventCount, + "text": text, + "selection": ["start": selection.start, "end": selection.end], + ]) } textView.onAttributedMutation = { [weak self] in self?.emitTextChange() @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.spellCheckingType = spellCheck ? .yes : .no } + func setTextPasteThresholdBytes(_ threshold: Int) { + textView.textPasteThresholdBytes = threshold + } + + func setMaxInputChars(_ maxInputChars: Int) { + textView.maxInputChars = maxInputChars + } + func focusEditor() { textView.becomeFirstResponder() } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 7430e28a63..533c131088 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -33,6 +33,15 @@ @interface T3ContextCopyTextView : UITextView @end @implementation T3ContextCopyTextView +// Read-only text still supports selecting the entire document after selecting a word. +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (action == @selector(selectAll:)) { + return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; + } + return [super canPerformAction:action withSender:sender]; +} + - (void)copy:(id)sender { NSRange selected = self.selectedRange; diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index caf8f676f0..04382839c9 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { // The document picker types every pick as a plain file, so a picture arrives here as one. // What it *is* decides how it presents, the same way videos are already recognised below. if (attachment.type === "image" || imageMimeType(attachment) !== null) { + // A pasted-text marker does not fit the snapshot source a picture carries. + const { source: _droppedSource, ...rest } = attachment; return ( ); diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 46365630b4..a1870c0046 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -20,6 +20,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { ) : null} - {table && document.rendered ? ( + {table && document.activeMode === "table" ? ( {table.truncated ? ( @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { - ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( + ) : document.activeMode === "markdown" && props.environmentId ? ( ) : ( - + )} ); @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { export function AttachmentFileScreen(props: AttachmentFileScreenProps) { const navigation = useNavigation(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { handleBack(); }, [draftKey, handleBack, params.attachmentId]); - const { content, renderedMode, rendered, setRendered, share, sharing } = document; + const { content, renderedMode, activeMode, setRendered, share, sharing } = document; const menuActions = useMemo( () => [ @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { onPress: () => setRendered(false), } as const) : null, + content && activeMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, content ? ({ id: "copy", @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { } as const) : null, ].filter((action) => action !== null), - [content, draftKey, removeFromDraft, renderedMode, setRendered, share, sharing, uri], + [ + appearance.codeWordBreak, + setCodeWordBreak, + content, + draftKey, + removeFromDraft, + activeMode, + renderedMode, + setRendered, + share, + sharing, + uri, + ], ); - const activeMode = rendered ? "preview" : "source"; + const selectedAction = activeMode === "source" ? "source" : "preview"; const androidMenuActions = useMemo( () => menuActions.map((action) => ({ id: action.id, title: action.title, image: action.icon, - state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined, + state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, ...("destructive" in action ? { attributes: { destructive: true } } : {}), })), - [activeMode, menuActions], + [selectedAction, menuActions], ); const handleAndroidMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { {action.title} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 2eabce998e..82d69b28e8 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; +import { + FlatList, + RefreshControl, + ScrollView, + Text as NativeText, + useWindowDimensions, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -23,13 +30,17 @@ import { NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; + +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly initialLine?: number | null; + /** Keep the entire document in one native text-selection scope. */ + readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ readonly onRefresh?: () => Promise | void; } @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { ? "ready" : "highlighting"; - return { lines, rowsJson, status, targetIndex, theme, tokens }; + return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat return null; } -function NativeSourceFileSurface( - props: SourceFileSurfaceProps & { - readonly NativeView: ComponentType; - }, -) { - const { NativeView, onRefresh } = props; - const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); - const { themeAppearance, themeId } = useAppearancePreferences(); - const appTheme = useUniwindTheme(); - const { width: viewportWidth } = useWindowDimensions(); - const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); +function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { if (!onRefresh) { @@ -169,6 +170,21 @@ function NativeSourceFileSurface( setIsPullRefreshing(false); } }, [onRefresh]); + return { isPullRefreshing, handlePullToRefresh }; +} + +function NativeSourceFileSurface( + props: SourceFileSurfaceProps & { + readonly NativeView: ComponentType; + }, +) { + const { NativeView, onRefresh } = props; + const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { themeAppearance, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); + const { width: viewportWidth } = useWindowDimensions(); + const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), @@ -212,9 +228,18 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); - const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); + const selectableTokens = useMemo( + () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null), + [props.selectable, tokens], + ); const listRef = useRef>(null); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); + const refreshControl = props.onRefresh ? ( + void handlePullToRefresh()} /> + ) : undefined; useEffect(() => { if (targetIndex === null) { @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { [codeSurface, codeWordBreak, targetIndex, tokens], ); + // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, + // which selects across every line, wraps, and lays out long documents through TextKit; on + // Android the primitive is an RN `Text`, which selects across its nested children. Either + // way "select all" takes the file rather than a line, which a `FlatList` row can never do + // because each row is its own selection scope. + const selectableBlock = props.selectable ? ( + + {selectableTokens + ? lines.map((line, index) => { + const lineTokens = selectableTokens[index] ?? null; + const body = + lineTokens && lineTokens.length > 0 + ? lineTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line; + return ( + + {body} + {index < lines.length - 1 ? "\n" : ""} + + ); + }) + : normalizedContents} + + ) : null; + const list = ( String(index)} initialNumToRender={80} @@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { /> ); + // Workspace files retain their numbered, virtualized rows, with or without a line target. + // Attachments opt into one selection scope for the entire document. + const usesLineList = !props.selectable; + const padded = ( + + {selectableBlock} + + ); + return ( - {codeWordBreak ? ( - list + {usesLineList ? ( + codeWordBreak ? ( + list + ) : ( + + {list} + + ) + ) : codeWordBreak ? ( + padded ) : ( + // Without wrapping the text keeps its natural width and the reader pans to it. - {list} + {padded} )} @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); - return NativeView ? ( + const { codeWordBreak } = useAppearanceCodeSurface(); + // The native canvas draws source lines without text selection or wrapping. Attachments + // need one selectable text view in either wrap mode; workspace line navigation can still + // use the canvas when wrapping is disabled. + return NativeView && !codeWordBreak && !props.selectable ? ( ) : ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index d588bad459..669762b60a 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, + // Only the source body wraps; a rendered preview lays itself out. + resolvedActiveMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, ...(mediaSource ? mediaActions.actions .filter(({ id }) => id !== "open-file") @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => copyTextWithHaptic(relativePath), } as const, ]), + // Selecting a long file by hand is painful on a phone, so copying the whole thing is + // the action most readers actually want. The attachment screen already offers it. + fileData?.contents != null + ? ({ + id: "copy-contents", + title: fileData.truncated ? "Copy preview" : "Copy contents", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(fileData.contents), + } as const) + : null, isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : null, ].filter((action) => action !== null); }, [ + appearance.codeWordBreak, + setCodeWordBreak, assetPreviewUri, assetPreview.refresh, previewUri, @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { resolvedActiveMode, mediaSource, mediaActions.actions, + fileData?.contents, + fileData?.truncated, ]); const androidFileMenuActions = useMemo( diff --git a/apps/mobile/src/features/files/source-file-document.test.ts b/apps/mobile/src/features/files/source-file-document.test.ts index 04b3046994..a490a77da9 100644 --- a/apps/mobile/src/features/files/source-file-document.test.ts +++ b/apps/mobile/src/features/files/source-file-document.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; describe("prepareSourceFileDocument", () => { it("normalizes and serializes source rows once for repeated consumers", () => { @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { expect(second).toBe(first); }); }); + +it("bounds selectable highlighting without allocating spans for huge files", () => { + const token = { content: "text", color: "#fff", fontStyle: null }; + const small = [[token]]; + expect(boundedSelectableSourceTokens(small)).toBe(small); + expect(boundedSelectableSourceTokens(null)).toBeNull(); + expect(boundedSelectableSourceTokens(Array.from({ length: 20_000 }, () => [token]))).toBeNull(); + expect(boundedSelectableSourceTokens([Array.from({ length: 2_000 }, () => token)])).toBeNull(); + const longPlainText = "full contents\n".repeat(50_000); + expect(prepareSourceFileDocument(longPlainText).contents).toBe(longPlainText); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts index d78ead3288..12f1fce099 100644 --- a/apps/mobile/src/features/files/source-file-document.ts +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -1,3 +1,4 @@ +import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; const MAX_CACHED_DOCUMENTS = 8; @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument return document; } + +// A selectable document cannot virtualize its rows. Cap React spans instead; large +// attachments remain fully selectable as one plain string, including every newline. +export function boundedSelectableSourceTokens( + tokens: ReadonlyArray> | null, +): typeof tokens { + if (!tokens) return null; + let spans = tokens.length; + for (const line of tokens) { + spans += line.length; + if (spans > 2_000) return null; + } + return tokens; +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 4bf29f849c..70c7558fd0 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,5 +1,11 @@ import { supportsUnicodeSkillAliases } from "@t3tools/client-runtime/providerSkills"; import { shouldHandleUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { + nextPastedTextFileName, + pastedTextDisposition, + replaceTextSelection, +} from "@t3tools/client-runtime/text-paste"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -12,7 +18,7 @@ import { usePreventRemove, type NavigationAction, } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, @@ -27,10 +33,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { + connectedPastedTextAttachmentLease, + useConnectedPastedTextAttachmentCapability, +} from "../../state/pasted-text-capability"; import { composerContextImportsAtom, useComposerDraft } from "../../state/use-composer-drafts"; import { composerContextSendBlockReason, @@ -77,13 +89,20 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { + appendComposerDraftAttachments, clearComposerDraftContent, + composerDraftIncarnation, + countComposerDraftAttachmentsAfterSelection, + insertComposerDraftTextIfIncarnation, + mayCommitPastedTextAttachment, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, @@ -195,6 +214,9 @@ export function NewTaskDraftScreen(props: { const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); + const pastedTextAttachmentsAvailable = useConnectedPastedTextAttachmentCapability( + selectedProject?.environmentId ?? null, + ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( @@ -396,6 +418,45 @@ export function NewTaskDraftScreen(props: { const activeShareImportTokenRef = useRef(null); const shareImportMountedRef = useRef(true); const latestDraftKeyRef = useRef(flow.draftKey); + const pasteOwner = useMemo( + () => ({ + key: flow.draftKey, + environmentId: flow.selectedEnvironmentId, + incarnation: Symbol("new-task-paste"), + }), + [ + flow.draftKey, + flow.selectedEnvironmentId, + flow.selectedProjectKey, + flow.editingPendingTask?.messageId, + ], + ); + const committedPasteOwner = useRef(null); + const pasteOwnerDraftIncarnations = useRef(new WeakMap()); + useLayoutEffect(() => { + if (pasteOwner.key) + pasteOwnerDraftIncarnations.current.set(pasteOwner, composerDraftIncarnation(pasteOwner.key)); + committedPasteOwner.current = pasteOwner; + return () => { + if (committedPasteOwner.current === pasteOwner) committedPasteOwner.current = null; + }; + }, [pasteOwner]); + const pastedTextNames = useRef<{ owner: symbol | null; names: Set }>({ + owner: null, + names: new Set(), + }); + const pendingPastedTextRef = useRef(new Map()); + const [pendingPastedTextState, setPendingPastedTextState] = useState<{ + owner: symbol; + count: number; + }>(() => ({ owner: pasteOwner.incarnation, count: 0 })); + const pendingPastedTextCount = + pendingPastedTextState.owner === pasteOwner.incarnation ? pendingPastedTextState.count : 0; + useLayoutEffect(() => { + return () => { + pendingPastedTextRef.current.delete(pasteOwner.incarnation); + }; + }, [pasteOwner]); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; @@ -1049,29 +1110,185 @@ export function NewTaskDraftScreen(props: { const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { + const draftKey = pasteOwner.key; + const draftIncarnation = pasteOwnerDraftIncarnations.current.get(pasteOwner) ?? null; + if (!draftKey || !mayCommitPastedTextAttachment(draftKey, draftIncarnation)) return; try { const images = await convertPastedImagesToAttachments({ uris, existingCount: flow.attachments.length, }); - if (images.length > 0) { - flow.appendAttachments(images); + if (images.length > 0 && mayCommitPastedTextAttachment(draftKey, draftIncarnation)) { + appendComposerDraftAttachments(draftKey, images, { appendReference: true }); } } catch (error) { console.error("[native paste] error converting images", error); } }, - [flow], + [flow.attachments.length, pasteOwner], + ); + + const handleNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const draftKey = pasteOwner.key; + if (!draftKey) return; + const draftIncarnation = pasteOwnerDraftIncarnations.current.get(pasteOwner) ?? null; + if (!mayCommitPastedTextAttachment(draftKey, draftIncarnation)) { + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the text to the current draft.", + ); + return; + } + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + flow.setPrompt(insertion.value); + composerMenu.onSelectionChange({ start: insertion.cursor, end: insertion.cursor }); + }; + const target = { text: paste.value, ...paste.selection }; + const preserveCapturedPaste = () => { + if (!insertComposerDraftTextIfIncarnation(draftKey, draftIncarnation, paste.text, target)) + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the text to the current draft.", + ); + }; + if (committedPasteOwner.current !== pasteOwner) { + preserveCapturedPaste(); + return; + } + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const connectedLease = + pasteOwner.environmentId == null + ? null + : connectedPastedTextAttachmentLease(pasteOwner.environmentId); + const capabilities = connectedLease?.config.environment.capabilities; + if (capabilities?.attachmentUploads !== true) { + if (!wouldExceedInputLimit) insertPaste(); + else + Alert.alert( + "Pasted text is too large for this message", + "Remove text or an attachment, then paste again.", + ); + return; + } + const advertisedMax = capabilities.fileAttachments?.maxUploadBytes; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const shouldFold = + pastedTextDisposition({ text: paste.text, wouldExceedInputLimit, canAttach: true }) === + "attachment"; + if (!shouldFold || (maxBytes === null && !wouldExceedInputLimit)) { + insertPaste(); + return; + } + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(draftKey, target) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if (!canAttach || maxBytes === null) { + if (!wouldExceedInputLimit) { + insertPaste(); + } else + Alert.alert( + "Pasted text is too large for this message", + "Remove text or an attachment, then paste again.", + ); + return; + } + if (pastedTextNames.current.owner !== pasteOwner.incarnation) + pastedTextNames.current = { owner: pasteOwner.incarnation, names: new Set() }; + const reserved = pastedTextNames.current.names; + for (const attachment of getComposerDraftSnapshot(draftKey).attachments) + reserved.add(attachment.name); + const name = nextPastedTextFileName([...reserved]); + reserved.add(name); + pendingPastedTextRef.current.set( + pasteOwner.incarnation, + (pendingPastedTextRef.current.get(pasteOwner.incarnation) ?? 0) + 1, + ); + setPendingPastedTextState({ + owner: pasteOwner.incarnation, + count: pendingPastedTextRef.current.get(pasteOwner.incarnation) ?? 0, + }); + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if ( + committedPasteOwner.current !== pasteOwner || + !mayCommitPastedTextAttachment(draftKey, draftIncarnation) || + pasteOwner.environmentId == null || + connectedPastedTextAttachmentLease(pasteOwner.environmentId)?.state !== + connectedLease?.state + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + preserveCapturedPaste(); + if (wouldExceedInputLimit) + Alert.alert("Pasted text is too large to send", "Reconnect before sending."); + return; + } + if (flow.appendAttachments([attachment], target) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + if (!wouldExceedInputLimit) { + insertComposerDraftTextIfIncarnation(draftKey, draftIncarnation, paste.text, target); + } else + Alert.alert( + "Could not attach pasted text", + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + preserveCapturedPaste(); + if (wouldExceedInputLimit) + Alert.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } finally { + const remaining = pendingPastedTextRef.current.get(pasteOwner.incarnation); + if (remaining !== undefined) { + pendingPastedTextRef.current.set(pasteOwner.incarnation, Math.max(0, remaining - 1)); + setPendingPastedTextState({ + owner: pasteOwner.incarnation, + count: pendingPastedTextRef.current.get(pasteOwner.incarnation) ?? 0, + }); + } + } + }, + [composerMenu, flow, pasteOwner, selectedEnvironmentServerConfig], ); async function handleStart(): Promise { - if (voiceInput.blocksSubmission) return; + if ( + voiceInput.blocksSubmission || + (pendingPastedTextRef.current.get(pasteOwner.incarnation) ?? 0) > 0 + ) + return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey || providerUnavailable) { return; } const draft = getComposerDraftSnapshot(draftKey); + if (draft.text.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + Alert.alert( + "Message text is too large", + "Remove some text or attach it as a file before starting the task.", + ); + return; + } if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return; // Snapshot read keeps just-typed selector state. Ambient stale defaults // may fall back, but a human/recovered exact provider choice must remain @@ -1117,6 +1334,18 @@ export function NewTaskDraftScreen(props: { ); return; } + if ( + draft.attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ) && + connectedPastedTextAttachmentLease(selectedProject.environmentId) === null + ) { + Alert.alert( + "Reconnect before sending", + "Pasted-text attachments need a connected server that supports them. Your draft is saved.", + ); + return; + } const contextBlockReason = composerContextSendBlockReason(draft.context); if (contextBlockReason) { @@ -1243,6 +1472,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + pendingPastedTextCount === 0 && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { @@ -1300,6 +1530,14 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onPasteText={ + pastedTextAttachmentsAvailable && + !voiceInput.freezesEditor && + !isIncomingShareTransferPending && + !flow.submitting + ? (paste) => void handleNativePasteText(paste) + : undefined + } placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ffc998b341..b4c30d4632 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -48,6 +48,8 @@ import { type SessionResourcesSnapshot, } from "@t3tools/client-runtime/state/session-resources"; import { PROVIDER_SESSION_AGENT_MESSAGE_MAX_CHARS } from "@t3tools/contracts"; +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { useConnectedPastedTextAttachmentCapability } from "../../state/pasted-text-capability"; import type { EnvironmentId, MessageId, @@ -253,6 +255,7 @@ export interface ThreadComposerProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onReloadSessionResources: () => Promise; @@ -416,6 +419,9 @@ export function ComposerSurface(props: { } export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const pastedTextAttachmentsAvailable = useConnectedPastedTextAttachmentCapability( + props.environmentId, + ); const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); const { materialYouStyleLayoutActive, themeVariables: materialTheme } = useAppearancePreferences(); @@ -426,6 +432,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const pendingPastedTextRef = useRef(new Map()); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, @@ -440,6 +447,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const pasteOwner = useMemo( + () => Symbol("composer-paste"), + [composerOwnerKey, props.selectedThread.sourceEpoch], + ); + const [pendingPastedTextState, setPendingPastedTextState] = useState<{ + owner: symbol; + count: number; + }>(() => ({ owner: pasteOwner, count: 0 })); + const pendingPastedTextCount = + pendingPastedTextState.owner === pasteOwner ? pendingPastedTextState.count : 0; + useLayoutEffect(() => { + return () => { + pendingPastedTextRef.current.delete(pasteOwner); + }; + }, [pasteOwner]); const draftContext = useComposerDraft(composerOwnerKey).context; const contextImports = useAtomValue(composerContextImportsAtom); const stripAttachments = useMemo( @@ -568,7 +590,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); // A provider follow-up bypasses the outbox and uploads its files itself, so // it still waits for the background transfer instead of starting another. - const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const sendBlockedReason = + props.sendBlockedReason ?? + (pendingPastedTextCount > 0 ? "Attaching pasted text" : null) ?? + attachmentBlockReason; const followUpBlockReason = sendBlockedReason ?? (attachmentsUploading ? "Attachment still uploading" : null); const canSend = @@ -1380,7 +1405,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleSend = useCallback(async () => { // canSend is derived above voiceInput, so the block lives here. // Reachable via a hardware-keyboard Return while recording. - if (voiceInput.blocksSubmission) return; + if (voiceInput.blocksSubmission || (pendingPastedTextRef.current.get(pasteOwner) ?? 0) > 0) + return; if (!canSend) return; if (handleLocalUsageLimits()) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); @@ -1815,6 +1841,29 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} + onPasteText={ + pastedTextAttachmentsAvailable && !voiceInput.freezesEditor + ? (paste) => { + pendingPastedTextRef.current.set( + pasteOwner, + (pendingPastedTextRef.current.get(pasteOwner) ?? 0) + 1, + ); + setPendingPastedTextState({ + owner: pasteOwner, + count: pendingPastedTextRef.current.get(pasteOwner) ?? 0, + }); + void props.onNativePasteText(paste).finally(() => { + const remaining = pendingPastedTextRef.current.get(pasteOwner); + if (remaining === undefined) return; + pendingPastedTextRef.current.set(pasteOwner, Math.max(0, remaining - 1)); + setPendingPastedTextState({ + owner: pasteOwner, + count: pendingPastedTextRef.current.get(pasteOwner) ?? 0, + }); + }); + } + : undefined + } 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 9c37a20fe8..bc846f958f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,5 @@ import type { WorktreeSetupCardProps } from "./worktree-setup-card"; +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; import { @@ -213,6 +214,7 @@ export interface ThreadDetailScreenProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onReloadSessionResources: () => Promise; @@ -1246,6 +1248,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} onReloadSessionResources={props.onReloadSessionResources} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 927514bc98..c364275f16 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1342,6 +1342,7 @@ function ThreadRouteContent( onPickDraftMedia={composer.onPickDraftMedia} onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} + onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 387bbba4fa..9a26176ccc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -52,6 +52,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, + type ComposerDraftInsertion, clearComposerDraft, composerDraftsAtom, createNewTaskDraft, @@ -207,7 +208,10 @@ type NewTaskFlowContextValue = { readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ - readonly appendAttachments: (attachments: ReadonlyArray) => number; + readonly appendAttachments: ( + attachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ) => number; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; @@ -649,12 +653,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Returns how many attachments the live cap rejected so the caller can // tell the user (a concurrent add can fill the draft mid-pick). const appendAttachments = useCallback( - (nextAttachments: ReadonlyArray): number => { + ( + nextAttachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ): number => { if (!selectedProjectDraftKey) { return 0; } return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { appendReference: true, + insertion, }); }, [selectedProjectDraftKey], diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 417a66138d..1986a3266f 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); + it("preserves the no-wrap default unless wrapping is explicitly enabled", () => { + expect(resolveAppearancePreferences(undefined).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({}).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: null }).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: false }).codeWordBreak).toBe(false); + }); + it("returns the authored text scale at the 16pt default", () => { expect(DEFAULT_BASE_FONT_SIZE).toBe(16); diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index b7b8397cc6..b371523a89 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -11,6 +11,7 @@ import type { FileBackedComposerAttachment } from "./composerImages"; import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; import { useRefreshAssetUrl } from "../state/assets"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; const isLocalUri = (uri: string) => /^(file|content):/.test(uri); @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { }) { const kind = filePreviewKind(input); const delimiter = filePreviewDelimiter(input); - const renderedMode = - kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; const shareController = useRef(null); useEffect(() => () => shareController.current?.abort(), []); const resource = useMemo( @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { const [contentError, setContentError] = useState(null); const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); const [rendered, setRendered] = useState(true); + const presentation = attachmentDocumentPresentation({ + kind, + hasTable: table !== null, + hasEnvironment: input.environmentId !== null, + rendered, + }); const [revision, setRevision] = useState(0); const [sharing, setSharing] = useState(false); const uri = input.attachment ? localUri : remoteUri; @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { }; return { kind, - renderedMode, + ...presentation, uri, /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ resource, diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts new file mode 100644 index 0000000000..9f63567cfc --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "vite-plus/test"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; + +it.each([ + { + kind: "markdown", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: "markdown", + activeMode: "markdown", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "markdown", + activeMode: "source", + }, + { + kind: "text", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: false, + rendered: true, + renderedMode: "table", + activeMode: "table", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: true, + rendered: false, + renderedMode: "table", + activeMode: "source", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: "html", + activeMode: "html", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "html", + activeMode: "source", + }, +] as const)("matches the available preview for %j", ({ renderedMode, activeMode, ...input }) => { + expect(attachmentDocumentPresentation(input)).toEqual({ renderedMode, activeMode }); +}); diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.ts new file mode 100644 index 0000000000..0d8136262a --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.ts @@ -0,0 +1,21 @@ +import type { FilePreviewKind } from "@t3tools/shared/filePreview"; + +/** The available preview and selected body must agree, including source-only draft files. */ +export function attachmentDocumentPresentation(input: { + kind: FilePreviewKind; + hasTable: boolean; + hasEnvironment: boolean; + rendered: boolean; +}) { + const renderedMode = input.hasTable + ? "table" + : input.kind === "markdown" && input.hasEnvironment + ? "markdown" + : input.kind === "html" + ? "html" + : null; + return { + renderedMode, + activeMode: input.rendered && renderedMode !== null ? renderedMode : "source", + } as const; +} diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index ea56d3f508..2bcbf139b1 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -15,6 +15,11 @@ const mocks = vi.hoisted(() => ({ writeFile: vi.fn(), deleteFile: vi.fn(), readBase64: vi.fn(), + pastedTextLease: null as null | { readonly state: object }, +})); + +vi.mock("../state/pasted-text-capability", () => ({ + connectedPastedTextAttachmentLease: () => mocks.pastedTextLease, })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ @@ -93,6 +98,7 @@ vi.mock("expo-file-system", () => ({ import { prepareTurnAttachments, + preparedPastedTextLeaseCurrent, releasePendingAttachmentUploads, withUploadedMobileAttachmentReferences, validateDraftFileAttachments, @@ -194,6 +200,7 @@ function removeCallsFor(attachmentId: string): number { describe("prepareTurnAttachments", () => { beforeEach(() => { + mocks.pastedTextLease = null; mocks.documentUri = "file:///documents"; mocks.createAssetUrl.mockReset(); mocks.createAssetUrl.mockImplementation((target: unknown) => target); @@ -240,6 +247,36 @@ describe("prepareTurnAttachments", () => { expect(mocks.upload).not.toHaveBeenCalled(); }); + it("keeps a marked draft when an old server lacks the pasted-text capability", async () => { + const marked = { ...file, source: { _tag: "pasted-text" as const } }; + await expect(prepareTurnAttachments({ environmentId, attachments: [marked] })).rejects.toThrow( + "Pasted-text attachments require the connected server", + ); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("rejects a same-ID server replacement after upload without sending old marked bytes", async () => { + const oldState = {}; + mocks.pastedTextLease = { state: oldState }; + mocks.upload.mockImplementation(async () => { + mocks.pastedTextLease = { state: {} }; + return { status: 204, body: "", headers: {} }; + }); + const marked = { ...file, source: { _tag: "pasted-text" as const } }; + await expect(prepareTurnAttachments({ environmentId, attachments: [marked] })).rejects.toThrow( + "Pasted-text attachments require the connected server", + ); + expect(removeCallsFor(MINTED_ID)).toBe(1); + mocks.pastedTextLease = { state: oldState }; + mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} }); + const prepared = await prepareTurnAttachments({ environmentId, attachments: [marked] }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(preparedPastedTextLeaseCurrent(environmentId, prepared)).toBe(true); + mocks.pastedTextLease = { state: {} }; + expect(preparedPastedTextLeaseCurrent(environmentId, prepared)).toBe(false); + }); + it("inlines a file-backed image lazily when the server lacks image uploads", async () => { const prepared = await prepareTurnAttachments({ environmentId, @@ -360,7 +397,15 @@ describe("prepareTurnAttachments", () => { }); it("uploads generic file bytes directly and keeps mixed attachment order", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + mocks.pastedTextLease = { state: {} }; + 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", @@ -379,11 +424,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 01b023abda..c73027ad93 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -1,4 +1,5 @@ import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { ConnectedInitialConfig } from "@t3tools/client-runtime/state/session"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, @@ -20,6 +21,7 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; +import { connectedPastedTextAttachmentLease } from "../state/pasted-text-capability"; import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { @@ -155,10 +157,22 @@ export interface PreparedTurnAttachments { readonly draftAttachments: ReadonlyArray; /** Every pending upload backing this turn (reused and newly minted). */ readonly pendingAttachmentIds: ReadonlyArray; + /** The exact connected server that accepted any marked pasted-text file. */ + readonly pastedTextLeaseState?: ConnectedInitialConfig["state"]; /** Deletes all pending uploads once the delivered turn holds the bytes. */ readonly releaseUploads: () => Promise; } +export function preparedPastedTextLeaseCurrent( + environmentId: EnvironmentId, + prepared: PreparedTurnAttachments, +): boolean { + return ( + prepared.pastedTextLeaseState === undefined || + connectedPastedTextAttachmentLease(environmentId)?.state === prepared.pastedTextLeaseState + ); +} + export type PrepareTurnAttachmentsResult = | PreparedTurnAttachments | { readonly status: "abandoned" }; @@ -200,7 +214,11 @@ function uploadedReference( // chat view with nothing to show a thumbnail from, on every client. return isComposerImageAttachment(attachment) ? { type: "image", ...fields } - : { type: "file", ...fields }; + : { + type: "file", + ...fields, + ...(attachment.source ? { source: attachment.source } : {}), + }; } function attachmentUploadInput(attachment: DraftComposerAttachment) { @@ -322,6 +340,21 @@ export async function prepareTurnAttachments(input: { }): Promise { const { environmentId } = input; if (input.signal?.aborted) return { status: "abandoned" }; + const hasPastedText = input.attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ); + const pasteLease = hasPastedText ? connectedPastedTextAttachmentLease(environmentId) : null; + const assertPasteLease = () => { + if ( + hasPastedText && + (pasteLease === null || + connectedPastedTextAttachmentLease(environmentId)?.state !== pasteLease.state) + ) + throw new Error( + "Pasted-text attachments require the connected server that supports them. Reconnect before sending.", + ); + }; + assertPasteLease(); const files = input.attachments.filter((attachment) => attachment.type === "file"); const ready = ( attachments: ReadonlyArray, @@ -332,6 +365,7 @@ export async function prepareTurnAttachments(input: { attachments, draftAttachments, pendingAttachmentIds, + ...(pasteLease ? { pastedTextLeaseState: pasteLease.state } : {}), releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), }); @@ -435,6 +469,7 @@ export async function prepareTurnAttachments(input: { } if (controller.signal.aborted) throw new Error("Upload cancelled."); + assertPasteLease(); const draftAttachments = withUploadedMobileAttachmentReferences({ environmentId, @@ -450,6 +485,7 @@ export async function prepareTurnAttachments(input: { return { status: "abandoned" }; } } + assertPasteLease(); return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); } catch (error) { await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 41aced8c98..bf87e1e000 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 1eab265882..85e6dd8cd9 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,14 +1,28 @@ 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(); + +const clipboard = vi.hoisted(() => ({ + hasImageAsync: vi.fn(), + getImageAsync: vi.fn(), + hasStringAsync: vi.fn(), + getStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => clipboard); vi.mock("expo-file-system", () => ({ File: class { readonly uri: string; + readonly name: string; + readonly parentDirectory: { readonly uri: string }; - constructor(uri: string) { - this.uri = uri; + 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 +43,90 @@ 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, + pasteComposerClipboard, +} from "./composerImages"; + +describe("composer clipboard paste", () => { + beforeEach(() => { + vi.clearAllMocks(); + clipboard.hasImageAsync.mockResolvedValue(false); + clipboard.hasStringAsync.mockResolvedValue(true); + clipboard.getStringAsync.mockResolvedValue("clipboard text"); + clipboard.getImageAsync.mockResolvedValue({ data: "data:image/png;base64,aGVsbG8=" }); + }); + + it("returns only the image when the clipboard contains both image and text", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + const result = await pasteComposerClipboard({ existingCount: 0 }); + expect(result).toEqual({ + images: [expect.objectContaining({ type: "image", name: "pasted-image.png" })], + text: null, + error: null, + }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("does not paste alternate text when the image cannot fit", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + expect( + await pasteComposerClipboard({ existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }), + ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("returns plain text without image chips", async () => { + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: "clipboard text", + error: null, + }); + }); + + it("reports an empty text clipboard", async () => { + clipboard.getStringAsync.mockResolvedValue(""); + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: null, + error: "Clipboard is empty.", + }); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { @@ -91,6 +181,26 @@ 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); + }); }); describe("composerStripAttachments", () => { @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { fileUri: "file:///notes.txt", }; - it("keeps media even when it already has an inline chip", async () => { + it("keeps media, because a thumbnail is the only way to see it", async () => { const { composerStripAttachments } = await import("./composerImages"); - const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"])); - // A thumbnail is the only way to see media, so it stays regardless of the chip. + const kept = composerStripAttachments([image, video] as never, new Set()); expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); }); - it("drops a plain file once its inline chip represents it", async () => { + it("shows a document above the composer only when it has no inline chip", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set(["doc-1"]))).toEqual([]); + expect(composerStripAttachments([doc] as never, new Set([doc.id]))).toEqual([]); + expect(composerStripAttachments([doc] as never, new Set())).toEqual([doc]); }); - it("keeps a plain file that has no inline chip", async () => { + it("keeps media beside a document rather than dropping the whole strip", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set()).map((a) => a.id)).toEqual(["doc-1"]); + expect( + composerStripAttachments([doc, image, video] as never, new Set([doc.id])).map((a) => a.id), + ).toEqual(["img-1", "vid-1"]); + }); + + it("treats a picture picked through the document picker as media", async () => { + const { composerStripAttachments } = await import("./composerImages"); + // The document picker types every pick as a plain file; what it *is* decides the strip. + const pickedImage = { + id: "pick-1", + type: "file" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 30, + fileUri: "file:///photo.png", + }; + expect(composerStripAttachments([pickedImage] as never, new Set()).map((a) => a.id)).toEqual([ + "pick-1", + ]); }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 86658db8dc..91b64c77a4 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"; @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; +import { writeFileAtomically } from "./atomic-file"; export interface DraftComposerImageAttachment extends Omit { readonly id: string; @@ -40,18 +42,43 @@ 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; -/** - * What the strip above the composer shows. Media previews there because a thumbnail is the - * only way to see it; every other file is already legible as its inline chip, so it only - * falls back to the strip when the prompt carries no reference to it. Mirrors web's - * `composerOtherFilesForPresentation`. - */ +/** Documents with no inline reference remain visible in the attachment strip. */ export function composerStripAttachments( attachments: ReadonlyArray, inlineAttachmentIds: ReadonlySet, @@ -539,11 +566,19 @@ export async function pickComposerMedia(input: { }; } -export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ - readonly images: ReadonlyArray; - readonly text: string | null; - readonly error: string | null; -}> { +/** Clipboard images take priority over their alternate text representation. */ +export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise< + | { + readonly images: ReadonlyArray; + readonly text: null; + readonly error: string | null; + } + | { + readonly images: readonly []; + readonly text: string; + readonly error: null; + } +> { let clipboard: Awaited>; try { clipboard = await loadClipboard(); @@ -605,8 +640,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const text = await clipboard.getStringAsync(); return { images: [], - text: text.length > 0 ? text : null, - error: text.length > 0 ? null : "Clipboard is empty.", + ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }), }; } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 7f69acc26a..98b0c8f230 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -32,6 +34,8 @@ import { type ComposerNativeEventSnapshot, } from "./composerEditorRevision"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; +import { supportsNativePastedTextAttachments } from "./composerPasteCapability"; +import { hasVersionedComposerPasteContext } from "./composerPasteEvent"; const NATIVE_MODULE_NAME = "T3ComposerEditor"; const EMPTY_SKILLS: NonNullable = []; @@ -52,6 +56,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -81,8 +92,18 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativeSyntheticEvent<{ + readonly text: string; + readonly fragment: string; + readonly html: string; + readonly value?: string; + readonly eventCount?: number; + readonly selection?: ComposerEditorSelection; + }>, ) => void; + readonly textPasteThresholdBytes?: number; + readonly maxInputChars?: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: () => void; @@ -108,12 +129,14 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, onSubmit, contentInsetVertical = 0, ...props }: ComposerEditorProps) { + const supportsTextPaste = supportsNativePastedTextAttachments(); const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); @@ -297,6 +320,12 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + {...(supportsTextPaste + ? { + textPasteThresholdBytes: onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0, + maxInputChars: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + } + : {})} style={style as StyleProp} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -330,7 +359,44 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + if (!hasVersionedComposerPasteContext(paste)) { + props.onPasteContext?.(paste); + return; + } + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + {...(supportsTextPaste + ? { + onComposerPasteText: (event: NativePasteTextEvent) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }, + } + : {})} onComposerFocus={onFocus} onComposerBlur={onBlur} onComposerSubmit={onSubmit} @@ -342,4 +408,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 7c715d0039..659c22b15a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -35,6 +37,8 @@ import { type ComposerNativeEventSnapshot, } from "./composerEditorRevision"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; +import { supportsNativePastedTextAttachments } from "./composerPasteCapability"; +import { hasVersionedComposerPasteContext } from "./composerPasteEvent"; const NATIVE_MODULE_NAME = "T3ComposerEditor"; const EMPTY_SKILLS: NonNullable = []; @@ -55,6 +59,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -85,8 +96,18 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativeSyntheticEvent<{ + readonly text: string; + readonly fragment: string; + readonly html: string; + readonly value?: string; + readonly eventCount?: number; + readonly selection?: ComposerEditorSelection; + }>, ) => void; + readonly textPasteThresholdBytes?: number; + readonly maxInputChars?: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; } @@ -111,11 +132,13 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, contentInsetVertical = 0, ...props }: ComposerEditorProps) { + const supportsTextPaste = supportsNativePastedTextAttachments(); const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); @@ -306,6 +329,12 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + {...(supportsTextPaste + ? { + textPasteThresholdBytes: onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0, + maxInputChars: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + } + : {})} style={{ flex: 1, minHeight: 0 }} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -340,7 +369,44 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + if (!hasVersionedComposerPasteContext(paste)) { + props.onPasteContext?.(paste); + return; + } + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + {...(supportsTextPaste + ? { + onComposerPasteText: (event: NativePasteTextEvent) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }, + } + : {})} onComposerFocus={onFocus} onComposerBlur={onBlur} /> @@ -352,4 +418,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 07a409c9a4..9ff7f41a6e 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 365dfbf526..f8ae0dac9d 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { readonly end: number; }; +export type ComposerTextPaste = { + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}; + export interface ComposerEditorHandle { focus: () => void; blur: () => void; @@ -51,6 +58,7 @@ export interface ComposerEditorProps { readonly start: number; readonly end: number; }) => 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/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index ccc2214e24..30f3ba23ee 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -192,3 +192,25 @@ describe("assumeComposerControlledState", () => { ); }); }); + +describe("typing immediately before an intercepted paste", () => { + it("keeps the pre-paste React value behind the native paste revision", () => { + const snapshots = [ + { eventCount: 0, value: "", selection: { start: 0, end: 0 } }, + { eventCount: 1, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 2, value: "typed", selection: { start: 0, end: 5 } }, + ]; + // Both native platforms stamp the paste with its current value and selection. + // A render still carrying the typing caret cannot overwrite that selection. + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 2, snapshots)).toBe( + 1, + ); + expect(isComposerNativeEcho("typed", { start: 0, end: 5 }, 2, snapshots)).toBe(true); + // The replacement is a parent edit at the acknowledged paste revision. + expect(resolveComposerControlledEventCount("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe( + 2, + ); + expect(isComposerNativeEcho("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(false); + expect(acknowledgeComposerNativeEvent(2, 1)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/native/composerPasteCapability.test.ts b/apps/mobile/src/native/composerPasteCapability.test.ts new file mode 100644 index 0000000000..3164305e76 --- /dev/null +++ b/apps/mobile/src/native/composerPasteCapability.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const native = vi.hoisted(() => ({ + module: null as null | { textPasteAttachmentRevision?: number }, +})); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => native.module })); + +import { supportsNativePastedTextAttachments } from "./composerPasteCapability"; + +describe("installed composer native paste capability", () => { + beforeEach(() => { + native.module = null; + }); + + it("keeps an old installed binary on ordinary native paste", () => { + expect(supportsNativePastedTextAttachments()).toBe(false); + native.module = {}; + expect(supportsNativePastedTextAttachments()).toBe(false); + }); + + it("enables interception only for the matching native revision", () => { + native.module = { textPasteAttachmentRevision: 1 }; + expect(supportsNativePastedTextAttachments()).toBe(true); + native.module = { textPasteAttachmentRevision: 2 }; + expect(supportsNativePastedTextAttachments()).toBe(false); + }); +}); diff --git a/apps/mobile/src/native/composerPasteCapability.ts b/apps/mobile/src/native/composerPasteCapability.ts new file mode 100644 index 0000000000..938a15ea8c --- /dev/null +++ b/apps/mobile/src/native/composerPasteCapability.ts @@ -0,0 +1,14 @@ +import { requireOptionalNativeModule } from "expo"; + +/** Old installed binaries have no interception event or paste-size props. */ +export function supportsNativePastedTextAttachments(): boolean { + try { + return ( + requireOptionalNativeModule<{ readonly textPasteAttachmentRevision?: number }>( + "T3ComposerEditor", + )?.textPasteAttachmentRevision === 1 + ); + } catch { + return false; + } +} diff --git a/apps/mobile/src/native/composerPasteEvent.test.ts b/apps/mobile/src/native/composerPasteEvent.test.ts new file mode 100644 index 0000000000..aaf291b2a2 --- /dev/null +++ b/apps/mobile/src/native/composerPasteEvent.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hasVersionedComposerPasteContext } from "./composerPasteEvent"; + +describe("native context paste across installed binary versions", () => { + it("keeps legacy context clipboard payloads on their existing import path", () => { + expect(hasVersionedComposerPasteContext({ text: "hello", fragment: "{}", html: "" })).toBe( + false, + ); + }); + + it("accepts a complete revisioned payload and rejects a partial one", () => { + const base = { text: "hello", fragment: "{}", html: "", value: "before", eventCount: 4 }; + expect(hasVersionedComposerPasteContext({ ...base, selection: { start: 2, end: 2 } })).toBe( + true, + ); + expect(hasVersionedComposerPasteContext({ ...base, selection: { start: 2 } })).toBe(false); + }); +}); diff --git a/apps/mobile/src/native/composerPasteEvent.ts b/apps/mobile/src/native/composerPasteEvent.ts new file mode 100644 index 0000000000..cc1e61d008 --- /dev/null +++ b/apps/mobile/src/native/composerPasteEvent.ts @@ -0,0 +1,37 @@ +import type { ComposerEditorSelection } from "./T3ComposerEditor.types"; + +/** The installed native editor may predate versioned paste-context events. */ +export function hasVersionedComposerPasteContext< + T extends { + readonly text?: unknown; + readonly fragment?: unknown; + readonly html?: unknown; + readonly eventCount?: unknown; + readonly value?: unknown; + readonly selection?: unknown; + }, +>( + event: T, +): event is T & { + readonly eventCount: number; + readonly value: string; + readonly selection: ComposerEditorSelection; +} { + if ( + typeof event.eventCount !== "number" || + !Number.isFinite(event.eventCount) || + typeof event.value !== "string" || + typeof event.selection !== "object" || + event.selection === null + ) + return false; + const selection = event.selection; + return ( + "start" in selection && + typeof selection.start === "number" && + Number.isFinite(selection.start) && + "end" in selection && + typeof selection.end === "number" && + Number.isFinite(selection.end) + ); +} diff --git a/apps/mobile/src/state/pasted-text-capability.test.ts b/apps/mobile/src/state/pasted-text-capability.test.ts new file mode 100644 index 0000000000..b3e1985558 --- /dev/null +++ b/apps/mobile/src/state/pasted-text-capability.test.ts @@ -0,0 +1,65 @@ +import type { ConnectedInitialConfig } from "@t3tools/client-runtime/state/session"; +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + stateAtom: Symbol("state"), + configAtom: Symbol("config"), + get: vi.fn(), +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: mocks.get })); +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { stateAtom: () => mocks.stateAtom }, +})); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { get: mocks.get } })); +vi.mock("./session", () => ({ + environmentSession: { connectedInitialConfigAtom: () => mocks.configAtom }, +})); + +import { + connectedPastedTextAttachmentLease, + useConnectedPastedTextAttachmentCapability, +} from "./pasted-text-capability"; + +const environmentId = EnvironmentId.make("paste-capability-test"); +const connectedState = { + phase: "connected", +} as ConnectedInitialConfig["state"]; +const config = { + environment: { + capabilities: { + pastedTextAttachments: true, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }, + }, +} as ServerConfig; + +describe("pasted text capability during connection refresh", () => { + it("uses the current connected lease even when both atoms are waiting", () => { + const lease = { state: connectedState, config }; + mocks.get.mockImplementation((atom: symbol) => + atom === mocks.stateAtom + ? AsyncResult.waiting(AsyncResult.success(connectedState)) + : AsyncResult.waiting(AsyncResult.success(Option.some(lease))), + ); + + expect(connectedPastedTextAttachmentLease(environmentId)).toBe(lease); + expect(useConnectedPastedTextAttachmentCapability(environmentId)).toBe(true); + }); + + it("rejects a stale lease from an earlier connection", () => { + const staleState = { phase: "connected" } as ConnectedInitialConfig["state"]; + mocks.get.mockImplementation((atom: symbol) => + atom === mocks.stateAtom + ? AsyncResult.waiting(AsyncResult.success(connectedState)) + : AsyncResult.waiting(AsyncResult.success(Option.some({ state: staleState, config }))), + ); + + expect(connectedPastedTextAttachmentLease(environmentId)).toBeNull(); + expect(useConnectedPastedTextAttachmentCapability(environmentId)).toBe(false); + }); +}); diff --git a/apps/mobile/src/state/pasted-text-capability.ts b/apps/mobile/src/state/pasted-text-capability.ts new file mode 100644 index 0000000000..a8188a10d3 --- /dev/null +++ b/apps/mobile/src/state/pasted-text-capability.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectedInitialConfigForState, + type ConnectedInitialConfig, +} from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { appAtomRegistry } from "./atom-registry"; +import { environmentSession } from "./session"; + +const EMPTY_RESULT = Atom.make(AsyncResult.initial(false)); + +// Stream atoms can be waiting for their next emission while retaining a valid value. +// The connected lease identity, rather than the waiting flag, fences stale config. +export function connectedPastedTextAttachmentLease( + environmentId: EnvironmentId, +): ConnectedInitialConfig | null { + const stateResult = appAtomRegistry.get(environmentCatalog.stateAtom(environmentId)); + const configResult = appAtomRegistry.get( + environmentSession.connectedInitialConfigAtom(environmentId), + ); + const state = Option.getOrNull(AsyncResult.value(stateResult)); + const observed = Option.getOrElse(AsyncResult.value(configResult), () => Option.none()); + const config = connectedInitialConfigForState(state, observed); + return config?.environment.capabilities.pastedTextAttachments === true && + config.environment.capabilities.attachmentUploads === true && + config.environment.capabilities.fileAttachments !== undefined && + Option.isSome(observed) + ? observed.value + : null; +} + +export function useConnectedPastedTextAttachmentCapability( + environmentId: EnvironmentId | null, +): boolean { + const stateResult = useAtomValue( + environmentId === null ? EMPTY_RESULT : environmentCatalog.stateAtom(environmentId), + ); + const configResult = useAtomValue( + environmentId === null + ? EMPTY_RESULT + : environmentSession.connectedInitialConfigAtom(environmentId), + ); + if (environmentId === null) return false; + const state = Option.getOrNull(AsyncResult.value(stateResult)); + const observed = Option.getOrElse(AsyncResult.value(configResult), () => Option.none()); + const capabilities = connectedInitialConfigForState(state, observed)?.environment.capabilities; + return ( + capabilities?.pastedTextAttachments === true && + capabilities.attachmentUploads === true && + capabilities.fileAttachments !== undefined + ); +} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6ef9d4fec5..9fd0aa193d 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -134,6 +134,7 @@ vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }) vi.mock("./assets", () => ({ assetEnvironment: {} })); vi.mock("./attachments", () => ({ attachmentEnvironment: {} })); vi.mock("./session", () => ({ environmentSession: {} })); +vi.mock("./pasted-text-capability", () => ({ connectedPastedTextAttachmentLease: () => null })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ createEnvironmentRpcCommand: () => Symbol("rpc-command"), executeAtomQuery: () => { @@ -154,6 +155,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ })); import type { DraftComposerAttachment } from "../lib/composerImages"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; import { recoverHeldCreationDraft } from "./recover-failed-thread-draft"; import { @@ -166,12 +168,18 @@ import { threadOutboxManager } from "./thread-outbox"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import { appendComposerDraftAttachments, + bindComposerDraftSourceEpoch, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + getComposerDraftAfterSelection, archiveCloudComposerDrafts, clearComposerDraftContent, + clearComposerDraft, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, + composerDraftIncarnation, composerContextImportsAtom, beginComposerContextImport, composerCloudDraftsAtom, @@ -198,6 +206,10 @@ import { retargetNewTaskDraft, setComposerDraftText, insertComposerDraftContext, + insertComposerDraftText, + insertComposerDraftTextIfIncarnation, + isComposerDraftIncarnationCurrent, + mayCommitPastedTextAttachment, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -423,6 +435,81 @@ describe("mobile composer drafts", () => { expect(restorePendingSendComposerDraftState(restored, key, held)).toEqual(restored); }); + it.each([false, true])( + "restores visible file chips from legacy drafts (archived: %s)", + async (archived) => { + const file = { + type: "file" as const, + id: "legacy-file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const image = { ...file, id: "photo", name: "photo.png", mimeType: "image/png" }; + const video = { ...file, id: "video", name: "clip.mp4", mimeType: "video/mp4" }; + const legacy = { text: "Review these", attachments: [file, image, video] }; + const document = { + schemaVersion: 1, + drafts: archived ? {} : { thread: legacy }, + ...(archived + ? { signedOutDrafts: { account: { drafts: { thread: legacy }, queuedMessages: [] } } } + : {}), + }; + const decoded = decodePersistedComposerState(document); + const restored = archived + ? decoded.cloudDrafts.signedOut.account?.drafts.thread + : decoded.drafts.thread; + expect(restored?.text).toBe("Review these [notes.txt](t3-context://v1/file/legacy-file) "); + expect(restored?.attachments).toEqual(legacy.attachments); + expect(restored?.context?.records).toEqual([ + expect.objectContaining({ kind: "file", attachmentId: file.id }), + ]); + expect( + decodePersistedComposerState({ schemaVersion: 1, drafts: { thread: restored } }).drafts + .thread, + ).toEqual(restored); + appAtomRegistry.set(composerDraftsAtom, { thread: restored! }); + setComposerDraftText("thread", "Review these"); + expect(getComposerDraftSnapshot("thread").attachments).toEqual([image, video]); + await releaseUnusedComposerAttachmentFiles([file]); + }, + ); + + it("restores a missing file reference without duplicating its record or replacing another record's id", () => { + const file = { + type: "file" as const, + id: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const existing = { + version: 1, + contextId: "original", + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }; + const skill = { version: 1, contextId: "file", kind: "skill", label: "Skill", name: "skill" }; + for (const records of [[existing, skill], [skill]]) { + const restored = decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + thread: { text: "", attachments: [file], context: { version: 1, records } }, + }, + }).drafts.thread; + expect(restored?.context?.records).toHaveLength(2); + expect(restored?.context?.records).toContainEqual(skill); + expect(restored?.text).toBe( + `[notes.txt](t3-context://v1/file/${records.length === 2 ? "original" : "file_2"}) `, + ); + } + }); it.each([false, true])( "restores deleted file chips and releases undo history (uploaded: %s)", async (uploaded) => { @@ -493,6 +580,30 @@ describe("mobile composer drafts", () => { expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); }); + it("gives a folded paste a chip that survives the send", () => { + const key = "environment-1:thread-1"; + // What `createPastedTextComposerAttachment` produces for a long paste. + const pasted = { + type: "file" as const, + id: "pasted-1", + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///pasted-text.txt", + }; + appendComposerDraftAttachments(key, [pasted], { appendReference: true }); + + const draft = getComposerDraftSnapshot(key); + // Visible in the composer before sending, not only once the message lands. + expect(draft.text).toContain("pasted-text.txt"); + expect(draft.context?.records).toMatchObject([ + { kind: "file", attachmentId: pasted.id, name: pasted.name }, + ]); + // The reference points at the record, so the chip stays a chip in the sent message. + const [record] = draft.context?.records ?? []; + expect(draft.text).toContain(String(record?.contextId)); + }); + it("drops chips and records for attachments a replace no longer keeps", () => { const key = "new-task:draft-1"; const kept = { @@ -665,41 +776,57 @@ describe("mobile composer drafts", () => { expect(reloaded.cloudDrafts.signedOut).toEqual({}); }); - it("removes a file only after its last reference is deleted, while retaining images", async () => { - const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => outboxLoad.mockRestore()); - const cleanup = Promise.withResolvers(); - composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { - cleanup.resolve(); - }); - const key = "environment-1:remove-context-files"; - const file = { - id: "file-1", - type: "file" as const, - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 4, - fileUri: "file:///notes.txt", - }; - const image = { - ...file, - id: "image-1", - type: "image" as const, - name: "image.png", - mimeType: "image/png", - fileUri: "file:///image.png", - previewUri: "file:///image.png", - }; - appendComposerDraftAttachments(key, [file, image], { appendReference: true }); - const fileLink = "[notes.txt](t3-context://v1/file/file-1)"; - setComposerDraftText(key, `${fileLink} ${fileLink}`); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, fileLink); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, "plain text"); - expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); - await cleanup.promise; - }); + it.each([ + { name: "notes.txt", mimeType: "text/plain" }, + { name: "document-photo.png", mimeType: "image/png" }, + { name: "recording.mp4", mimeType: "video/mp4" }, + ])( + "removes $name after its last reference is deleted, while retaining native images", + async ({ name, mimeType }) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + }); + const key = "environment-1:remove-context-files"; + const file = { + id: "file-1", + type: "file" as const, + name, + mimeType, + sizeBytes: 4, + fileUri: `file:///${name}`, + }; + const image = { + ...file, + id: "image-1", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + fileUri: "file:///image.png", + previewUri: "file:///image.png", + }; + appendComposerDraftAttachments(key, [file, image], { appendReference: true }); + const fileLink = formatComposerContextReference( + getComposerDraftSnapshot(key).context!.records[0]!, + ); + setComposerDraftText(key, `${fileLink} ${fileLink}`); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + setComposerDraftText(key, fileLink); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + expect( + countComposerDraftAttachmentsAfterSelection(key, { + text: fileLink, + start: 0, + end: fileLink.length, + }), + ).toBe(1); + setComposerDraftText(key, "plain text"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); + await cleanup.promise; + }, + ); it("rejects attachments atomically when no context slots remain", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); @@ -745,6 +872,316 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual(before); await cleanup.promise; }); + + it.each(["text", "image"])( + "does not apply %s returned by a delayed clipboard read after draft discard", + async (kind) => { + const key = `environment-1:clipboard-${kind}`; + setComposerDraftText(key, "original"); + const target = captureComposerDraftInsertion(key); + const incarnation = composerDraftIncarnation(key); + const clipboardRead = Promise.withResolvers(); + const pending = clipboardRead.promise.then(() => { + if (!mayCommitPastedTextAttachment(key, incarnation)) return false; + if (kind === "text") insertComposerDraftText(key, " pasted", target); + else + appendComposerDraftAttachments( + key, + [ + { + id: "clipboard-image", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }, + ], + { appendReference: true, insertion: target }, + ); + return true; + }); + clearComposerDraft(key); + setComposerDraftText(key, "replacement"); + clipboardRead.resolve(); + expect(await pending).toBe(false); + expect(getComposerDraftSnapshot(key)).toMatchObject({ text: "replacement", attachments: [] }); + }, + ); + it.each(["environment-1:thread", "environment-1:new-task:draft"])( + "preserves the captured paste selection across async writes in %s", + async (key) => { + const file = { + id: "paste", + type: "file" as const, + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///paste.txt", + }; + setComposerDraftText(key, "before selected after"); + const insertion = captureComposerDraftInsertion(key, { start: 7, end: 15 }); + const write = Promise.withResolvers(); + const pending = write.promise.then((attachment) => + appendComposerDraftAttachments(key, [attachment], { appendReference: true, insertion }), + ); + rememberComposerDraftSelection(key, insertion.text, { start: 0, end: 6 }); + rememberComposerDraftSelection("another-draft", "unrelated", { start: 0, end: 9 }); + write.resolve(file); + expect(await pending).toBe(0); + const draft = getComposerDraftSnapshot(key); + expect(draft.text).toBe("before [pasted-text.txt](t3-context://v1/file/paste) after"); + expect(draft.attachments).toEqual([file]); + }, + ); + + it("preserves edits made while a paste is pending instead of deleting stale offsets", async () => { + const key = "environment-1:typing-during-paste"; + setComposerDraftText(key, "old selection"); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: 13 }); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => insertComposerDraftText(key, " pasted", insertion)); + setComposerDraftText(key, "keep newly typed text"); + write.resolve(); + await pending; + expect(getComposerDraftSnapshot(key).text).toBe("keep newly typed text pasted"); + }); + + it("completes an intercepted paste in its original draft after navigation and intervening typing", async () => { + const key = "environment-1:paste-owner"; + setComposerDraftText(key, "before after"); + const target = captureComposerDraftInsertion(key, { start: 7, end: 7 }); + const incarnation = composerDraftIncarnation(key); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => + insertComposerDraftTextIfIncarnation(key, incarnation, "pasted ", target), + ); + // Another editor can become active while the captured draft keeps changing. + setComposerDraftText("environment-2:other", "other draft"); + setComposerDraftText(key, "newly typed text"); + write.resolve(); + expect(await pending).toBe(true); + expect(getComposerDraftSnapshot(key).text).toBe("newly typed textpasted "); + expect(getComposerDraftSnapshot("environment-2:other").text).toBe("other draft"); + }); + + it("does not resurrect a discarded draft when a delayed paste finishes after same-key recreation", async () => { + const key = "environment-1:discarded-paste"; + setComposerDraftText(key, "old draft"); + const target = captureComposerDraftInsertion(key); + const incarnation = composerDraftIncarnation(key); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => + isComposerDraftIncarnationCurrent(key, incarnation) + ? insertComposerDraftTextIfIncarnation(key, incarnation, "pasted", target) + : false, + ); + clearComposerDraft(key); + expect(isComposerDraftIncarnationCurrent(key, incarnation)).toBe(false); + setComposerDraftText(key, "new draft"); + write.resolve(); + expect(await pending).toBe(false); + expect(getComposerDraftSnapshot(key).text).toBe("new draft"); + }); + + it("rejects an old source epoch paste while keeping a same-epoch navigation paste", async () => { + const key = "environment-1:epoch-paste"; + setComposerDraftText(key, "draft"); + const target = captureComposerDraftInsertion(key); + const oldEpoch = bindComposerDraftSourceEpoch(key, 4); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => + insertComposerDraftTextIfIncarnation(key, oldEpoch, " old", target), + ); + expect(bindComposerDraftSourceEpoch(key, 4)).toBe(oldEpoch); + const newEpoch = bindComposerDraftSourceEpoch(key, 5); + expect(newEpoch).not.toBe(oldEpoch); + expect(isComposerDraftIncarnationCurrent(key, oldEpoch)).toBe(false); + write.resolve(); + expect(await pending).toBe(false); + expect(getComposerDraftSnapshot(key).text).toBe("draft"); + }); + + it.each([ + ["existing thread", "environment-1:thread-paste-discard"], + ["new task", "new-task:environment-1:task-paste-discard"], + ])( + "does not append a completed %s pasted file after its draft is discarded", + async (_kind, key) => { + setComposerDraftText(key, "old draft"); + const incarnation = composerDraftIncarnation(key); + const write = Promise.withResolvers(); + const attachment = { + id: "completed-paste", + type: "file" as const, + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 6, + fileUri: "file:///documents/t3-composer-attachments/pasted-text.txt", + source: { _tag: "pasted-text" as const }, + }; + const pending = write.promise.then(() => + mayCommitPastedTextAttachment(key, incarnation) + ? appendComposerDraftAttachments(key, [attachment], { appendReference: true }) + : null, + ); + clearComposerDraft(key); + setComposerDraftText(key, "replacement draft"); + write.resolve(); + expect(await pending).toBeNull(); + expect(getComposerDraftSnapshot(key)).toMatchObject({ + text: "replacement draft", + attachments: [], + }); + }, + ); + + it.each(["attachment", "context", "imported context"])( + "releases a selected file when replaced by %s", + async (kind) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:replace-file"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `file-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; + if (kind === "attachment") { + expect( + appendComposerDraftAttachments(key, [replacement], { appendReference: true, insertion }), + ).toBe(0); + } else if (kind === "imported context") { + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("replacement"), + attachmentId: replacement.id, + label: replacement.name, + name: replacement.name, + mimeType: replacement.mimeType, + sizeBytes: replacement.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [replacement], + }, + insertion, + ), + ).toBe(true); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); + expect(getComposerDraftSnapshot(key).text).toBe( + `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, + ); + } else { + insertComposerDraftContext( + key, + { text: "replacement", context: { version: 1, records: [] } }, + insertion, + ); + } + const draft = getComposerDraftSnapshot(key); + expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); + expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); + }, + ); + + it("rejects an imported file atomically when edits during import use up its replacement slot", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:concurrent-import"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `existing-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/existing-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + setComposerDraftText(key, `New edit ${insertion.text}`); + const edited = getComposerDraftSnapshot(key); + const imported = { ...files[0]!, id: "imported", fileUri: "file:///imported.txt" }; + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("imported"), + attachmentId: imported.id, + label: imported.name, + name: imported.name, + mimeType: imported.mimeType, + sizeBytes: imported.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [imported], + }, + insertion, + ), + ).toBe(false); + expect(getComposerDraftSnapshot(key)).toEqual(edited); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(imported.fileUri); + }); + + it("retains a file when replacing only one of its repeated references", () => { + const key = "environment-1:repeat-reference"; + const file = { + id: "repeat", + type: "file" as const, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4, + fileUri: "file:///repeat.txt", + }; + appendComposerDraftAttachments(key, [file], { appendReference: true }); + const link = getComposerDraftSnapshot(key).text; + setComposerDraftText(key, `${link} ${link}`); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: link.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(1); + insertComposerDraftContext( + key, + { text: "replaced", context: { version: 1, records: [] } }, + insertion, + ); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file]); + expect(getComposerDraftSnapshot(key).text).toBe(`replaced ${link}`); + }); + it("inserts context at the saved caret and retains its payload through persistence and restore", () => { const draftKey = "context-environment:context-thread"; const record = { @@ -803,7 +1240,25 @@ describe("mobile composer drafts", () => { }, }).drafts, ).toEqual({ - "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + "environment-1:thread-1": { + text: "Review this file [report.pdf](t3-context://v1/file/file-1) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); }); @@ -1121,8 +1576,34 @@ describe("mobile composer drafts", () => { const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); onTestFinished(() => enqueue.mockRestore()); await restoreCloudComposerDrafts("account-a"); - expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); - expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(getComposerDraftSnapshot(key)).toEqual( + type === "image" + ? { text: "Unsent notes", attachments: [file] } + : { + text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, + ); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe( + type === "image" + ? "Edited queued task" + : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ", + ); expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); const persisted = decodePersistedComposerState( @@ -2628,7 +3109,25 @@ describe("mobile composer drafts", () => { await fresh.releaseUnusedComposerAttachmentFiles([file]); expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ - "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, + "environment-1:thread-1": { + text: "Persisted draft [report.pdf](t3-context://v1/file/file-cold-start) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index f386ccccc5..eceb624d1f 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -24,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; import { + collectComposerContextReferences, formatComposerContextReference, sanitizeComposerContextLabel, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, @@ -57,6 +59,47 @@ const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; const COMPOSER_DRAFTS_FILE = "drafts.json"; const PERSIST_DEBOUNCE_MS = 200; +const draftIncarnations = new Map(); +const draftSourceEpochs = new Map(); + +/** A retained draft keeps its identity across edits and navigation, but not discard. */ +export function composerDraftIncarnation(draftKey: string): symbol { + let incarnation = draftIncarnations.get(draftKey); + if (!incarnation) { + incarnation = Symbol(draftKey); + draftIncarnations.set(draftKey, incarnation); + } + return incarnation; +} + +export function isComposerDraftIncarnationCurrent( + draftKey: string, + incarnation: symbol | null, +): boolean { + return incarnation !== null && draftIncarnations.get(draftKey) === incarnation; +} + +/** Gate a completed async paste write before it can append to a durable draft. */ +export function mayCommitPastedTextAttachment( + draftKey: string, + incarnation: symbol | null, +): boolean { + return isComposerDraftIncarnationCurrent(draftKey, incarnation); +} + +function retireComposerDraftIncarnation(draftKey: string): void { + draftIncarnations.delete(draftKey); + draftSourceEpochs.delete(draftKey); +} + +/** A server source-history replacement cannot inherit an older paste completion. */ +export function bindComposerDraftSourceEpoch(draftKey: string, sourceEpoch: number): symbol { + const previous = draftSourceEpochs.get(draftKey); + if (previous !== undefined && previous !== sourceEpoch) retireComposerDraftIncarnation(draftKey); + draftSourceEpochs.set(draftKey, sourceEpoch); + return composerDraftIncarnation(draftKey); +} + export const composerContextImportsAtom = Atom.make>({}).pipe( Atom.keepAlive, ); @@ -103,6 +146,89 @@ export function readComposerDraftSelection( return { start: lastComposerSelection.start, end: lastComposerSelection.end }; } +export interface ComposerDraftInsertion { + readonly text: string; + readonly start: number; + readonly end: number; +} + +/** Capture the paste target before any clipboard reads, downloads, or file writes. */ +export function captureComposerDraftInsertion( + draftKey: string, + selection?: { start: number; end: number }, +): ComposerDraftInsertion { + const { text } = getComposerDraftSnapshot(draftKey); + return { + text, + ...(selection ?? + readComposerDraftSelection(draftKey, text) ?? { start: text.length, end: text.length }), + }; +} + +function contextInsertionRange( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const captured = + target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null); + // Edits made during a file write must not be replaced using stale offsets. A changed + // draft receives the file at its end; moving only the caret preserves the captured range. + const selection = captured?.text === draft.text ? captured : null; + const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); + return { start, end: Math.max(start, Math.min(selection?.end ?? start, draft.text.length)) }; +} + +function draftWithoutInsertionSelection( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = `${draft.text.slice(0, start)} ${draft.text.slice(end)}`; + return withReferencedContextFiles(draft, text, referencedComposerContext(text, draft.context)); +} + +export function countComposerDraftAttachmentsAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): number { + return getComposerDraftAfterSelection(draftKey, target).attachments.length; +} + +export function getComposerDraftAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): ComposerDraft { + return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target); +} + +function withReferencedContextFiles( + draft: ComposerDraft, + text: string, + context: OrchestrationMessageContext | undefined, +): ComposerDraft { + const previousIds = new Set( + draft.context?.records.flatMap((record) => + "attachmentId" in record ? [record.attachmentId] : [], + ), + ); + const retainedIds = new Set( + context?.records.flatMap((record) => ("attachmentId" in record ? [record.attachmentId] : [])), + ); + return { + ...draft, + text, + context, + attachments: draft.attachments.filter( + (attachment) => + attachment.type === "image" || + !previousIds.has(attachment.id) || + retainedIds.has(attachment.id), + ), + }; +} + /** Retains file bytes while native text undo can restore their references. */ export function createComposerDraftContextHistory() { const restoreContext = createComposerContextHistory(); @@ -170,16 +296,39 @@ export function setComposerDraftContext( export function insertComposerDraftContext( draftKey: string, - content: { text: string; context: OrchestrationMessageContext }, + content: { + text: string; + context: OrchestrationMessageContext; + attachments?: ReadonlyArray; + }, + target?: ComposerDraftInsertion, ): boolean { let inserted = false; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const draft = normalizeDraft(current[draftKey]); - const nextDraft = draftWithInsertedContext(draftKey, draft, content); + // An import replacing a captured selection cannot safely retarget that + // selection after intervening edits. Reject the whole import instead. + if (target && target.start !== target.end && target.text !== draft.text) return current; + const attachments = content.attachments ?? []; + const retained = draftWithoutInsertionSelection(draftKey, draft, target); + if ( + attachments.length > 0 && + retained.attachments.length + attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) + return current; + const nextDraft = draftWithInsertedContext( + draftKey, + { ...draft, attachments: [...draft.attachments, ...attachments] }, + content, + target, + ); if (!nextDraft) return current; inserted = true; + removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); return { ...current, [draftKey]: nextDraft }; }); + scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); return inserted; } @@ -187,13 +336,9 @@ function draftWithInsertedContext( draftKey: string, draft: ComposerDraft, content: { text: string; context: OrchestrationMessageContext }, + target?: ComposerDraftInsertion, ): ComposerDraft | null { - const selection = - lastComposerSelection?.draftKey === draftKey && lastComposerSelection.text === draft.text - ? lastComposerSelection - : null; - const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); - const end = Math.max(start, Math.min(selection?.end ?? start, draft.text.length)); + const { start, end } = contextInsertionRange(draftKey, draft, target); const before = draft.text.slice(0, start); const after = draft.text.slice(end); const insertion = `${before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content.text) ? " " : ""}${content.text}${!/\s$/.test(content.text) && (after.length === 0 || !/^\s/.test(after)) ? " " : ""}`; @@ -208,7 +353,7 @@ function draftWithInsertedContext( start: start + insertion.length, end: start + insertion.length, }; - return { ...draft, text, context }; + return withReferencedContextFiles(draft, text, context); } export class ComposerDraftPersistenceError extends Schema.TaggedError()( @@ -378,6 +523,68 @@ export function resetComposerDraftsLoadState(): void { persistRetryNeeded = false; } +function attachmentContextRecord( + attachment: DraftComposerAttachment, + contextId = ComposerContextId.make(attachment.id), +) { + const common = { + version: 1 as const, + contextId, + label: sanitizeComposerContextLabel(attachment.name, attachment.type), + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + // A picture picked through the document picker is typed as a plain file, but the + // record has to say what it is or no client will offer to open it as an image. + return attachment.type === "image" || imageMimeType(attachment) !== null + ? { ...common, kind: "image" as const } + : { ...common, kind: "file" as const }; +} + +/** Older drafts stored documents only in the attachment strip. Restore their missing chips. */ +function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft { + const records = [...(draft.context?.records ?? [])]; + const usedIds = new Set(records.map((record) => record.contextId)); + const referenced = new Set( + collectComposerContextReferences(draft.text).map( + (reference) => `${reference.kind}:${reference.contextId}`, + ), + ); + let text = draft.text; + let changed = false; + for (const attachment of draft.attachments) { + if ( + attachment.type === "image" || + imageMimeType(attachment) !== null || + videoMimeType(attachment) !== null + ) + continue; + let record = records.find( + (candidate) => + candidate.kind === "file" && + "attachmentId" in candidate && + candidate.attachmentId === attachment.id, + ); + if (!record) { + const baseId = attachment.id.replace(/[^a-z0-9_-]/gi, "_").slice(0, 110) || "file"; + let contextId = baseId; + for (let suffix = 2; usedIds.has(contextId); suffix += 1) contextId = `${baseId}_${suffix}`; + usedIds.add(contextId); + record = attachmentContextRecord(attachment, ComposerContextId.make(contextId)); + records.push(record); + changed = true; + } + const key = `${record.kind}:${record.contextId}`; + if (referenced.has(key)) continue; + text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatComposerContextReference(record)} `; + referenced.add(key); + changed = true; + } + return changed ? { ...draft, text, context: { version: 1, records } } : draft; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -448,14 +655,15 @@ export function migrateLegacyNewTaskDraft( draft: ComposerDraft, now: string, ): readonly [key: string, draft: ComposerDraft] { + const restored = restoreMissingComposerFileReferences(draft); const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; if (legacy === null) { - return [key, draft]; + return [key, restored]; } return [ newTaskDraftKey(newDraftId()), { - ...draft, + ...restored, project: { environmentId: EnvironmentIdSchema.make(legacy.environmentId), projectId: ProjectIdSchema.make(legacy.projectId), @@ -1122,28 +1330,40 @@ export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const context = referencedComposerContext(value, existing.context); - const retainedIds = new Set(context?.records.map((record) => record.contextId)); - const removedFileIds = new Set( - existing.context?.records.flatMap((record) => - record.kind === "file" && "attachmentId" in record && !retainedIds.has(record.contextId) - ? [record.attachmentId] - : [], - ), - ); - removed = existing.attachments.filter( - (attachment) => attachment.type !== "image" && removedFileIds.has(attachment.id), - ); - const draft = { - ...existing, - text: value, - context, - attachments: existing.attachments.filter((attachment) => !removed.includes(attachment)), - }; + const draft = withReferencedContextFiles(existing, value, context); + removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment)); return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup(removed); } +export function insertComposerDraftText( + draftKey: string, + value: string, + target: ComposerDraftInsertion, +): void { + const draft = getComposerDraftSnapshot(draftKey); + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = draft.text.slice(0, start) + value + draft.text.slice(end); + setComposerDraftText(draftKey, text); + rememberComposerDraftSelection(draftKey, text, { + start: start + value.length, + end: start + value.length, + }); +} + +/** Complete an intercepted paste only into the draft that originally owned it. */ +export function insertComposerDraftTextIfIncarnation( + draftKey: string, + incarnation: symbol | null, + value: string, + target: ComposerDraftInsertion, +): boolean { + if (!isComposerDraftIncarnationCurrent(draftKey, incarnation)) return false; + insertComposerDraftText(draftKey, value, target); + return true; +} + export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); @@ -1170,6 +1390,7 @@ export function appendComposerDraftAttachments( options?: { readonly allowOverflow?: boolean; readonly appendReference?: boolean; + readonly insertion?: ComposerDraftInsertion; readonly maxAttachments?: number; }, ): number { @@ -1177,8 +1398,12 @@ export function appendComposerDraftAttachments( return 0; } let rejected: ReadonlyArray = []; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); + const retained = options?.appendReference + ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) + : existing; const remaining = options?.allowOverflow ? attachments.length : Math.max( @@ -1186,10 +1411,10 @@ export function appendComposerDraftAttachments( Math.min( PROVIDER_SEND_TURN_MAX_ATTACHMENTS, options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ) - existing.attachments.length, + ) - retained.attachments.length, ); const contextCapacity = options?.appendReference - ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (existing.context?.records.length ?? 0)) + ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (retained.context?.records.length ?? 0)) : attachments.length; const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); rejected = attachments.slice(accepted.length); @@ -1198,38 +1423,31 @@ export function appendComposerDraftAttachments( } let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; if (options?.appendReference) { - const records = accepted.map((attachment) => { - const common = { - version: 1 as const, - contextId: ComposerContextId.make(attachment.id), - label: sanitizeComposerContextLabel(attachment.name, attachment.type), - attachmentId: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }; - // A picture picked through the document picker is typed as a plain file, but the - // record has to say what it is or no client will offer to open it as an image. - return attachment.type === "image" || imageMimeType(attachment) !== null - ? { ...common, kind: "image" as const } - : { ...common, kind: "file" as const }; - }); - const inserted = draftWithInsertedContext(draftKey, draft, { - text: records.map(formatComposerContextReference).join(" "), - context: { version: 1, records }, - }); + const records = accepted.map((attachment) => attachmentContextRecord(attachment)); + const inserted = draftWithInsertedContext( + draftKey, + draft, + { + text: records.map(formatComposerContextReference).join(" "), + context: { version: 1, records }, + }, + options?.insertion, + ); if (!inserted) { rejected = attachments; return current; } draft = { ...inserted, attachments: [...inserted.attachments] }; + removed = existing.attachments.filter( + (attachment) => !draft.attachments.includes(attachment), + ); } return { ...current, [draftKey]: draft, }; }); - scheduleUnusedComposerAttachmentCleanup(rejected); + scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); return rejected.length; } @@ -1724,6 +1942,7 @@ export function clearComposerDraftContent( readonly deferAttachmentCleanup?: boolean; }, ): void { + retireComposerDraftIncarnation(draftKey); const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); if (!options?.deferAttachmentCleanup) { @@ -1735,6 +1954,7 @@ export function clearComposerDraft( draftKey: string, options?: { readonly deferAttachmentCleanup?: boolean }, ): void { + retireComposerDraftIncarnation(draftKey); const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -1859,6 +2079,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI const removedAttachments = Object.entries(current) .filter(([draftKey]) => next[draftKey] === undefined) .flatMap(([, draft]) => draft.attachments); + for (const draftKey of Object.keys(current)) { + if (next[draftKey] === undefined) retireComposerDraftIncarnation(draftKey); + } if (persistTimer !== null) { clearTimeout(persistTimer); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 2f8571793d..34b6c41fe5 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,12 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { CommandId, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, RuntimeTaskId, type EnvironmentId, type ModelSelection, @@ -15,6 +16,10 @@ 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 type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; +import { supportsNativePastedTextAttachments } from "../native/composerPasteCapability"; import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; import { parseCodexFeedbackCommand, @@ -62,12 +67,18 @@ import { serializeComposerMessageForEnvironment } from "./serialize-composer-mes import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; -import { prepareTurnAttachments, validateDraftFileAttachments } from "../lib/attachmentUpload"; +import { + prepareTurnAttachments, + preparedPastedTextLeaseCurrent, + validateDraftFileAttachments, +} from "../lib/attachmentUpload"; import { scopedThreadKey } from "../lib/scopedEntities"; import { canSendToModelSelection, @@ -82,9 +93,14 @@ import { serverEnvironment } from "../state/server"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, - appendComposerDraftText, + countComposerDraftAttachmentsAfterSelection, + insertComposerDraftText, + insertComposerDraftTextIfIncarnation, + mayCommitPastedTextAttachment, insertComposerDraftContext, clearComposerDraftContent, + captureComposerDraftInsertion, + bindComposerDraftSourceEpoch, composerDraftsAtom, composerContextImportsAtom, ensureComposerDraftsLoaded, @@ -98,6 +114,7 @@ import { useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; +import { connectedPastedTextAttachmentLease } from "./pasted-text-capability"; import { useEnvironmentServerConfig, useProjects } from "../state/entities"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; @@ -237,6 +254,32 @@ export function useThreadComposerState() { const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; + const pasteOwner = useMemo( + () => ({ + key: selectedThreadKey, + incarnation: Symbol("thread-paste"), + }), + [selectedThreadKey, selectedThreadShell?.sourceEpoch], + ); + const committedPasteOwner = useRef(null); + const pasteOwnerDraftIncarnations = useRef(new WeakMap()); + const pendingPasteWrites = useRef(new Map()); + useLayoutEffect(() => { + if (pasteOwner.key) + pasteOwnerDraftIncarnations.current.set( + pasteOwner, + bindComposerDraftSourceEpoch(pasteOwner.key, selectedThreadShell?.sourceEpoch ?? 0), + ); + committedPasteOwner.current = pasteOwner; + return () => { + if (committedPasteOwner.current === pasteOwner) committedPasteOwner.current = null; + pendingPasteWrites.current.delete(pasteOwner.incarnation); + }; + }, [pasteOwner, selectedThreadShell?.sourceEpoch]); + const pastedTextNames = useRef<{ owner: symbol | null; names: Set }>({ + owner: null, + names: new Set(), + }); // The creation entry is the thread itself (rendered as the first message), // not a follow-up waiting behind it. const selectedThreadQueuedMessages = useMemo( @@ -679,7 +722,11 @@ export function useThreadComposerState() { (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); const onSendMessage = useCallback(async () => { - if (!selectedThreadShell || sessionCompactionBlocksSubmission) { + if ( + !selectedThreadShell || + sessionCompactionBlocksSubmission || + (pendingPasteWrites.current.get(pasteOwner.incarnation) ?? 0) > 0 + ) { return null; } // The server has not created this thread yet. Queuing a follow-up against @@ -691,11 +738,28 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + if (getComposerDraftSnapshot(threadKey).text.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + setPendingConnectionError( + "Message text is too large. Remove some text or attach it as a file before sending.", + ); + return null; + } const draft = getComposerDraftSnapshot(threadKey); if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; + if ( + attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ) && + connectedPastedTextAttachmentLease(selectedThreadShell.environmentId) === null + ) { + setPendingConnectionError( + "Pasted-text attachments need a connected server that supports them. Reconnect before sending.", + ); + return null; + } if ( composerAttachmentUploadBlockReason({ environmentId: selectedThreadShell.environmentId, @@ -866,12 +930,14 @@ export function useThreadComposerState() { selectedThreadProject, selectedThreadServerConfig, selectedThreadShell, + pasteOwner, uploadThreadFeedback, ]); const onQueueFollowUp = useCallback(async () => { if ( !selectedThreadShell || + (pendingPasteWrites.current.get(pasteOwner.incarnation) ?? 0) > 0 || sessionCompactionBlocksSubmission || selectedThreadDetail?.session?.status !== "running" || selectedThreadDetail.session.activeTurnId == null @@ -880,6 +946,12 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); + if (draft.text.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + setPendingConnectionError( + "Message text is too large. Remove some text or attach it as a file before sending.", + ); + return null; + } const submissionSettings = resolveExistingThreadComposerSettings({ thread: selectedThreadDetail, sessionProviderInstanceId: selectedSessionProviderInstanceId, @@ -895,6 +967,17 @@ export function useThreadComposerState() { } const text = draft.text.trim(); const attachments = draft.attachments; + if ( + attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ) && + connectedPastedTextAttachmentLease(selectedThreadShell.environmentId) === null + ) { + setPendingConnectionError( + "Pasted-text attachments need a connected server that supports them. Reconnect before sending.", + ); + return null; + } if (text.length === 0 && attachments.length === 0) return null; if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; const contextBlockReason = composerContextSendBlockReason(draft.context); @@ -969,6 +1052,13 @@ export function useThreadComposerState() { setPendingConnectionError("The attachments are no longer available."); return null; } + if (!preparedPastedTextLeaseCurrent(selectedThreadShell.environmentId, prepared)) { + await prepared.releaseUploads(); + setPendingConnectionError( + "The server changed while pasted text was uploading. Reconnect and send again.", + ); + return null; + } // Defer cleanup: the sweep would otherwise delete the local bytes while the // queue call is still in flight, leaving a failure restore pointing at @@ -1019,6 +1109,7 @@ export function useThreadComposerState() { selectedThreadServerConfig, selectedThreadShell, sessionCompactionBlocksSubmission, + pasteOwner, ]); const onMessageSessionAgent = useCallback( @@ -1320,23 +1411,159 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const result = await pasteComposerClipboard({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, - }); - const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { - appendReference: true, - }); - if (result.text) { - appendComposerDraftText(threadKey, result.text); - } - if (result.error) { - setPendingConnectionError(result.error); - } else if (rejectedPasteCount > 0) { - setPendingConnectionError( - `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + const target = captureComposerDraftInsertion(threadKey); + const draftIncarnation = pasteOwnerDraftIncarnations.current.get(pasteOwner) ?? null; + if (!mayCommitPastedTextAttachment(threadKey, draftIncarnation)) { + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the clipboard content.", ); + return; } - }, [composerDrafts, selectedThreadShell]); + pendingPasteWrites.current.set( + pasteOwner.incarnation, + (pendingPasteWrites.current.get(pasteOwner.incarnation) ?? 0) + 1, + ); + try { + const result = await pasteComposerClipboard({ + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, target), + }); + if (!mayCommitPastedTextAttachment(threadKey, draftIncarnation)) { + if (result.text || result.images.length > 0) + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the clipboard content.", + ); + return; + } + if (committedPasteOwner.current !== pasteOwner) { + if (result.text) + insertComposerDraftTextIfIncarnation(threadKey, draftIncarnation, result.text, target); + if (result.images.length > 0) + appendComposerDraftAttachments(threadKey, result.images, { + appendReference: true, + insertion: target, + }); + return; + } + const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { + appendReference: true, + insertion: target, + }); + if (result.text) { + const draft = getComposerDraftSnapshot(threadKey); + const connectedLease = connectedPastedTextAttachmentLease( + selectedThreadShell.environmentId, + ); + const capabilities = connectedLease?.config.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true && capabilities.pastedTextAttachments === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + draft.text.length - + (draft.text === target.text ? Math.max(0, target.end - target.start) : 0) + + result.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const shouldFold = + supportsNativePastedTextAttachments() && + maxBytes !== null && + pastedTextDisposition({ + text: result.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + const canAttach = + maxBytes !== null && + shouldFold && + countComposerDraftAttachmentsAfterSelection(threadKey, target) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(result.text).byteLength <= maxBytes; + if (canAttach && maxBytes !== null) { + if (pastedTextNames.current.owner !== pasteOwner.incarnation) + pastedTextNames.current = { owner: pasteOwner.incarnation, names: new Set() }; + const reserved = pastedTextNames.current.names; + for (const attachment of draft.attachments) reserved.add(attachment.name); + const name = nextPastedTextFileName([...reserved]); + reserved.add(name); + try { + const attachment = await createPastedTextComposerAttachment({ + text: result.text, + name, + maxBytes, + }); + if ( + committedPasteOwner.current !== pasteOwner || + !mayCommitPastedTextAttachment(threadKey, draftIncarnation) || + connectedPastedTextAttachmentLease(selectedThreadShell.environmentId)?.state !== + connectedLease?.state + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + if ( + insertComposerDraftTextIfIncarnation( + threadKey, + draftIncarnation, + result.text, + target, + ) && + wouldExceedInputLimit + ) + setPendingConnectionError( + "Pasted text is too large to send. Reconnect before sending.", + ); + return; + } + if ( + appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion: target, + }) > 0 + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + if (!wouldExceedInputLimit) + insertComposerDraftTextIfIncarnation( + threadKey, + draftIncarnation, + result.text, + target, + ); + else setPendingConnectionError("Pasted text is too large for this message."); + } + } catch (error) { + if ( + insertComposerDraftTextIfIncarnation( + threadKey, + draftIncarnation, + result.text, + target, + ) && + wouldExceedInputLimit + ) + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + } else if (!wouldExceedInputLimit) insertComposerDraftText(threadKey, result.text, target); + else + setPendingConnectionError( + "Pasted text is too large for this message. Remove text or an attachment, then paste again.", + ); + } + if (result.error) { + setPendingConnectionError(result.error); + } else if (rejectedPasteCount > 0) { + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } finally { + const remaining = pendingPasteWrites.current.get(pasteOwner.incarnation); + if (remaining !== undefined) + pendingPasteWrites.current.set(pasteOwner.incarnation, Math.max(0, remaining - 1)); + } + }, [pasteOwner, selectedThreadServerConfig, selectedThreadShell]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray) => { @@ -1345,12 +1572,14 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const draftIncarnation = pasteOwnerDraftIncarnations.current.get(pasteOwner) ?? null; + if (!mayCommitPastedTextAttachment(threadKey, draftIncarnation)) return; try { const images = await convertPastedImagesToAttachments({ uris, existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, }); - if (images.length > 0) { + if (images.length > 0 && mayCommitPastedTextAttachment(threadKey, draftIncarnation)) { appendComposerDraftAttachments(threadKey, images, { appendReference: true }); } } catch (error) { @@ -1362,7 +1591,134 @@ export function useThreadComposerState() { }); } }, - [composerDrafts, selectedThreadShell], + [composerDrafts, pasteOwner, selectedThreadShell], + ); + + const onNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const threadKey = pasteOwner.key; + if (!threadKey || !selectedThreadShell) return; + const target = { text: paste.value, ...paste.selection }; + const draftIncarnation = pasteOwnerDraftIncarnations.current.get(pasteOwner) ?? null; + if (!mayCommitPastedTextAttachment(threadKey, draftIncarnation)) { + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the text to the current draft.", + ); + return; + } + const preserveCapturedPaste = () => { + if (!insertComposerDraftTextIfIncarnation(threadKey, draftIncarnation, paste.text, target)) + Alert.alert( + "Paste was not added", + "The original draft was discarded. Paste again to add the text to the current draft.", + ); + }; + if (committedPasteOwner.current !== pasteOwner) { + preserveCapturedPaste(); + return; + } + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const connectedLease = connectedPastedTextAttachmentLease(selectedThreadShell.environmentId); + const capabilities = connectedLease?.config.environment.capabilities; + if (capabilities?.attachmentUploads !== true) { + if (!wouldExceedInputLimit) insertComposerDraftText(threadKey, paste.text, target); + else + setPendingConnectionError( + "Pasted text is too large for this message. Remove text or an attachment, then paste again.", + ); + return; + } + const advertisedMax = capabilities.fileAttachments?.maxUploadBytes; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const shouldFold = + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + if (!shouldFold || (maxBytes === null && !wouldExceedInputLimit)) { + insertComposerDraftText(threadKey, paste.text, target); + return; + } + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(threadKey, target) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if (!canAttach || maxBytes === null) { + if (!wouldExceedInputLimit) insertComposerDraftText(threadKey, paste.text, target); + else + setPendingConnectionError( + "Pasted text is too large for this message. Remove text or an attachment, then paste again.", + ); + return; + } + const currentNames = getComposerDraftSnapshot(threadKey).attachments.map( + (attachment) => attachment.name, + ); + if (pastedTextNames.current.owner !== pasteOwner.incarnation) + pastedTextNames.current = { owner: pasteOwner.incarnation, names: new Set() }; + const reserved = pastedTextNames.current.names; + for (const name of currentNames) reserved.add(name); + const name = nextPastedTextFileName([...reserved]); + reserved.add(name); + pendingPasteWrites.current.set( + pasteOwner.incarnation, + (pendingPasteWrites.current.get(pasteOwner.incarnation) ?? 0) + 1, + ); + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if ( + committedPasteOwner.current !== pasteOwner || + !mayCommitPastedTextAttachment(threadKey, draftIncarnation) || + connectedPastedTextAttachmentLease(selectedThreadShell.environmentId)?.state !== + connectedLease?.state + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + preserveCapturedPaste(); + if (wouldExceedInputLimit) + setPendingConnectionError( + "Pasted text is too large to send. Reconnect before sending.", + ); + return; + } + if ( + appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion: target, + }) > 0 + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + if (!wouldExceedInputLimit) + insertComposerDraftTextIfIncarnation(threadKey, draftIncarnation, paste.text, target); + else + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + preserveCapturedPaste(); + if (wouldExceedInputLimit) + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } finally { + const remaining = pendingPasteWrites.current.get(pasteOwner.incarnation); + if (remaining !== undefined) + pendingPasteWrites.current.set(pasteOwner.incarnation, Math.max(0, remaining - 1)); + } + }, + [pasteOwner, selectedThreadServerConfig], ); const onRemoveDraftImage = useCallback( @@ -1716,6 +2072,7 @@ export function useThreadComposerState() { onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, + onNativePasteText, onRemoveDraftImage, onSendMessage, onQueueFollowUp, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f9e245de57..3ef8359437 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -30,7 +30,11 @@ import { import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; -import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; +import { + prepareTurnAttachments, + preparedPastedTextLeaseCurrent, + type PreparedTurnAttachments, +} from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; import { retainAcknowledgedThreadMessage, @@ -793,6 +797,10 @@ export function useThreadOutboxDrain(): void { }, ); + if (!preparedPastedTextLeaseCurrent(queuedMessage.environmentId, prepared)) { + await prepared.releaseUploads(); + return "retry"; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -905,6 +913,10 @@ export function useThreadOutboxDrain(): void { }, ); + if (!preparedPastedTextLeaseCurrent(queuedMessage.environmentId, prepared)) { + await prepared.releaseUploads(); + return "retry"; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({