Skip to content

Commit df8744e

Browse files
Merge remote-tracking branch 'origin/main' into t3/mobile-terminal-copy-paste
2 parents fea7518 + 97c6773 commit df8744e

19 files changed

Lines changed: 1211 additions & 32 deletions

‎apps/mobile/app.config.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,12 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
195195
displayName: "Agent Activity",
196196
description: "Shows the current state of active T3 Code agents.",
197197
android: null,
198-
ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
198+
ios: {
199+
supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
200+
// Embeds the layout so the gallery preview and a new widget render
201+
// before the app has run once; the app replaces it on publish.
202+
initialLayout: "./src/widgets/AgentActivityWidget.tsx",
203+
},
199204
},
200205
],
201206
},
@@ -453,6 +458,8 @@ const config: ExpoConfig = {
453458
"./plugins/withAndroidModernPopupMenu.cjs",
454459
"./plugins/withAndroidModernAlertDialog.cjs",
455460
"./plugins/withAndroidPredictiveBackCompat.cjs",
461+
// MainActivity mods run in reverse order; extend the tablet-orientation override.
462+
"./plugins/withAndroidWindowDisplayMetrics.cjs",
456463
"./plugins/withAndroidTabletOrientation.cjs",
457464
...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
458465
],

‎apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt‎

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ import android.view.MotionEvent
2525
import android.view.KeyEvent
2626
import android.view.View
2727
import android.view.ViewGroup
28+
import android.view.inputmethod.BaseInputConnection
2829
import android.view.inputmethod.EditorInfo
2930
import android.view.inputmethod.InputConnection
3031
import android.view.inputmethod.InputConnectionWrapper
3132
import android.view.inputmethod.InputMethodManager
33+
import android.view.inputmethod.TextAttribute
3234
import android.widget.EditText
35+
import androidx.annotation.RequiresApi
3336
import com.facebook.react.common.assets.ReactFontManager
3437
import expo.modules.kotlin.AppContext
3538
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -131,8 +134,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
131134
val token =
132135
tokens.firstOrNull {
133136
(it.type == "context" || it.type == "mention" || it.type == "skill") &&
134-
offset >= it.start &&
135-
offset < it.end
137+
offset in it.start..it.end &&
138+
it.end <= editor.length() &&
139+
chipContains(it, event.x, event.y)
136140
}
137141
?: return false
138142
if (token.end <= editor.length() &&
@@ -450,6 +454,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
450454
)
451455
}
452456

457+
// A chip is one replacement span, so a tap on its right half reports the offset at its end,
458+
// and a tap below the last line clamps to it; only the chip's drawn bounds count.
459+
private fun chipContains(token: ComposerToken, viewX: Float, viewY: Float): Boolean {
460+
val layout = editor.layout ?: return false
461+
val x = viewX - editor.totalPaddingLeft + editor.scrollX
462+
val y = viewY - editor.totalPaddingTop + editor.scrollY
463+
val line = layout.getLineForOffset(token.start)
464+
val left = layout.getPrimaryHorizontal(token.start)
465+
val right =
466+
if (layout.getLineForOffset(token.end) == line) {
467+
layout.getPrimaryHorizontal(token.end)
468+
} else {
469+
layout.getLineRight(line)
470+
}
471+
return y >= layout.getLineTop(line) && y <= layout.getLineBottom(line) &&
472+
x >= minOf(left, right) && x <= maxOf(left, right)
473+
}
474+
453475
private fun applyTokenSpans() {
454476
val editable = editor.text ?: return
455477
editable.getSpans(
@@ -680,8 +702,57 @@ internal class SelectionAwareEditText(
680702
afterLength: Int
681703
): Boolean = deleteAdjacentChip(beforeLength, afterLength) ||
682704
super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
705+
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean =
706+
commitUnlessPasted(text) { super.commitText(text, newCursorPosition) }
707+
708+
// InputConnectionWrapper sends this overload straight to its target, not through the
709+
// two-argument commitText.
710+
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
711+
override fun commitText(
712+
text: CharSequence,
713+
newCursorPosition: Int,
714+
textAttribute: TextAttribute?
715+
): Boolean = commitUnlessPasted(text) {
716+
super.commitText(text, newCursorPosition, textAttribute)
717+
}
718+
719+
private fun commitUnlessPasted(text: CharSequence?, commit: () -> Boolean): Boolean =
720+
if (interceptKeyboardPaste(text)) {
721+
// The keyboard now treats its composing text as replaced; a span left behind would
722+
// let its next word overwrite text the paste did not remove.
723+
finishComposingText()
724+
true
725+
} else {
726+
commit()
727+
}
728+
}
729+
}
730+
731+
// IME clipboard inserts replace the composing range, just like native commitText.
732+
private fun interceptKeyboardPaste(text: CharSequence?): Boolean {
733+
if (readOnly || textPasteThresholdBytes <= 0 || text == null) return false
734+
val value = text.toString()
735+
val composingStart = BaseInputConnection.getComposingSpanStart(editableText)
736+
val composingEnd = BaseInputConnection.getComposingSpanEnd(editableText)
737+
return when {
738+
Character.codePointCount(text, 0, text.length) < 2 -> false
739+
composingStart < 0 -> interceptPaste(value)
740+
else -> {
741+
val continuesWord = value.startsWith(editableText.subSequence(composingStart, composingEnd))
742+
val exceedsInputLimit =
743+
length().toLong() - (composingEnd - composingStart) + value.length > maxInputChars
744+
val keepTyping = if (continuesWord && exceedsInputLimit && !shouldFoldText(value)) {
745+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
746+
// commitText has no paste flag; inspect the clipboard only for ambiguous overflow commits.
747+
clipboard?.primaryClip?.plainText() != value
748+
} else {
749+
false
750+
}
751+
!keepTyping && interceptPaste(value, start = composingStart, end = composingEnd)
752+
}
683753
}
684754
}
755+
685756
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
686757
super.onSelectionChanged(selStart, selEnd)
687758
selectionListener?.invoke(selStart, selEnd)
@@ -793,13 +864,17 @@ internal class SelectionAwareEditText(
793864
private fun pasteInterceptedText(clip: ClipData?, foldLargeText: Boolean = true): Boolean {
794865
val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null
795866
if (text.isNullOrEmpty()) return false
796-
val start = minOf(selectionStart, selectionEnd).coerceIn(0, length())
797-
val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
867+
return interceptPaste(text, foldLargeText)
868+
}
869+
870+
private fun interceptPaste(
871+
text: String,
872+
foldLargeText: Boolean = true,
873+
start: Int = minOf(selectionStart, selectionEnd).coerceIn(0, length()),
874+
end: Int = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
875+
): Boolean {
798876
val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars
799-
val shouldFold = foldLargeText && (
800-
text.length >= textPasteThresholdBytes ||
801-
text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes
802-
)
877+
val shouldFold = foldLargeText && shouldFoldText(text)
803878
val shouldIntercept = exceedsInputLimit || shouldFold
804879
if (shouldIntercept) {
805880
pasteTextListener?.invoke(text, start, end)
@@ -808,6 +883,10 @@ internal class SelectionAwareEditText(
808883
return shouldIntercept
809884
}
810885

886+
private fun shouldFoldText(text: String): Boolean =
887+
text.length >= textPasteThresholdBytes ||
888+
text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes
889+
811890
// Match native paste's newlines, including empty items.
812891
// URI-backed clips stay native: coerceToText can read a provider synchronously.
813892
private fun ClipData.plainText(): String? {

‎apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import android.net.Uri
1313
import android.view.DragEvent
1414
import android.view.View
1515
import android.view.KeyEvent
16+
import android.view.inputmethod.BaseInputConnection
17+
import android.view.inputmethod.EditorInfo
1618
import org.junit.Assert.assertEquals
1719
import org.junit.Assert.assertFalse
1820
import org.junit.Assert.assertNull
@@ -103,6 +105,88 @@ class ComposerPasteTest {
103105
assertEquals("old", editor.text.toString())
104106
}
105107

108+
@Test
109+
fun keyboardClipboardInsertFoldsAtTheUtf8Threshold() {
110+
val pasted = "é".repeat(16 * 1024)
111+
editor.setText("before old after")
112+
editor.setSelection(10, 7)
113+
var intercepted: Triple<String, Int, Int>? = null
114+
editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) }
115+
val connection = editor.onCreateInputConnection(EditorInfo())!!
116+
117+
assertTrue(connection.commitText(pasted, 1))
118+
assertEquals(Triple(pasted, 7, 10), intercepted)
119+
assertEquals("before old after", editor.text.toString())
120+
121+
intercepted = null
122+
assertTrue(connection.commitText(pasted, 1, null))
123+
assertEquals(Triple(pasted, 7, 10), intercepted)
124+
assertEquals("before old after", editor.text.toString())
125+
}
126+
127+
@Test
128+
fun keyboardClipboardInsertWhileComposingReplacesTheComposingWord() {
129+
val pasted = "wor" + "é".repeat(16 * 1024)
130+
editor.setText("before ")
131+
editor.setSelection(editor.length())
132+
var intercepted: Triple<String, Int, Int>? = null
133+
editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) }
134+
val connection = editor.onCreateInputConnection(EditorInfo())!!
135+
136+
for (withTextAttribute in listOf(false, true)) {
137+
editor.setText("before ")
138+
editor.setSelection(editor.length())
139+
intercepted = null
140+
assertTrue(connection.setComposingText("wor", 1))
141+
val committed = if (withTextAttribute) {
142+
connection.commitText(pasted, 1, null)
143+
} else {
144+
connection.commitText(pasted, 1)
145+
}
146+
assertTrue(committed)
147+
assertEquals(Triple(pasted, 7, 10), intercepted)
148+
assertEquals("before wor", editor.text.toString())
149+
assertEquals(-1, BaseInputConnection.getComposingSpanStart(editor.text))
150+
}
151+
}
152+
153+
@Test
154+
fun keyboardClipboardPrefixInterceptsInputLimitOverflow() {
155+
val pasted = "word from clipboard"
156+
clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted))
157+
editor.setText("x".repeat(119_997))
158+
editor.setSelection(editor.length())
159+
var intercepted: Triple<String, Int, Int>? = null
160+
editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) }
161+
val connection = editor.onCreateInputConnection(EditorInfo())!!
162+
163+
assertTrue(connection.setComposingText("wor", 1))
164+
assertTrue(connection.commitText(pasted, 1))
165+
assertEquals(Triple(pasted, 119_997, 120_000), intercepted)
166+
assertEquals(120_000, editor.length())
167+
assertEquals(-1, BaseInputConnection.getComposingSpanStart(editor.text))
168+
}
169+
170+
@Test
171+
fun keyboardCommitsAtTheInputLimitInterceptOnlyClipboardInserts() {
172+
clipboard.setPrimaryClip(ClipData.newPlainText("test", "unrelated clipboard"))
173+
val intercepted = mutableListOf<String>()
174+
editor.pasteTextListener = { text, _, _ -> intercepted.add(text) }
175+
editor.setText("x".repeat(120_000))
176+
editor.setSelection(editor.length())
177+
val connection = editor.onCreateInputConnection(EditorInfo())!!
178+
179+
assertTrue(connection.commitText("hello", 1))
180+
assertEquals(listOf("hello"), intercepted)
181+
assertEquals(120_000, editor.length())
182+
183+
assertTrue(connection.commitText("a", 1))
184+
assertTrue(connection.setComposingText("wor", 1))
185+
assertTrue(connection.commitText("word", 1))
186+
assertEquals(listOf("hello"), intercepted)
187+
assertEquals("x".repeat(120_000) + "aword", editor.text.toString())
188+
}
189+
106190
@Test
107191
fun regularPasteMeasuresAndInterceptsEveryTextItemJoinedWithNewlines() {
108192
// Only the complete UTF-8 payload, including both newlines, reaches 32 KiB.

‎apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,26 @@ private final class ComposerTextView: UITextView {
196196
return super.canPerformAction(action, withSender: sender)
197197
}
198198

199+
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
200+
guard isEditable, !isReadOnly else { return false }
201+
// UIKit checks canPaste before forwarding a drag to UITextDropDelegate.
202+
if !itemProviders.isEmpty,
203+
itemProviders.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) {
204+
return true
205+
}
206+
return super.canPaste(itemProviders)
207+
}
208+
209+
override func paste(itemProviders: [NSItemProvider]) {
210+
guard isEditable, !isReadOnly else { return }
211+
if !itemProviders.isEmpty,
212+
itemProviders.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) {
213+
loadImages(from: itemProviders)
214+
return
215+
}
216+
super.paste(itemProviders: itemProviders)
217+
}
218+
199219
override func paste(_ sender: Any?) {
200220
guard !isReadOnly else {
201221
return

‎apps/mobile/modules/t3-composer-editor/tests/ios/main.swift‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import UniformTypeIdentifiers
12
import UIKit
23

34
func check(_ passed: Bool, _ message: String) {
@@ -108,3 +109,87 @@ do {
108109
}
109110

110111
print("composer editor tests passed")
112+
113+
private let dropEditor = ComposerTextView()
114+
private let image = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)).image { context in
115+
UIColor.red.setFill()
116+
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
117+
}
118+
private let imageProvider = NSItemProvider(object: image)
119+
private let pngProvider = NSItemProvider(item: image.pngData()! as NSData, typeIdentifier: UTType.png.identifier)
120+
private let textProvider = NSItemProvider(object: "plain text" as NSString)
121+
private let fileProvider = NSItemProvider(item: Data("file".utf8) as NSData, typeIdentifier: UTType.data.identifier)
122+
123+
check(dropEditor.canPaste([imageProvider]), "An image drag must reach the composer's UITextDropDelegate")
124+
check(dropEditor.canPaste([pngProvider]), "A PNG representation must reach the image drop handler")
125+
check(dropEditor.canPaste([imageProvider, pngProvider]), "A multi-image drag must be accepted")
126+
127+
private let defaultEditor = UITextView()
128+
for providers in [[textProvider], [], [fileProvider], [imageProvider, textProvider], [imageProvider, fileProvider]] {
129+
check(dropEditor.canPaste(providers) == defaultEditor.canPaste(providers), "Non-image-only sessions must retain UIKit behavior")
130+
}
131+
132+
dropEditor.isReadOnly = true
133+
check(!dropEditor.canPaste([imageProvider]), "A read-only composer must reject image drops")
134+
dropEditor.isReadOnly = false
135+
dropEditor.isEditable = false
136+
check(!dropEditor.canPaste([imageProvider]), "A noneditable composer must reject image drops")
137+
dropEditor.isEditable = true
138+
check(dropEditor.canPaste([imageProvider]), "An unlocked composer must accept image drops again")
139+
print("Composer image-drop eligibility checks passed")
140+
141+
private final class ImmediateImageProvider: NSItemProvider {
142+
let image: UIImage
143+
private(set) var loadCount = 0
144+
145+
init(image: UIImage) {
146+
self.image = image
147+
super.init()
148+
registerObject(image, visibility: .all)
149+
}
150+
151+
override func loadObject(
152+
ofClass aClass: NSItemProviderReading.Type,
153+
completionHandler: @escaping @Sendable (NSItemProviderReading?, Error?) -> Void
154+
) -> Progress {
155+
loadCount += 1
156+
completionHandler(image, nil)
157+
return Progress(totalUnitCount: 1)
158+
}
159+
}
160+
161+
private let pasteEditor = ComposerTextView()
162+
private let pasteProvider = ImmediateImageProvider(image: image)
163+
private var pastedImages: [[String]] = []
164+
pasteEditor.text = "Keep this draft"
165+
pasteEditor.onPasteImages = { pastedImages.append($0) }
166+
pasteEditor.paste(itemProviders: [pasteProvider])
167+
168+
for readOnly in [true, false] {
169+
let lockedEditor = ComposerTextView()
170+
let provider = ImmediateImageProvider(image: image)
171+
lockedEditor.isReadOnly = readOnly
172+
lockedEditor.isEditable = readOnly
173+
lockedEditor.paste(itemProviders: [provider])
174+
check(provider.loadCount == 0, "Locked inputs must not load pasted image providers")
175+
}
176+
177+
// Inline provider completion places the attachment callback before this main-queue check.
178+
DispatchQueue.main.async {
179+
check(pastedImages.count == 1, "An accepted provider-based paste must emit exactly one attachment batch")
180+
check(pastedImages[0].count == 1, "One pasted image must produce one attachment")
181+
guard let url = URL(string: pastedImages[0][0]),
182+
let data = try? Data(contentsOf: url),
183+
let decoded = UIImage(data: data) else {
184+
check(false, "Provider-based paste must create a readable image file")
185+
return
186+
}
187+
try? FileManager.default.removeItem(at: url)
188+
check(decoded.cgImage?.width == image.cgImage?.width &&
189+
decoded.cgImage?.height == image.cgImage?.height && data == image.pngData(),
190+
"The attachment must contain the pasted image's pixels")
191+
check(pasteEditor.text == "Keep this draft", "Image paste must preserve the draft text")
192+
print("Composer provider-paste checks passed")
193+
exit(0)
194+
}
195+
dispatchMain()

‎apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ private final class WorkspaceTabBar: UITabBar {
1111
let candidates = identified.isEmpty ? tabButtons(in: self) { _ in true } : identified
1212
// Pressing a tab adds a magnified copy of the row for the glass lens, without identifiers.
1313
// Sizing compose from it leaves compose enlarged until the bar lays out again.
14-
let buttons = candidates.filter { convert($0.bounds, from: $0).size == $0.bounds.size }
14+
// The copy is many points larger; the tolerance only absorbs conversion rounding.
15+
let buttons = candidates.filter {
16+
let size = convert($0.bounds, from: $0).size
17+
return abs(size.width - $0.bounds.width) < 1 && abs(size.height - $0.bounds.height) < 1
18+
}
1519
let row = buttons.reduce(CGRect.null) { $0.union(convert($1.bounds, from: $1)) }
1620
if !row.isNull { onContentLayout?(row) }
1721
}

0 commit comments

Comments
 (0)