diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c6ca676fc467af1ce62d48d448dbc840a149869e..c1eba805c2b69aa0a206917d59a0a213926b4421 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { openExternal, openSystemSettings, checkSystemPermission, + pasteAsText, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); + yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5489e56fea1c22be652b035a35eb56f3b5651e22..ca6bbd30b3e4bd5457fa803e4389d82396b00676 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660ea5ff2f6fd13b491c07becf4fb1a6..6fcf5e8137492aa7e1a6004a5fe69ab1435b46e4 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -6,6 +6,15 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ + focusedWebContents: vi.fn(), + ownerWindow: vi.fn(), +})); +vi.mock("electron", () => ({ + webContents: { getFocusedWebContents: focusedWebContents }, + BrowserWindow: { fromWebContents: ownerWindow }, +})); + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, } from "./window.ts"; @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect( + "pastes into the focused guest only after the main renderer acknowledges the menu action", + () => { + const paste = vi.fn(); + const mainPaste = vi.fn(); + const window = { + webContents: { id: 42, paste: mainPaste }, + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); + ownerWindow.mockReturnValue(window); + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + assert.equal(mainPaste.mock.calls.length, 0); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(null); // Detached contents. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(window); + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + focusedWebContents.mockReturnValue(null); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + }), + ), + ); + }, + ); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 5e7c41514a674b29630b4fb4db7e0adb79381d9e..284b62ad31ac9c4f896df3257ac61c2bf8ad617c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const pasteAsText = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + const focused = Electron.webContents.getFocusedWebContents(); + if ( + focused && + !focused.isDestroyed() && + Electron.BrowserWindow.fromWebContents(focused) === window.value + ) { + focused.paste(); + } + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 63041db98c28030f51e5bb0e0677add92ffd88af..d4edb7818180dd9d2f910a5a3c9ce3b04cc91247 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index eeb0c86f031cd5ae1eac1019219e1ef14b17f58a..bf0c4c3eff6ecf08e9100bd56438e2a6b79b73d1 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("owns Paste as Text and routes it through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + assert.isDefined(pasteAsTextItem); + assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); + if (typeof pasteAsTextItem.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index bf7634981f86d3cb7ef23b0377f6326f86d74ee4..a90b9ca632317a86654eaa429cc2ef417fa25a76 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function action: string, ): Effect.fn.Return { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); + yield* desktopWindow.dispatchMenuAction(action, { + reveal: action !== "paste-as-text", + }); }); const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const pasteAsTextClick = () => { + runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); + }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { + label: "Paste as Text", + accelerator: "CmdOrCtrl+Shift+V", + click: pasteAsTextClick, + }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(environment.platform === "darwin" + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], + }, + ] + : []), + ], + }, { label: "View", submenu: [ diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 489641ec6c6e0510c9b651f3abcb77f65bfc7a14..0a3e7ed7e17b17bcd122e030b639ec0cf2ef45d4 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 729fec480068dd5d3789da95a6b4d0c4b0281d7e..1703a767020249692046e478ea51b8f9ef109775 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 @@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() { Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int -> + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int -> + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -125,6 +131,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 e409c791a5e5658bd96667613c92e0147317bde1..a4720a461327c12d6618b5c563071422e4d86780 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 @@ -48,6 +49,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 @@ -86,7 +88,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, @@ -120,6 +131,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()) @@ -305,6 +327,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 @@ -365,10 +395,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) { @@ -379,7 +412,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, ), ) @@ -544,11 +577,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 = "" private fun deleteChip(backwards: Boolean): Boolean { @@ -602,7 +638,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) @@ -615,6 +650,10 @@ 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) + id == android.R.id.pasteAsPlainText -> { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + pasteInterceptedText(clipboard?.primaryClip, foldLargeText = false) + } pasting -> pasteContextOrImages() else -> false } @@ -636,6 +675,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 { @@ -648,7 +691,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?, foldLargeText: Boolean = true): 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 = foldLargeText && ( + 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 0000000000000000000000000000000000000000..9838c6314680b5bd9c068b23550b3b67a280ab43 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -0,0 +1,123 @@ +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 shortcutInterceptsInputLimitOverflowWithoutChangingTheSelection() { + 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()) + assertEquals(Triple("hello", 7, 10), intercepted) + assertEquals(119_999, editor.length()) + assertEquals(10, editor.selectionStart) + assertEquals(7, editor.selectionEnd) + } + + @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 4f0ead66e5c7f41fb6e0ea3e9dc4147d50d3cb6e..523f0d61e0b6750ea4b4e3b6efd0581d429328bf 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -87,6 +87,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 +103,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 11c62f36f226490f2c060d0f4df541637178a017..6258c81c8f971ac5f80c3106ed444f7764e0d39b 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -86,10 +86,14 @@ 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 + private var bypassTextPasteInterception = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -101,6 +105,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 +122,15 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func pasteInline(_ sender: UIKeyCommand) { + guard !isReadOnly else { + return + } + bypassTextPasteInterception = true + defer { bypassTextPasteInterception = false } + paste(sender) + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { return } } + if !bypassTextPasteInterception, + 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 7430e28a633e64524aeccf9a4afe0783695dcebb..533c13108865432523317f6541783ca7e36ff17c 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 @@ using namespace facebook::react; @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 caf8f676f07b89afdb46ac798958fe1e7523bdaa..04382839c909e99fa66dfe7b5f1599164f4856ba 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/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 4a5cb017c236530da9f96f451508eff6e8e8d7b1..725b3b84389b5153bca4f1094066301601d2e422 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -9,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native import { appendComposerDraftAttachments, createComposerDraftContextHistory, + getComposerDraftAfterSelection, getComposerDraftSnapshot, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftContext, setComposerContextImporting, useComposerDraft, } from "../state/use-composer-drafts"; -import { - importComposerContextClipboard, - type NativeContextClipboard, -} from "../lib/composerContextClipboard"; +import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; import { @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { readonly onOpenMention?: (path: string) => void; /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; + /** + * A resting composer is a target to type in, not a document to navigate. Its chips go inert + * so a draft full of them can still be tapped anywhere to start writing; the caller focuses + * the editor instead. Chips become live again once the composer is open. + */ + readonly chipsInert?: boolean; + /** Called instead of opening a chip while `chipsInert` is set. */ + readonly onInertChipPress?: () => void; }; export function ComposerEditor({ @@ -41,6 +48,8 @@ export function ComposerEditor({ environmentId, onOpenMention, onOpenAttachment, + chipsInert, + onInertChipPress, ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); @@ -68,40 +77,35 @@ export function ComposerEditor({ }, [draftKey], ); - const pasteContext = async (clipboard: NativeContextClipboard) => { + const pasteContext = async ( + clipboard: Parameters>[0], + ) => { if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + const insertion = { text: clipboard.value, ...clipboard.selection }; const controller = new AbortController(); importRef.current = controller; setImporting(true); setComposerContextImporting(draftKey, true); try { + const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, - getComposerDraftSnapshot(draftKey).attachments.length, + retained.attachments.length, controller.signal, - getComposerDraftSnapshot(draftKey).context?.records.length ?? 0, + retained.context?.records.length ?? 0, ); if (!result) { - insertComposerDraftContext(draftKey, { - text: clipboard.text, - context: { version: 1, records: [] }, - }); + insertComposerDraftText(draftKey, clipboard.text, insertion); return; } - const rejected = appendComposerDraftAttachments(draftKey, result.attachments); - const ids = new Set( - getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), - ); - insertComposerDraftContext(draftKey, { - text: result.text, - context: { - version: 1, - records: result.context.records.filter( - (record) => !("attachmentId" in record) || ids.has(record.attachmentId), - ), - }, - }); - if (result.failures.length > 0 || rejected > 0) + if (!insertComposerDraftContext(draftKey, result, insertion)) { + Alert.alert( + "Could not paste context", + "Remove some attachments or context items from the draft, then paste again.", + ); + return; + } + if (result.failures.length > 0) Alert.alert( "Some attachments could not be copied", "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", @@ -161,6 +165,10 @@ export function ComposerEditor({ onPasteContext={(clipboard) => void pasteContext(clipboard)} context={draft.context} onContextPress={(selection) => { + if (chipsInert) { + onInertChipPress?.(); + return; + } const path = composerMentionPath(selection.source, draft.context); if (path && onOpenMention) { onOpenMention(path); @@ -174,7 +182,12 @@ export function ComposerEditor({ setSelected(selection); }} onSelectionChange={(selection) => { - if (draftKey) rememberComposerDraftSelection(draftKey, props.value, selection); + if (draftKey) + rememberComposerDraftSelection( + draftKey, + getComposerDraftSnapshot(draftKey).text, + selection, + ); props.onSelectionChange?.(selection); }} /> @@ -229,4 +242,8 @@ export function ComposerEditor({ ); } -export type { ComposerEditorHandle, ComposerEditorSelection } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorSelection, + ComposerTextPaste, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 46365630b4d2c2d00641f265c076dc6da7bac424..a1870c00462a9604f48e52e6bb04948b085676a2 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 2eabce998e8ebe8b970342cdeb000bd0a4bad8f7..82d69b28e841ba7c3c5b8d6959c8b13944c6afc1 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,6 +157,22 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat return null; } +function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullToRefresh = useCallback(async () => { + if (!onRefresh) { + return; + } + setIsPullRefreshing(true); + try { + await onRefresh(); + } finally { + setIsPullRefreshing(false); + } + }, [onRefresh]); + return { isPullRefreshing, handlePullToRefresh }; +} + function NativeSourceFileSurface( props: SourceFileSurfaceProps & { readonly NativeView: ComponentType; @@ -157,18 +184,7 @@ function NativeSourceFileSurface( const appTheme = useUniwindTheme(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); - const [isPullRefreshing, setIsPullRefreshing] = useState(false); - const handlePullToRefresh = useCallback(async () => { - if (!onRefresh) { - return; - } - setIsPullRefreshing(true); - try { - await onRefresh(); - } finally { - setIsPullRefreshing(false); - } - }, [onRefresh]); + 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 0e58b26bdaf5b568c73487bb5962a0055654697c..a8a0f860cfa87ba3ff1642ebeb620df7c754e0f2 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 04b3046994ad669b37866c66c1f30cfd726d37ff..a490a77da9dcf6b1ff541dac464aac008c2f77eb 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 d78ead3288fc19e48d2419e6fc922e0e7211a1c5..12f1fce099cf92b5423e67a3bad1b636ef34c9b0 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 8b03a2983bfad205744e99413403344d0d1182ae..71403de9954b629868e25ee702975faa8e6323de 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,4 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { + nextPastedTextFileName, + pastedTextDisposition, + replaceTextSelection, +} from "@t3tools/client-runtime/text-paste"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { CommonActions, @@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, resolveEnvironmentMachineKind, type EnvironmentId, } from "@t3tools/contracts"; -import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerTextPaste, +} from "../../components/ComposerEditor"; import { composerContextImportsAtom } from "../../state/use-composer-drafts"; import { composerContextSendBlockReason, @@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { composerStripAttachments } from "../../lib/composerImages"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, @@ -73,13 +83,17 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, @@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: { const shareImportDraftBackupRef = useRef(new Map()); const activeShareImportTokenRef = useRef(null); const shareImportMountedRef = useRef(true); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); + const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set }>({ + draftKey: null, + names: new Set(), + }); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; @@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: { : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; // Media needs its thumbnail; every other file already reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - flow.attachments, - new Set( - collectComposerContextReferences(flow.prompt).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [flow.attachments, flow.prompt], + () => composerStripAttachments(flow.attachments), + [flow.attachments], ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, @@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: { return; } const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerMedia({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes : undefined, }); const rejectedCount = - result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0; + result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0; const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 @@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: { Alert.alert("File attachments are not available on this server."); return; } + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerFiles({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxBytes, }); - const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; + const rejectedCount = + result.files.length > 0 ? flow.appendAttachments(result.files, insertion) : 0; // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. const problems = [ @@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: { const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { try { + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const images = await convertPastedImagesToAttachments({ uris, - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, }); if (images.length > 0) { - flow.appendAttachments(images); + flow.appendAttachments(images, insertion); } } catch (error) { console.error("[native paste] error converting images", error); @@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const draftKey = flow.draftKey; + if (!draftKey) return; + const insertion = { text: paste.value, ...paste.selection }; + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + flow.setPrompt(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(draftKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach && maxBytes !== null) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + try { + if (pastedTextFileNamesRef.current.draftKey !== draftKey) { + pastedTextFileNamesRef.current = { draftKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const attachment of flow.attachments) reservedNames.add(attachment.name); + const name = nextPastedTextFileName([...reservedNames]); + reservedNames.add(name); + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if (latestDraftKeyRef.current !== draftKey) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + return; + } + if (flow.appendAttachments([attachment], insertion) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + Alert.alert( + "Could not attach pasted text", + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + Alert.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } finally { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + } + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + + insertPaste(); + }, + [composerMenu, flow, selectedEnvironmentServerConfig], + ); + async function handleStart(): Promise { - if (voiceInput.blocksSubmission) return; + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey) { @@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + pendingPastedTextAttachmentCount === 0 && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { @@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onPasteText={(paste) => void handleNativePasteText(paste)} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: { 0 + ? "Attaching pasted text" + : flow.submitting + ? "Starting task" + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task") } disabled={!canStart} icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e195510074e9b03133c299bd2a7e5fea5c7c5a8..680c9cf0babedfcf483f7b417587065535487151 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,14 +1,19 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - MessageId, - ModelSelection, - OrchestrationThreadShell, - ProviderInteractionMode, - RuntimeMode, - ServerConfig as T3ServerConfig, - UsageLimitsReport, +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type EnvironmentId, + type MessageId, + type ModelSelection, + type OrchestrationThreadShell, + type ProviderInteractionMode, + type RuntimeMode, + type ServerConfig as T3ServerConfig, + type UsageLimitsReport, } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -46,7 +51,10 @@ import Animated, { import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { composerContextImportsAtom } from "../../state/use-composer-drafts"; +import { + composerContextImportsAtom, + countComposerDraftAttachmentsAfterSelection, +} from "../../state/use-composer-drafts"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject } from "../../state/entities"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -72,7 +80,6 @@ import { type DraftComposerAttachment, type DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { buildModelOptions, groupByProvider, @@ -135,6 +142,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 onSendMessage: () => Promise; @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewFile, setPreviewFile] = useState(null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Attachment context ids are the attachment id, so the prompt alone says which attachments - // already read as an inline chip and need no strip tile. + // Only media belongs above the composer; every other file reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - props.draftAttachments, - new Set( - collectComposerContextReferences(props.draftMessage).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [props.draftAttachments, props.draftMessage], + () => composerStripAttachments(props.draftAttachments), + [props.draftAttachments], ); const showStopAction = !hasContent && @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer states: uploadStates, }); const contextImports = useAtomValue(composerContextImportsAtom); - const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const sendBlockedReason = + props.sendBlockedReason ?? + (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? + attachmentBlockReason; const canSend = hasContent && !contextImports[composerOwnerKey] && @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (openUsageLimits()) onChangeDraftMessage(""); return; } - if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); }} onOpenAttachment={openDraftDocument} + // A rested composer full of chips left almost nowhere to tap to start typing: + // every chip opened its file instead. Collapsed, they focus the editor. + chipsInert={!isExpanded} + onInertChipPress={() => inputRef.current?.focus()} ref={inputRef} multiline value={props.draftMessage} @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} + onPasteText={(paste) => { + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + props.onChangeDraftMessage(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = props.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined + ? null + : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(composerOwnerKey, { + text: paste.value, + ...paste.selection, + }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + const finishAttachment = () => { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + }; + void props.onNativePasteText(paste).then(finishAttachment, finishAttachment); + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + insertPaste(); + }} placeholder={props.placeholder} onFocus={handleFocus} onBlur={handleBlur} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 55cd9e9a6c83cc4aad237c0d888f7db9dee064cd..2f4ee67a187e01228cc6c0d51690343d65a5882f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { appendCodexArtifactTemplateUsePrompt, @@ -155,6 +156,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 onSendMessage: () => Promise; @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onPickDraftMedia={props.onPickDraftMedia} onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages} + onNativePasteText={props.onNativePasteText} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={handleSendMessage} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index bb78e189bf43afdbcfca9a93de9b43a2171c7e77..f22540e768d90079c4e9959068158ed8fcc7e401 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -891,6 +891,7 @@ function ThreadRouteContent( onPickDraftMedia={composer.onPickDraftMedia} onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} + onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f6ad273d903766a848977c615d664fe99b439e1d..5c74e52cfac123fcfd324f23bd8715a565d8fe3d 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -44,6 +44,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, + type ComposerDraftInsertion, clearComposerDraft, composerDraftsAtom, createNewTaskDraft, @@ -203,7 +204,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; @@ -600,12 +604,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 417a66138d951ea6d082f49df4ee3b839ced7f06..1986a3266fa965a1bd3a408b5f002b48e2227c22 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 b7b8397cc6ec21885ad95d8ee54068bc66f3c959..b371523a89c203778d3c6ca8b6f9d3c2375f64ac 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 0000000000000000000000000000000000000000..9f63567cfcd29137ae7e86126e900253fbfbc73f --- /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 0000000000000000000000000000000000000000..0d8136262ae9408e59aa8ed420e57baf74aa0d2c --- /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 ea56d3f508081c695b733d0bbee656028a55d9ef..2c6b27864432ea596f38ab94ffe0d4991c90ab38 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -360,7 +360,14 @@ describe("prepareTurnAttachments", () => { }); it("uploads generic file bytes directly and keeps mixed attachment order", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + const pastedFile = { + ...file, + source: { _tag: "pasted-text" as const }, + }; + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [pastedFile, image], + }); expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", @@ -379,11 +386,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 815fc9826b3477fd7d89bebf8be78db5f5a59d2c..10f4fdb7c6c7784222ee6dbb8b909930c7c1a354 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -198,7 +198,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) { diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 41aced8c98e5bcb40dc959b44bd38a56d3ff596d..bf87e1e000dabbacad16002d470febfe6c6652f4 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 1eab2658828687b916bb1c373301d1e500ba3b64..3e05c578f3f328971a7f4d5cb09e8e93b2f4f9c3 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); expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); }); - it("drops a plain file once its inline chip represents it", async () => { + it("never shows a non-media file above the composer", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set(["doc-1"]))).toEqual([]); + // A document reads as its inline chip. A tile with a generic glyph says less than the + // chip does, so it is not a fallback worth having, chip present or not. + expect(composerStripAttachments([doc] as never)).toEqual([]); }); - 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).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).map((a) => a.id)).toEqual(["pick-1"]); }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 86658db8dc3bc1580c60fc805f3a6f4abc146436..391bf633e5572952b25aa24072fb461460904cc8 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,27 +42,59 @@ 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`. + * What the strip above the composer shows: media, and nothing else. A thumbnail is the only + * way to see a picture or a video, so those always preview there. Everything else reads as + * its inline chip, which carries the name, the type and the size in the line of prose the + * file belongs to — a square tile showing a generic document glyph says strictly less. + * + * The chip is not optional for a non-media file. Every path that attaches one also writes + * its reference, so a file with no chip means the draft lost it rather than that the strip + * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces + * whose attachments never get chips (a question answer) pass them to the strip directly + * instead of through this filter. */ export function composerStripAttachments( attachments: ReadonlyArray, - inlineAttachmentIds: ReadonlySet, ): ReadonlyArray { return attachments.filter( - (attachment) => - isComposerImageAttachment(attachment) || - videoMimeType(attachment) !== null || - !inlineAttachmentIds.has(attachment.id), + (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null, ); } @@ -539,11 +573,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 +647,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 a9b3111aa67a6b35f7f4d83f640119189c7138e3..eecae6660ab9b2b9c51956c109def3f030d880e8 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"; @@ -52,6 +54,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 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: () => void; @@ -108,6 +120,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, onSubmit, @@ -282,6 +295,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} + maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={style as StyleProp} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -315,7 +330,36 @@ 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; + 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); + }} + onComposerPasteText={(event) => { + 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} @@ -327,4 +371,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 9aa7e9e72814b3e51a8023e14529c04ce6d106d1..92a4e1e2c1807884229ee8e4ffe775551f600d17 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"; @@ -55,6 +57,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 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; } @@ -111,6 +123,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, contentInsetVertical = 0, @@ -291,6 +304,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + 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( @@ -325,7 +340,36 @@ 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; + 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); + }} + onComposerPasteText={(event) => { + 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} /> @@ -337,4 +381,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 07a409c9a48f9878347b1f1b524b8fa4215ae226..9ff7f41a6ebac1e1be65aec1ac6b19a8726aa177 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 dc1448e87d76c4ad8173fef2d88ba7383092045d..8985add819258327477ac98a9208186b0bf5e70e 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; @@ -18,11 +25,12 @@ export interface ComposerEditorProps { readonly value: string; readonly context?: OrchestrationMessageContext; readonly clipboardFragment?: string; - readonly onPasteContext?: (clipboard: { - readonly text: string; - readonly fragment: string; - readonly html: string; - }) => void; + readonly onPasteContext?: ( + clipboard: ComposerTextPaste & { + readonly fragment: string; + readonly html: string; + }, + ) => void; readonly skills?: ReadonlyArray< Pick & Partial> @@ -50,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 ccc2214e24c2fa3874dbd2509afc2edfc0e72a17..30f3ba23eef83209291e4e5801a986c622fd9567 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/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a97d84d256c42a2f40d0c341df736edf45af2f2e..6da72860009eae941b3b5efbf293f0632090c3ea 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -148,10 +148,14 @@ 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 { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + getComposerDraftAfterSelection, archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, @@ -181,6 +185,7 @@ import { retargetNewTaskDraft, setComposerDraftText, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { } describe("mobile composer drafts", () => { + 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) => { @@ -303,6 +383,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 = { @@ -426,41 +530,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); @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual(before); await cleanup.promise; }); + 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.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 = { @@ -564,7 +868,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, + }, + ], + }, + }, }); }); @@ -882,8 +1204,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( @@ -2314,7 +2662,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 b8ec4276b3cff93c653c2a428112fc1d13849d39..734199dcef5e5bb9039603090f92d8ee6c8eab48 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, @@ -95,6 +97,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(); @@ -162,16 +247,36 @@ 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); + 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; } @@ -179,13 +284,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)) ? " " : ""}`; @@ -200,7 +301,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()( @@ -352,6 +453,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; @@ -421,14 +584,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), @@ -1089,28 +1253,28 @@ 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, + }); +} + export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( options?: { readonly allowOverflow?: boolean; readonly appendReference?: boolean; + readonly insertion?: ComposerDraftInsertion; readonly maxAttachments?: number; }, ): number { @@ -1144,8 +1309,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( @@ -1153,10 +1322,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); @@ -1165,38 +1334,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; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index caa976beef9053d7e59ee412eb8e7f138e6a9efd..d21af7d79cc379a887636c44249aa5bfdb82d02b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,6 @@ +import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { @@ -7,6 +8,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -14,6 +16,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, - appendComposerDraftText, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + insertComposerDraftText, insertComposerDraftContext, clearComposerDraftContent, composerDraftsAtom, @@ -132,6 +140,23 @@ export function useThreadComposerState() { const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set }>({ + threadKey: null, + names: new Set(), + }); + const reservePastedTextFileName = useCallback( + (threadKey: string, existingNames: ReadonlyArray) => { + if (pastedTextFileNamesRef.current.threadKey !== threadKey) { + pastedTextFileNamesRef.current = { threadKey, names: new Set() }; + } + const names = pastedTextFileNamesRef.current.names; + for (const name of existingNames) names.add(name); + const nextName = nextPastedTextFileName([...names]); + names.add(nextName); + return nextName; + }, + [], + ); useEffect(() => { ensureComposerDraftsLoaded(); @@ -479,9 +504,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const result = await pickComposerMedia({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes @@ -489,6 +515,7 @@ export function useThreadComposerState() { }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { appendReference: true, + insertion, }); const problems = [ ...(result.error ? [result.error] : []), @@ -514,13 +541,15 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); // pickComposerFiles clamps the advertised limit to the contract maximum. const result = await pickComposerFiles({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxBytes, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { appendReference: true, + insertion, }); // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. @@ -541,14 +570,81 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const result = await pasteComposerClipboard({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { appendReference: true, + insertion, }); if (result.text) { - appendComposerDraftText(threadKey, result.text); + const currentDraft = getComposerDraftSnapshot(threadKey); + const currentAttachments = currentDraft.attachments; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + currentDraft.text.length - + (currentDraft.text === insertion.text + ? Math.max(0, insertion.end - insertion.start) + : 0) + + result.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const shouldFold = + pastedTextDisposition({ + text: result.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(threadKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(result.text).byteLength <= maxBytes; + if (shouldFold && canAttach && maxBytes !== null) { + try { + const attachment = await createPastedTextComposerAttachment({ + text: result.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes, + }); + // Same reference the pasted images above get: a folded paste is only visible + // as its chip until the message is sent. + if ( + appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }) > 0 + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + } else if (shouldFold && !wouldExceedInputLimit) { + insertComposerDraftText(threadKey, result.text, insertion); + } else if (shouldFold) { + setPendingConnectionError( + wouldExceedInputLimit + ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.", + ); + } else { + insertComposerDraftText(threadKey, result.text, insertion); + } } if (result.error) { setPendingConnectionError(result.error); @@ -557,7 +653,12 @@ export function useThreadComposerState() { `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, ); } - }, [composerDrafts, selectedThreadShell]); + }, [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray) => { @@ -566,13 +667,14 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); try { const images = await convertPastedImagesToAttachments({ uris, - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); if (images.length > 0) { - appendComposerDraftAttachments(threadKey, images, { appendReference: true }); + appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion }); } } catch (error) { console.error("[native paste] error converting images", { @@ -586,6 +688,55 @@ export function useThreadComposerState() { [composerDrafts, selectedThreadShell], ); + const onNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + if (!selectedThreadShell) return; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + if (advertisedMax === undefined) return; + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = { text: paste.value, ...paste.selection }; + const currentAttachments = getComposerDraftSnapshot(threadKey).attachments; + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes: clampFileAttachmentUploadBytes(advertisedMax), + }); + // The chip is how a folded paste stays visible: without it the attachment is in the + // draft but nothing in the composer says so until the message is sent. Web folds + // through its ordinary attach path, which always writes a reference; match that. + const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }); + if (rejectedCount > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + }, + [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ], + ); + const onRemoveDraftImage = useCallback( (imageId: string) => { if (!selectedThreadShell) { @@ -663,6 +814,7 @@ export function useThreadComposerState() { onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, + onNativePasteText, onRemoveDraftImage, onSendMessage, onUpdateModelSelection, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b68f8f219954e8939ec03ea1bbc61deec4cc65df..927d2a7d4b6f460573c82d9685ae1a9284d57f23 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + const pastedTextAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "Investigate this crash", + attachments: [pastedTextAttachment], + }); + const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); + assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects input that leaves no room for pasted-text attachment context", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + validation.codex.sendTurn.mockClear(); + + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [attachment], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 17ece3767792ebef445b77bcabf982d50248b4c6..cdac979c4dfdbf14f7323d12d82823bc18b6f03d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1593,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Every attachment gets an on-disk path in the prompt so the model's tools // can dereference the actual file. All attachments then go to the adapter, - // and each adapter decides what its provider ingests natively: OpenCode - // sends generic files as file parts, the others send images only and rely - // on the path line for everything else. Unresolvable ids are skipped here - // and surface as adapter errors when the file is read. + // and each adapter decides what its provider ingests natively. Folded + // clipboard text remains path-only everywhere: eagerly embedding it would + // spend the same context the client deliberately preserved by folding it. + // Unresolvable ids are skipped here and surface as adapter errors when the + // file is read. let inputTextWithAttachmentContext = inputTextWithCitations; const appendAttachmentContext = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputTextWithAttachmentContext ? `${inputTextWithAttachmentContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputTextWithAttachmentContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); - appendAttachmentContext( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = appendAttachmentContext( attachmentPath === null ? undefined - : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + : isPastedText + ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` + : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); + if (isPastedText && !appended) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + ); + } } for (const attachment of attachments) { const source = diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b95153aba6ae05aea3c976aff62f87f..aee10782a613490d35b840127539fc692c849cb8 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect("keeps folded clipboard text out of native context", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + yield* fixture.write(pastedText, "A very large crash report"); + + const prompt = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); + }), + ); + + it.effect("rejects missing folded clipboard text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + + const error = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'pasted-text.txt'.", + }); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 17b73e552006bd638a48cbdaa976278926c5ae5f..f2f370068181917d4303597b6566ac76b54541e1 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -264,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi let totalBytes = 0; for (const attachment of input.attachments ?? []) { + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + // ProviderService has already put the file path in the text block. Keep a + // folded clipboard paste lazy so the agent can search or sample it rather + // than paying to embed the entire resource in context immediately. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); + if (isPastedText) { + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); + } + continue; + } const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f05b8e446cef297e4ba07870778cee..c66a2f36a2073e923148b3c3fabbd3116a215e65 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 6d7187792a997d3adea9d69e715a8f2a7a6f9522..a79eff843cc7f05381f315eea6dd39526c29662e 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } if (!isOpenCodeNativeFilePart(attachment)) { continue; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c67639ac8e2d378bd9b97e058f611d823ba250ea..4e8b370d382c0a208a28dd84533eae62869bf95f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,6 +46,8 @@ import { } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { readPastedComposerContext } from "./composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. useEffect(() => { + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + } + }; const handler = (event: ClipboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) return; if (getTerminalFocusOwner() !== null) return; if (composerRef.current?.isModelPickerOpen()) return; const text = pasteTextToFocusComposer(event); - if (text === null) return; + const clipboardData = event.clipboardData; + if (text === null || clipboardData === null) return; + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; if ( - composerRef.current?.insertTextAtEnd( - text, - event.clipboardData ? { clipboardData: event.clipboardData } : undefined, - ) + ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || + composerRef.current?.insertTextAtEnd(text, { clipboardData }) ) { event.preventDefault(); event.stopPropagation(); } }; + window.addEventListener("keydown", keyHandler, true); window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; }, [activeThreadId, composerRef]); const [pendingRevert, setPendingRevert] = useState<{ @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0cf866c206968fc9b9168b4ff25e46773bc2933f..9d46174d174f045b6dde48034956c59768f54a01 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -18,6 +18,7 @@ import { import { importPastedComposerText, + readPastedComposerContext, registerComposerInlineTokenPaste, } from "./composerInlineTokenPaste"; import { @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { }); describe("context reference paste", () => { - it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - const importFragment = ( - fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, - ) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map([["img-old", "img-new"]]); - }; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: importFragment, - }); - const event = new TestClipboardEvent( - "![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)", - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: "img-old", - kind: "image", - label: "shot", - attachmentId: "a", - name: "shot.png", - mimeType: "image/png", - sizeBytes: 1, - }, - { - version: 1, - contextId: "ctx-t", - kind: "terminal", - label: "T", - terminalId: "t", - terminalLabel: "T", - lineStart: 1, - lineEnd: 1, - text: "x", - }, - { - version: 1, - contextId: "img-unrelated", - kind: "image", - label: "other", - attachmentId: "b", - name: "other.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - }, - ); - if (focus === "blurred") { - expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( - "![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)", + it.each([ + { focus: "focused", prefix: "" }, + { focus: "blurred", prefix: "" }, + { focus: "focused", prefix: "log ".repeat(10_000) }, + { focus: "blurred", prefix: "log ".repeat(10_000) }, + ])( + "imports structured paste when $focus with $prefix.length extra characters", + ({ focus, prefix }) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor({ nodes: [ComposerCitationNode] }); + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + const imported: string[] = []; + const importFragment = ( + fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, + ) => { + imported.push(...fragment.records.map((record) => record.contextId)); + return new Map([["img-old", "img-new"]]); + }; + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + createCitationNode: $createComposerCitationNode, + createContextReferenceNode: (reference) => + $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + importContextFragment: importFragment, + }); + const event = new TestClipboardEvent( + `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`, + { + "web application/x-t3-context-fragment+json": JSON.stringify({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + { + version: 1, + contextId: "img-old", + kind: "image", + label: "shot", + attachmentId: "a", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1, + }, + { + version: 1, + contextId: "ctx-t", + kind: "terminal", + label: "T", + terminalId: "t", + terminalLabel: "T", + lineStart: 1, + lineEnd: 1, + text: "x", + }, + { + version: 1, + contextId: "img-unrelated", + kind: "image", + label: "other", + attachmentId: "b", + name: "other.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + }, + ); + expect( + readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), + ).toEqual(["img-old", "ctx-t"]); + if (focus === "blurred") { + expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( + `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`, + ); + expect(imported).toEqual(["img-old", "ctx-t"]); + return; + } + editor.update( + () => { + editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, ); expect(imported).toEqual(["img-old", "ctx-t"]); - return; - } - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " and ", - ); - }); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + `${prefix} and `, + ); + }, + ); it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { const copied = upgradeLegacyContextMessage( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index fbd83962918207172cb0d674993b2398ad9c17e9..3ba086e0e981e6db38ebf5a98b071f1ecaeca357 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cb2143ab65aae057fab33ee53e68974adf5a9342..e3ae139110360fa257a13e5a5a6b92975aeb89fd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,13 +1,15 @@ +import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; -import { importPastedComposerText } from "../composerInlineTokenPaste"; +import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, + countQuestionAttachments, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; @@ -34,8 +36,15 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { + isPasteAsTextShortcut, + nextPastedTextFileName, + pastedTextDisposition, + wouldTextPasteExceedLimit, +} from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -194,6 +203,7 @@ import { ensureInlineContextReferences, formatInlineContextReference, insertInlineContextReference, + inlineContextReferenceReplacement, toKindScopedComposerContextId, } from "~/lib/composerContextReferences"; import { @@ -284,7 +294,7 @@ import { } from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { basenameOfPath } from "../../pierre-icons"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { text: string, options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, ) => boolean; + /** Apply large-paste folding for text redirected from a blurred composer. */ + pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; citeAssistantText: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); + const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set }>({ + targetKey: "", + names: new Set(), + }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerFooterControlsRef = useRef(null); @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); isRevertingCheckpointRef.current = isRevertingCheckpoint; + useEffect(() => { + const armPasteAsTextShortcut = () => { + // Electron can deliver its native menu action just before the paste + // event, while browsers normally deliver keydown first. A short deadline + // bridges both event paths without leaving later pastes in bypass mode. + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Node && + composerFormRef.current?.contains(event.target) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + armPasteAsTextShortcut(); + } + }; + const onBlur = () => { + pasteAsTextShortcutUntilRef.current = 0; + }; + const onDesktopPasteAsText = () => { + const activeElement = document.activeElement; + const blocksPasteToFocus = + activeElement instanceof Element && + activeElement.closest( + 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', + ) !== null; + if ( + (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || + !blocksPasteToFocus + ) { + armPasteAsTextShortcut(); + } + }; + window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", onBlur); + }; + }, []); + // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // An expired upload carries no ids, so it hydrates as a // needs-reattach row and the "Attach again" flow takes over. ...(expired @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sizeBytes: file.sizeBytes, attachmentId: upload.attachmentId, environmentId, + ...(file.source ? { source: file.source } : {}), }); } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it @@ -5032,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: attachments // ------------------------------------------------------------------ + const countReservedAttachments = () => { + const questionRequest = pendingUserInputs[0]; + const otherQuestionKeys = + questionAttachmentTarget && questionRequest && activeThreadId + ? questionRequest.questions + .map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId, + questionRequest.requestId, + question.id, + ), + ) + .filter((key) => key !== questionAttachmentTarget) + : []; + return ( + composerImagesRef.current.length + + composerFilesRef.current.length + + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) + + countQuestionAttachments(otherQuestionKeys) + ); + }; /** Resolves true when at least one chip was inserted for the accepted attachments. */ - const addComposerAttachments = async (files: File[]): Promise => { + const addComposerAttachments = async ( + files: File[], + options?: { + readonly source?: ChatFileAttachment["source"]; + readonly selection?: { start: number; end: number }; + }, + ): Promise => { if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; if ( pendingUserInputs.length > 0 && @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // accepted files reserve their attachment slots (via the pending counter) // before the first await, keeping the total under the limit. const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; - const otherQuestionAttachments = - questionAttachmentTarget && pendingUserInputs[0] - ? pendingUserInputs[0].questions.reduce((count, question) => { - const target = questionAttachmentDraftId( - environmentId, - threadId, - pendingUserInputs[0]!.requestId, - question.id, - ); - if (target === questionAttachmentTarget) return count; - const draft = getComposerDraft(target); - return ( - count + - (draft?.images.length ?? 0) + - (draft?.files.length ?? 0) + - (useQuestionAttachmentPreparation.getState().counts[target] ?? 0) - ); - }, 0) - : 0; - let reservedCount = - composerImagesRef.current.length + - composerFilesRef.current.length + - pendingCount + - otherQuestionAttachments; + let reservedCount = countReservedAttachments(); // A pick that matches a needs-reattach marker replaces it in the draft, so // it must not consume a slot; a draft full of markers would otherwise hit // the capacity error before the replacement path could run. @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: fileMimeType, sizeBytes: attachmentFile.size, file: attachmentFile, + ...(options?.source ? { source: options.source } : {}), }); } if (!matchingReattachMarker) { @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); if (storedFiles.length > 0) { - insertedAny = insertAttachmentReferences(storedFiles.map(fileContextReference)); + insertedAny = insertAttachmentReferences( + storedFiles.map(fileContextReference), + options?.selection, + ); + } + if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) { + const attached = storedFiles[0]!; + toastManager.add({ + type: "info", + title: `Large paste attached as ${attached.name}`, + description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${ + isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V" + } to keep a large paste inline.`, + data: { hideCopyButton: true }, + }); } } if (acceptedImages.length === 0) return insertedAny; @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) */ const insertAttachmentReferences = ( references: ReadonlyArray, + selection?: { start: number; end: number }, ): boolean => { if (references.length === 0) return false; // Question answers carry attachments beside the answer, never as chips. Falling back to // the thread prompt here would hide the file behind a reference the question never shows. if (questionAttachmentTarget) return false; + if (selection) { + const edit = inlineContextReferenceReplacement(promptRef.current, selection, references); + return applyPromptReplacement(edit.start, edit.end, edit.text); + } const text = references.map(formatInlineContextReference).join(" "); const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); if (!inserted) { @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ + const foldPastedText = ( + plainText: string, + bypassAutoAttachment: boolean, + selectionOverride?: { start: number; end: number }, + ): boolean => { + const questionCanAttach = + pendingUserInputs.length === 0 || + (supportsQuestionAttachments && + activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && + !activePendingIsResponding); + const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; + const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); + const wouldExceedInputLimit = wouldTextPasteExceedLimit({ + valueLength: promptRef.current.length, + selection: selection ?? { start: 0, end: 0 }, + textLength: plainText.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }); + const shouldFold = + pastedTextDisposition({ + text: plainText, + bypassAutoAttachment, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + if (!shouldFold) { + return false; + } + + const canStageAttachment = + Boolean(activeThreadId) && + !isRevertingCheckpointRef.current && + questionCanAttach && + hasAttachmentSlot; + if (!canStageAttachment || fileStagingLimit === null) { + if (!wouldExceedInputLimit) { + return false; + } + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Remove some text or an attachment, then paste again.", + data: { hideCopyButton: true }, + }); + return true; + } + + if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) { + pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const file of composerFilesRef.current) reservedNames.add(file.name); + const foldedFileName = nextPastedTextFileName([...reservedNames]); + reservedNames.add(foldedFileName); + const foldedFile = new File([plainText], foldedFileName, { + type: "text/plain;charset=utf-8", + }); + if (foldedFile.size > fileStagingLimit) { + reservedNames.delete(foldedFileName); + if (!wouldExceedInputLimit) return false; + toastManager.add({ + type: "error", + title: "Pasted text is too large to attach", + description: "Reduce the clipboard contents or save a smaller excerpt as a file.", + data: { hideCopyButton: true }, + }); + return true; + } + + void addComposerAttachments([foldedFile], { + source: { _tag: "pasted-text" }, + ...(selection ? { selection } : {}), + }); + return true; + }; + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); + const plainText = event.clipboardData.getData("text/plain"); + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; // Claimable pastes go through even when agent questions are pending or the // composer is at its attachment limit: `addComposerAttachments` surfaces // those as a toast and a thread error. An early return here would swallow // the paste with no feedback. if ( - files.length === 0 || - !activeThreadId || - !shouldHandleComposerAttachmentPaste({ - files, - plainText: event.clipboardData.getData("text/plain"), - }) + files.length > 0 && + activeThreadId && + shouldHandleComposerAttachmentPaste({ files, plainText }) ) { + event.preventDefault(); + event.stopPropagation(); + void addComposerAttachments(files); return; } + + // Copied T3 chips need the structured importer to bring their records and files along. + if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; + if (!foldPastedText(plainText, bypassAutoAttachment)) { + return; + } + event.preventDefault(); - void addComposerAttachments(files); + event.stopPropagation(); }; const insertComposerText = useCallback( @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, insertTextAtEnd: insertComposerTextAtEnd, + pasteTextAtEnd: (text: string, options) => { + const bypassAutoAttachment = + options?.bypassAutoAttachment === true || + Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + const promptLength = promptRef.current.length; + if ( + !foldPastedText(text, bypassAutoAttachment, { + start: promptLength, + end: promptLength, + }) + ) { + return false; + } + focusComposer(); + return true; + }, citeAssistantText: (citation, sourceAnchor) => insertComposerText( formatAssistantCitationForComposer(citation, citation.comment), @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + foldPastedText, composerDraftTarget, composerCursor, composerTerminalContexts, diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index a6e1a25cf967153e32d795f0be92065325077574..33eac5d58aa35a63ff0bdc4ecc5a3f264af806fa 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste( ); } -/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ -export function importPastedComposerText( +/** Clipboard records referenced by the copied text, including dependent screenshots. */ +export function readPastedComposerContext( clipboardData: Pick, - importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], -): string { +): ComposerContextClipboardFragment | null { const pastedText = clipboardData.getData("text/plain"); // Only records whose links are in the pasted text get imported; a fragment may carry // more (it was built for a larger copy) and must not start transfers for those. - const decodedFragment = importContextFragment - ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? - decodeComposerContextClipboardHtml(clipboardData.getData("text/html"))) - : null; + const decodedFragment = + decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? + decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); + if (decodedFragment === null) return null; const pastedIds = new Set( collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), ); - if (decodedFragment) { - for (const record of decodedFragment.records) { - if ( - record.kind === "preview-annotation" && - !("payload" in record) && - pastedIds.has(record.contextId) && - record.screenshotContextId - ) { - pastedIds.add(record.screenshotContextId); - } + for (const record of decodedFragment.records) { + if ( + record.kind === "preview-annotation" && + !("payload" in record) && + pastedIds.has(record.contextId) && + record.screenshotContextId + ) { + pastedIds.add(record.screenshotContextId); } } - const fragment = - decodedFragment === null - ? null - : { - ...decodedFragment, - records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), - }; + return { + ...decodedFragment, + records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), + }; +} + +/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ +export function importPastedComposerText( + clipboardData: Pick, + importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], +): string { + const pastedText = clipboardData.getData("text/plain"); + const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; const text = diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index b3202675de169a936bf84a6e6c04ae268473d247..125a537bc27268cded3dac3c8c2fe7e7cd66b59a 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -119,29 +119,30 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { return ( - - - {props.label} - - } - > -
+ + props.onOpen(event, props.metadata.url)} + > + + {props.label} + + } + /> + -

Captured pull request context

- -
-
+ + ); } diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index b96b42970b99f5a0cde8bd3b5aa47ae4d2490004..3152e4f4a9c56e876b9b9f67dc75028735af0c18 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -12,6 +12,7 @@ import { Eye, Table2, Trash2Icon, + WrapTextIcon, XIcon, } from "lucide-react"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { AudioPreview } from "./AudioPreview"; @@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); + const wordWrap = useClientSettings((settings) => settings.wordWrap); + const updateClientSettings = useUpdateClientSettings(); + // Only the raw-text body honours word wrap. A rendered table or Markdown lays itself out, + // so offering the toggle there would be a control that visibly does nothing. + const showsRawText = + failure === null && + needsText && + content !== null && + !(delimiter && rendered) && + !(kind === "markdown" && rendered); const save = () => { setSaving(true); @@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: { )} ) : null} + {showsRawText ? ( + updateClientSettings({ wordWrap: !wordWrap })} + > + + + ) : null} {content ? (