| author | |
| committer | |
| log | 68c2277f500bbbb299396bcdcd0aec60dcb5db9d |
| tree | eb01b378954f9176fac589107cdff9cf4ae1711c |
| parent | 36caf200c2b24c12838570966f5c85786a1fc1f8 |
| signature | Signed by PGP key B5690EEEBB952194 |
77 files changed, 3275 insertions(+), 462 deletions(-)
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -40,6 +40,7 @@ import { |
| 40 | 40 | openExternal, |
| 41 | 41 | openSystemSettings, |
| 42 | 42 | checkSystemPermission, |
| 43 | pasteAsText, | |
| 43 | 44 | probeRemoteEditors, |
| 44 | 45 | pickFolder, |
| 45 | 46 | pickProjectFavicon, |
| ... | ... | @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 124 | 125 | yield* ipc.handle(openExternal); |
| 125 | 126 | yield* ipc.handle(openSystemSettings); |
| 126 | 127 | yield* ipc.handle(checkSystemPermission); |
| 128 | yield* ipc.handle(pasteAsText); | |
| 127 | 129 | yield* ipc.handle(probeRemoteEditors); |
| 128 | 130 | yield* ipc.handle(getUpdateState); |
| 129 | 131 | yield* ipc.handle(setUpdateChannel); |
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | 7 | export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; |
| 8 | 8 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; |
| 9 | 9 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; | |
| 10 | 11 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; |
| 11 | 12 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; |
| 12 | 13 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
apps/desktop/src/ipc/methods/window.test.ts+54| ... | ... | @@ -6,6 +6,15 @@ import { vi } from "vite-plus/test"; |
| 6 | 6 | |
| 7 | 7 | import type * as Electron from "electron"; |
| 8 | 8 | |
| 9 | const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ | |
| 10 | focusedWebContents: vi.fn(), | |
| 11 | ownerWindow: vi.fn(), | |
| 12 | })); | |
| 13 | vi.mock("electron", () => ({ | |
| 14 | webContents: { getFocusedWebContents: focusedWebContents }, | |
| 15 | BrowserWindow: { fromWebContents: ownerWindow }, | |
| 16 | })); | |
| 17 | ||
| 9 | 18 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 10 | 19 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 11 | 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| ... | ... | @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 13 | 22 | import { |
| 14 | 23 | getLocalEnvironmentBootstraps, |
| 15 | 24 | getWindowFullscreenState, |
| 25 | pasteAsText, | |
| 16 | 26 | pickProjectFavicon, |
| 17 | 27 | } from "./window.ts"; |
| 18 | 28 | |
| ... | ... | @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => { |
| 153 | 163 | }); |
| 154 | 164 | }); |
| 155 | 165 | |
| 166 | describe("pasteAsText", () => { | |
| 167 | it.effect( | |
| 168 | "pastes into the focused guest only after the main renderer acknowledges the menu action", | |
| 169 | () => { | |
| 170 | const paste = vi.fn(); | |
| 171 | const mainPaste = vi.fn(); | |
| 172 | const window = { | |
| 173 | webContents: { id: 42, paste: mainPaste }, | |
| 174 | isDestroyed: () => false, | |
| 175 | } as unknown as Electron.BrowserWindow; | |
| 176 | focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); | |
| 177 | ownerWindow.mockReturnValue(window); | |
| 178 | ||
| 179 | return Effect.gen(function* () { | |
| 180 | yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); | |
| 181 | assert.equal(paste.mock.calls.length, 1); | |
| 182 | assert.equal(mainPaste.mock.calls.length, 0); | |
| 183 | ||
| 184 | yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); | |
| 185 | assert.equal(paste.mock.calls.length, 1); | |
| 186 | ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. | |
| 187 | yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); | |
| 188 | assert.equal(paste.mock.calls.length, 1); | |
| 189 | ownerWindow.mockReturnValue(null); // Detached contents. | |
| 190 | yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); | |
| 191 | assert.equal(paste.mock.calls.length, 1); | |
| 192 | ownerWindow.mockReturnValue(window); | |
| 193 | focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); | |
| 194 | yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); | |
| 195 | assert.equal(paste.mock.calls.length, 1); | |
| 196 | focusedWebContents.mockReturnValue(null); | |
| 197 | yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); | |
| 198 | assert.equal(paste.mock.calls.length, 1); | |
| 199 | }).pipe( | |
| 200 | Effect.provide( | |
| 201 | Layer.mock(ElectronWindow.ElectronWindow)({ | |
| 202 | main: Effect.succeed(Option.some(window)), | |
| 203 | }), | |
| 204 | ), | |
| 205 | ); | |
| 206 | }, | |
| 207 | ); | |
| 208 | }); | |
| 209 | ||
| 156 | 210 | describe("pickProjectFavicon", () => { |
| 157 | 211 | it.effect("opens a single-image picker from the project directory", () => |
| 158 | 212 | Effect.gen(function* () { |
apps/desktop/src/ipc/methods/window.ts+26| ... | ... | @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ |
| 345 | 345 | }), |
| 346 | 346 | }); |
| 347 | 347 | |
| 348 | export const pasteAsText = DesktopIpc.makeIpcMethod({ | |
| 349 | channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, | |
| 350 | payload: Schema.Undefined, | |
| 351 | result: Schema.Void, | |
| 352 | handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { | |
| 353 | const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 354 | const window = yield* electronWindow.main; | |
| 355 | if ( | |
| 356 | event === undefined || | |
| 357 | Option.isNone(window) || | |
| 358 | window.value.isDestroyed() || | |
| 359 | window.value.webContents.id !== event.sender.id | |
| 360 | ) { | |
| 361 | return; | |
| 362 | } | |
| 363 | const focused = Electron.webContents.getFocusedWebContents(); | |
| 364 | if ( | |
| 365 | focused && | |
| 366 | !focused.isDestroyed() && | |
| 367 | Electron.BrowserWindow.fromWebContents(focused) === window.value | |
| 368 | ) { | |
| 369 | focused.paste(); | |
| 370 | } | |
| 371 | }), | |
| 372 | }); | |
| 373 | ||
| 348 | 374 | /** Theme files are a few KB; anything larger returns empty text and lets the |
| 349 | 375 | * renderer reject it by size without the contents ever crossing the bridge. */ |
| 350 | 376 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; |
apps/desktop/src/preload.ts+1| ... | ... | @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 157 | 157 | openSystemSettings: (pane: string) => |
| 158 | 158 | ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), |
| 159 | 159 | probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), |
| 160 | pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), | |
| 160 | 161 | onMenuAction: (listener) => { |
| 161 | 162 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 162 | 163 | if (typeof action !== "string") return; |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+30| ... | ... | @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { |
| 153 | 153 | }), |
| 154 | 154 | ); |
| 155 | 155 | |
| 156 | it.effect("owns Paste as Text and routes it through the renderer", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const selectedAction = yield* Deferred.make<string>(); | |
| 159 | const applicationMenuTemplate = | |
| 160 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); | |
| 161 | ||
| 162 | yield* configureMenu(selectedAction, applicationMenuTemplate); | |
| 163 | ||
| 164 | const template = yield* Deferred.await(applicationMenuTemplate); | |
| 165 | const editMenu = template.find((item) => item.label === "Edit"); | |
| 166 | assert.isDefined(editMenu); | |
| 167 | if (!Array.isArray(editMenu.submenu)) { | |
| 168 | throw new Error("Expected Edit menu submenu to be an array."); | |
| 169 | } | |
| 170 | const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); | |
| 171 | assert.isDefined(pasteAsTextItem); | |
| 172 | assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); | |
| 173 | if (typeof pasteAsTextItem.click !== "function") { | |
| 174 | throw new Error("Expected Paste as Text menu item to have a click handler."); | |
| 175 | } | |
| 176 | ||
| 177 | pasteAsTextItem.click( | |
| 178 | {} as Electron.MenuItem, | |
| 179 | {} as Electron.BrowserWindow, | |
| 180 | {} as KeyboardEvent, | |
| 181 | ); | |
| 182 | assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); | |
| 183 | }), | |
| 184 | ); | |
| 185 | ||
| 156 | 186 | // Zoom must route through DesktopWindow.zoomMain instead of the Electron |
| 157 | 187 | // zoom roles: the roles zoom whichever webContents has focus, which breaks |
| 158 | 188 | // app zoom while an embedded preview WebContentsView holds focus. |
apps/desktop/src/window/DesktopApplicationMenu.ts+34-2| ... | ... | @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function |
| 46 | 46 | action: string, |
| 47 | 47 | ): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> { |
| 48 | 48 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 49 | yield* desktopWindow.dispatchMenuAction(action); | |
| 49 | yield* desktopWindow.dispatchMenuAction(action, { | |
| 50 | reveal: action !== "paste-as-text", | |
| 51 | }); | |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | 54 | const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( |
| ... | ... | @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { |
| 135 | 137 | const settingsClick = () => { |
| 136 | 138 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); |
| 137 | 139 | }; |
| 140 | const pasteAsTextClick = () => { | |
| 141 | runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); | |
| 142 | }; | |
| 138 | 143 | const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { |
| 139 | 144 | runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); |
| 140 | 145 | }; |
| ... | ... | @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { |
| 184 | 189 | { role: environment.platform === "darwin" ? "close" : "quit" }, |
| 185 | 190 | ], |
| 186 | 191 | }, |
| 187 | { role: "editMenu" }, | |
| 192 | { | |
| 193 | label: "Edit", | |
| 194 | submenu: [ | |
| 195 | { role: "undo" }, | |
| 196 | { role: "redo" }, | |
| 197 | { type: "separator" }, | |
| 198 | { role: "cut" }, | |
| 199 | { role: "copy" }, | |
| 200 | { role: "paste" }, | |
| 201 | { | |
| 202 | label: "Paste as Text", | |
| 203 | accelerator: "CmdOrCtrl+Shift+V", | |
| 204 | click: pasteAsTextClick, | |
| 205 | }, | |
| 206 | { role: "delete" }, | |
| 207 | { type: "separator" }, | |
| 208 | { role: "selectAll" }, | |
| 209 | ...(environment.platform === "darwin" | |
| 210 | ? [ | |
| 211 | { type: "separator" as const }, | |
| 212 | { | |
| 213 | label: "Speech", | |
| 214 | submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], | |
| 215 | }, | |
| 216 | ] | |
| 217 | : []), | |
| 218 | ], | |
| 219 | }, | |
| 188 | 220 | { |
| 189 | 221 | label: "View", |
| 190 | 222 | submenu: [ |
apps/mobile/modules/t3-composer-editor/android/build.gradle+12| ... | ... | @@ -8,6 +8,10 @@ android { |
| 8 | 8 | namespace 'expo.modules.t3composereditor' |
| 9 | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | |
| 11 | testOptions { | |
| 12 | unitTests.includeAndroidResources = true | |
| 13 | } | |
| 14 | ||
| 11 | 15 | defaultConfig { |
| 12 | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | ... | @@ -17,4 +21,12 @@ android { |
| 17 | 21 | dependencies { |
| 18 | 22 | implementation project(':expo-modules-core') |
| 19 | 23 | implementation project(':t3tools-mobile-markdown-text') |
| 24 | testImplementation 'junit:junit:4.13.2' | |
| 25 | testImplementation 'org.robolectric:robolectric:4.16.1' | |
| 26 | } | |
| 27 | ||
| 28 | tasks.withType(Test).configureEach { | |
| 29 | javaLauncher = javaToolchains.launcherFor { | |
| 30 | languageVersion = JavaLanguageVersion.of(21) | |
| 31 | } | |
| 20 | 32 | } |
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt+7| ... | ... | @@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() { |
| 116 | 116 | Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> |
| 117 | 117 | view.setSpellCheck(spellCheck) |
| 118 | 118 | } |
| 119 | Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int -> | |
| 120 | view.setTextPasteThresholdBytes(threshold) | |
| 121 | } | |
| 122 | Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int -> | |
| 123 | view.setMaxInputChars(maxInputChars) | |
| 124 | } | |
| 119 | 125 | |
| 120 | 126 | Events( |
| 121 | 127 | "onComposerChange", |
| ... | ... | @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() { |
| 125 | 131 | "onComposerPasteImages", |
| 126 | 132 | "onComposerContextPress", |
| 127 | 133 | "onComposerPasteContext", |
| 134 | "onComposerPasteText", | |
| 128 | 135 | "onComposerContentSizeChange", |
| 129 | 136 | ) |
| 130 | 137 |
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+94-10| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | package expo.modules.t3composereditor |
| 2 | 2 | |
| 3 | import android.content.Context | |
| 3 | import android.content.ClipData | |
| 4 | 4 | import android.content.ClipboardManager |
| 5 | import android.content.Context | |
| 5 | 6 | import android.graphics.Color |
| 6 | 7 | import android.graphics.Canvas |
| 7 | 8 | import android.graphics.Paint |
| ... | ... | @@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 48 | 49 | private val onComposerPasteImages by EventDispatcher() |
| 49 | 50 | private val onComposerContextPress by EventDispatcher() |
| 50 | 51 | private val onComposerPasteContext by EventDispatcher() |
| 52 | private val onComposerPasteText by EventDispatcher() | |
| 51 | 53 | private val onComposerContentSizeChange by EventDispatcher() |
| 52 | 54 | private var applyingNativeValue = false |
| 53 | 55 | private var desiredLineHeightPx = 0 |
| ... | ... | @@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 86 | 88 | editor.pasteImagesListener = { uris -> |
| 87 | 89 | onComposerPasteImages(mapOf("uris" to uris)) |
| 88 | 90 | } |
| 89 | editor.pasteContextListener = { payload -> onComposerPasteContext(payload) } | |
| 91 | editor.pasteContextListener = { payload -> | |
| 92 | nativeEventCount += 1 | |
| 93 | onComposerPasteContext( | |
| 94 | payload + mapOf( | |
| 95 | "value" to editor.text.toString(), | |
| 96 | "eventCount" to nativeEventCount, | |
| 97 | "selection" to currentSelectionPayload(), | |
| 98 | ) | |
| 99 | ) | |
| 100 | } | |
| 90 | 101 | val contextGestures = |
| 91 | 102 | GestureDetector( |
| 92 | 103 | context, |
| ... | ... | @@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 120 | 131 | contextGestures.onTouchEvent(event) |
| 121 | 132 | false |
| 122 | 133 | } |
| 134 | editor.pasteTextListener = { text, start, end -> | |
| 135 | nativeEventCount += 1 | |
| 136 | onComposerPasteText( | |
| 137 | mapOf( | |
| 138 | "value" to editor.text.toString(), | |
| 139 | "eventCount" to nativeEventCount, | |
| 140 | "text" to text, | |
| 141 | "selection" to currentSelectionPayload(start, end), | |
| 142 | ), | |
| 143 | ) | |
| 144 | } | |
| 123 | 145 | editor.setOnFocusChangeListener { _, hasFocus -> |
| 124 | 146 | if (hasFocus) { |
| 125 | 147 | onComposerFocus(emptyMap<String, Any>()) |
| ... | ... | @@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 305 | 327 | updateInputFlags() |
| 306 | 328 | } |
| 307 | 329 | |
| 330 | fun setTextPasteThresholdBytes(threshold: Int) { | |
| 331 | editor.textPasteThresholdBytes = threshold | |
| 332 | } | |
| 333 | ||
| 334 | fun setMaxInputChars(maxInputChars: Int) { | |
| 335 | editor.maxInputChars = maxInputChars | |
| 336 | } | |
| 337 | ||
| 308 | 338 | fun focusEditor() { |
| 309 | 339 | editor.requestFocus() |
| 310 | 340 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager |
| ... | ... | @@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 365 | 395 | editor.highlightColor = defaultHighlightColor |
| 366 | 396 | } |
| 367 | 397 | |
| 368 | private fun currentSelectionPayload(): Map<String, Int> = | |
| 398 | private fun currentSelectionPayload( | |
| 399 | start: Int = editor.selectionStart, | |
| 400 | end: Int = editor.selectionEnd | |
| 401 | ): Map<String, Int> = | |
| 369 | 402 | mapOf( |
| 370 | "start" to editor.selectionStart.coerceAtLeast(0), | |
| 371 | "end" to editor.selectionEnd.coerceAtLeast(0), | |
| 403 | "start" to minOf(start, end).coerceAtLeast(0), | |
| 404 | "end" to maxOf(start, end).coerceAtLeast(0), | |
| 372 | 405 | ) |
| 373 | 406 | |
| 374 | 407 | private fun emitSelectionChange(start: Int, end: Int) { |
| ... | ... | @@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 379 | 412 | onComposerSelectionChange( |
| 380 | 413 | mapOf( |
| 381 | 414 | "value" to editor.text.toString(), |
| 382 | "selection" to mapOf("start" to start, "end" to end), | |
| 415 | "selection" to currentSelectionPayload(start, end), | |
| 383 | 416 | "eventCount" to nativeEventCount, |
| 384 | 417 | ), |
| 385 | 418 | ) |
| ... | ... | @@ -544,11 +577,14 @@ private fun parseTokens(value: String): List<ComposerToken> = try { |
| 544 | 577 | emptyList() |
| 545 | 578 | } |
| 546 | 579 | |
| 547 | private class SelectionAwareEditText(context: Context) : EditText(context) { | |
| 580 | internal class SelectionAwareEditText(context: Context) : EditText(context) { | |
| 548 | 581 | var readOnly = false |
| 549 | 582 | var selectionListener: ((Int, Int) -> Unit)? = null |
| 550 | 583 | var pasteImagesListener: ((List<String>) -> Unit)? = null |
| 551 | 584 | var pasteContextListener: ((Map<String, String>) -> Unit)? = null |
| 585 | var pasteTextListener: ((String, Int, Int) -> Unit)? = null | |
| 586 | var textPasteThresholdBytes = 0 | |
| 587 | var maxInputChars = Int.MAX_VALUE | |
| 552 | 588 | var clipboardFragment = "" |
| 553 | 589 | |
| 554 | 590 | private fun deleteChip(backwards: Boolean): Boolean { |
| ... | ... | @@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 602 | 638 | super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) |
| 603 | 639 | } |
| 604 | 640 | } |
| 605 | ||
| 606 | 641 | override fun onSelectionChanged(selStart: Int, selEnd: Int) { |
| 607 | 642 | super.onSelectionChanged(selStart, selEnd) |
| 608 | 643 | selectionListener?.invoke(selStart, selEnd) |
| ... | ... | @@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 615 | 650 | } |
| 616 | 651 | val handled = when { |
| 617 | 652 | id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut) |
| 653 | id == android.R.id.pasteAsPlainText -> { | |
| 654 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager | |
| 655 | pasteInterceptedText(clipboard?.primaryClip, foldLargeText = false) | |
| 656 | } | |
| 618 | 657 | pasting -> pasteContextOrImages() |
| 619 | 658 | else -> false |
| 620 | 659 | } |
| ... | ... | @@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 636 | 675 | pasteContextListener?.invoke(payload) |
| 637 | 676 | return true |
| 638 | 677 | } |
| 678 | return pasteImagesOrInterceptedText() | |
| 679 | } | |
| 680 | ||
| 681 | private fun pasteImagesOrInterceptedText(): Boolean { | |
| 639 | 682 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager |
| 640 | 683 | val clip = clipboard?.primaryClip |
| 641 | 684 | val imageUris = buildList { |
| ... | ... | @@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 648 | 691 | } |
| 649 | 692 | } |
| 650 | 693 | } |
| 651 | if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris) | |
| 652 | return imageUris.isNotEmpty() | |
| 694 | return when { | |
| 695 | imageUris.isNotEmpty() -> { | |
| 696 | pasteImagesListener?.invoke(imageUris) | |
| 697 | true | |
| 698 | } | |
| 699 | else -> pasteInterceptedText(clip) | |
| 700 | } | |
| 701 | } | |
| 702 | ||
| 703 | private fun pasteInterceptedText(clip: ClipData?, foldLargeText: Boolean = true): Boolean { | |
| 704 | val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null | |
| 705 | if (text.isNullOrEmpty()) return false | |
| 706 | val start = minOf(selectionStart, selectionEnd).coerceIn(0, length()) | |
| 707 | val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length()) | |
| 708 | val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars | |
| 709 | val shouldFold = foldLargeText && ( | |
| 710 | text.length >= textPasteThresholdBytes || | |
| 711 | text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes | |
| 712 | ) | |
| 713 | val shouldIntercept = exceedsInputLimit || shouldFold | |
| 714 | if (shouldIntercept) { | |
| 715 | pasteTextListener?.invoke(text, start, end) | |
| 716 | } | |
| 717 | // Let EditText perform ordinary pastes, retaining its native undo history. | |
| 718 | return shouldIntercept | |
| 719 | } | |
| 720 | ||
| 721 | // coerceToText opens content: URIs synchronously. Leave URI-backed | |
| 722 | // clipboard items to Android's normal paste path so the UI thread never | |
| 723 | // reads an arbitrary provider just to measure a text paste. | |
| 724 | private fun ClipData.plainText(): String? = | |
| 725 | takeIf { itemCount > 0 } | |
| 726 | ?.getItemAt(0) | |
| 727 | ?.takeIf { it.uri == null } | |
| 728 | ?.coerceToText(context) | |
| 729 | ?.toString() | |
| 730 | ?.takeIf(String::isNotEmpty) | |
| 731 | ||
| 732 | override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { | |
| 733 | if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { | |
| 734 | return onTextContextMenuItem(android.R.id.pasteAsPlainText) | |
| 735 | } | |
| 736 | return super.onKeyShortcut(keyCode, event) | |
| 653 | 737 | } |
| 654 | 738 | } |
apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | package expo.modules.t3composereditor | |
| 2 | ||
| 3 | import android.content.ClipData | |
| 4 | import android.content.ClipboardManager | |
| 5 | import android.content.Context | |
| 6 | import android.view.KeyEvent | |
| 7 | import org.junit.Assert.assertEquals | |
| 8 | import org.junit.Assert.assertFalse | |
| 9 | import org.junit.Assert.assertNull | |
| 10 | import org.junit.Assert.assertTrue | |
| 11 | import org.junit.Test | |
| 12 | import org.junit.runner.RunWith | |
| 13 | import org.robolectric.RobolectricTestRunner | |
| 14 | import org.robolectric.RuntimeEnvironment | |
| 15 | import org.robolectric.annotation.Config | |
| 16 | ||
| 17 | @RunWith(RobolectricTestRunner::class) | |
| 18 | @Config(sdk = [36], manifest = Config.NONE) | |
| 19 | class ComposerPasteTest { | |
| 20 | private val context = RuntimeEnvironment.getApplication() | |
| 21 | private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager | |
| 22 | private val editor = SelectionAwareEditText(context).apply { | |
| 23 | textPasteThresholdBytes = 32 * 1024 | |
| 24 | maxInputChars = 120_000 | |
| 25 | } | |
| 26 | ||
| 27 | private fun pasteAsText(): Boolean = editor.onKeyShortcut( | |
| 28 | KeyEvent.KEYCODE_V, | |
| 29 | KeyEvent( | |
| 30 | 0, | |
| 31 | 0, | |
| 32 | KeyEvent.ACTION_DOWN, | |
| 33 | KeyEvent.KEYCODE_V, | |
| 34 | 0, | |
| 35 | KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON | |
| 36 | ) | |
| 37 | ) | |
| 38 | ||
| 39 | @Test | |
| 40 | fun shortcutKeepsLargeTextInlineAndUndoable() { | |
| 41 | val pasted = "x".repeat(32 * 1024) | |
| 42 | clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) | |
| 43 | editor.setText("before old after") | |
| 44 | editor.setSelection(10, 7) | |
| 45 | editor.pasteTextListener = { _, _, _ -> error("Inline paste must stay native") } | |
| 46 | ||
| 47 | assertTrue(pasteAsText()) | |
| 48 | assertEquals("before $pasted after", editor.text.toString()) | |
| 49 | assertTrue(editor.onTextContextMenuItem(android.R.id.undo)) | |
| 50 | assertEquals("before old after", editor.text.toString()) | |
| 51 | } | |
| 52 | ||
| 53 | @Test | |
| 54 | fun shortcutInterceptsInputLimitOverflowWithoutChangingTheSelection() { | |
| 55 | clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) | |
| 56 | editor.setText("x".repeat(119_999)) | |
| 57 | editor.setSelection(10, 7) | |
| 58 | var intercepted: Triple<String, Int, Int>? = null | |
| 59 | editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) } | |
| 60 | ||
| 61 | assertTrue(pasteAsText()) | |
| 62 | assertEquals(Triple("hello", 7, 10), intercepted) | |
| 63 | assertEquals(119_999, editor.length()) | |
| 64 | assertEquals(10, editor.selectionStart) | |
| 65 | assertEquals(7, editor.selectionEnd) | |
| 66 | } | |
| 67 | ||
| 68 | @Test | |
| 69 | fun shortcutAllowsReplacementAtTheInputLimit() { | |
| 70 | clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) | |
| 71 | editor.setText("x".repeat(120_000)) | |
| 72 | editor.setSelection(12, 7) | |
| 73 | editor.pasteTextListener = { _, _, _ -> error("Replacement fits the input limit") } | |
| 74 | ||
| 75 | assertTrue(pasteAsText()) | |
| 76 | assertEquals("hello", editor.text.substring(7, 12)) | |
| 77 | assertEquals(120_000, editor.length()) | |
| 78 | } | |
| 79 | ||
| 80 | @Test | |
| 81 | fun regularPasteStillFoldsAtTheUtf8Threshold() { | |
| 82 | val pasted = "é".repeat(16 * 1024) | |
| 83 | clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) | |
| 84 | editor.setText("old") | |
| 85 | editor.setSelection(0, 3) | |
| 86 | var intercepted: String? = null | |
| 87 | editor.pasteTextListener = { text, _, _ -> intercepted = text } | |
| 88 | ||
| 89 | assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) | |
| 90 | assertEquals(pasted, intercepted) | |
| 91 | assertEquals("old", editor.text.toString()) | |
| 92 | } | |
| 93 | ||
| 94 | @Test | |
| 95 | fun shortcutPastesStructuredClipboardAsText() { | |
| 96 | clipboard.setPrimaryClip( | |
| 97 | ClipData.newHtmlText( | |
| 98 | "test", | |
| 99 | "plain text", | |
| 100 | "<pre data-t3-context-fragment=\"x\">plain text</pre>" | |
| 101 | ) | |
| 102 | ) | |
| 103 | editor.setSelection(0) | |
| 104 | editor.pasteContextListener = { error("Paste as Text must not import structured context") } | |
| 105 | ||
| 106 | assertTrue(pasteAsText()) | |
| 107 | assertEquals("plain text", editor.text.toString()) | |
| 108 | } | |
| 109 | ||
| 110 | @Test | |
| 111 | fun readOnlyShortcutDoesNotPasteOrEmitAnEvent() { | |
| 112 | clipboard.setPrimaryClip(ClipData.newPlainText("test", "x".repeat(120_001))) | |
| 113 | editor.setText("unchanged") | |
| 114 | editor.setSelection(editor.length()) | |
| 115 | editor.readOnly = true | |
| 116 | var intercepted: String? = null | |
| 117 | editor.pasteTextListener = { text, _, _ -> intercepted = text } | |
| 118 | ||
| 119 | assertFalse(pasteAsText()) | |
| 120 | assertEquals("unchanged", editor.text.toString()) | |
| 121 | assertNull(intercepted) | |
| 122 | } | |
| 123 | } |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+7| ... | ... | @@ -87,6 +87,12 @@ public class T3ComposerEditorModule: Module { |
| 87 | 87 | Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in |
| 88 | 88 | view.setSpellCheck(spellCheck) |
| 89 | 89 | } |
| 90 | Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in | |
| 91 | view.setTextPasteThresholdBytes(threshold) | |
| 92 | } | |
| 93 | Prop("maxInputChars") { (view: T3ComposerEditorView, maxInputChars: Int) in | |
| 94 | view.setMaxInputChars(maxInputChars) | |
| 95 | } | |
| 90 | 96 | |
| 91 | 97 | Events( |
| 92 | 98 | "onComposerChange", |
| ... | ... | @@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module { |
| 97 | 103 | "onComposerPasteImages", |
| 98 | 104 | "onComposerContextPress", |
| 99 | 105 | "onComposerPasteContext", |
| 106 | "onComposerPasteText", | |
| 100 | 107 | "onComposerContentSizeChange" |
| 101 | 108 | ) |
| 102 | 109 |
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+70-1| ... | ... | @@ -86,10 +86,14 @@ private final class ComposerTextView: UITextView { |
| 86 | 86 | |
| 87 | 87 | var onPasteImages: (([String]) -> Void)? |
| 88 | 88 | var onPasteContext: (([String: String]) -> Void)? |
| 89 | var onPasteText: ((String, NSRange) -> Void)? | |
| 89 | 90 | var clipboardFragment = "" |
| 90 | 91 | var onAttributedMutation: (() -> Void)? |
| 91 | 92 | var onSubmit: (() -> Void)? |
| 92 | 93 | var isReadOnly = false |
| 94 | var textPasteThresholdBytes = 0 | |
| 95 | var maxInputChars = Int.max | |
| 96 | private var bypassTextPasteInterception = false | |
| 93 | 97 | |
| 94 | 98 | override var keyCommands: [UIKeyCommand]? { |
| 95 | 99 | var commands = super.keyCommands ?? [] |
| ... | ... | @@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView { |
| 101 | 105 | submit.discoverabilityTitle = "Send Message" |
| 102 | 106 | submit.wantsPriorityOverSystemBehavior = true |
| 103 | 107 | commands.append(submit) |
| 108 | if textPasteThresholdBytes > 0 { | |
| 109 | let pasteAsText = UIKeyCommand( | |
| 110 | input: "v", | |
| 111 | modifierFlags: [.command, .shift], | |
| 112 | action: #selector(pasteInline(_:)) | |
| 113 | ) | |
| 114 | pasteAsText.discoverabilityTitle = "Paste as Text" | |
| 115 | pasteAsText.wantsPriorityOverSystemBehavior = true | |
| 116 | commands.append(pasteAsText) | |
| 117 | } | |
| 104 | 118 | return commands |
| 105 | 119 | } |
| 106 | 120 | |
| ... | ... | @@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView { |
| 108 | 122 | onSubmit?() |
| 109 | 123 | } |
| 110 | 124 | |
| 125 | @objc private func pasteInline(_ sender: UIKeyCommand) { | |
| 126 | guard !isReadOnly else { | |
| 127 | return | |
| 128 | } | |
| 129 | bypassTextPasteInterception = true | |
| 130 | defer { bypassTextPasteInterception = false } | |
| 131 | paste(sender) | |
| 132 | } | |
| 133 | ||
| 111 | 134 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 112 | 135 | if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { |
| 113 | 136 | return false |
| ... | ... | @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { |
| 150 | 173 | return |
| 151 | 174 | } |
| 152 | 175 | } |
| 176 | if !bypassTextPasteInterception, | |
| 177 | let text = pasteboard.string, shouldInterceptTextPaste(text) { | |
| 178 | onPasteText?(text, selectedRange) | |
| 179 | return | |
| 180 | } | |
| 153 | 181 | super.paste(sender) |
| 154 | 182 | } |
| 155 | 183 | |
| 184 | private func shouldInterceptTextPaste(_ text: String) -> Bool { | |
| 185 | guard textPasteThresholdBytes > 0, !text.isEmpty else { return false } | |
| 186 | let pastedLength = (text as NSString).length | |
| 187 | if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes { | |
| 188 | return true | |
| 189 | } | |
| 190 | // Chips occupy one display character but expand to their source in the | |
| 191 | // submitted message. Measure that source, including the replaced selection. | |
| 192 | let sourceLength = sourceOffset(forDisplayOffset: attributedText.length) | |
| 193 | let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) - | |
| 194 | sourceOffset(forDisplayOffset: selectedRange.location) | |
| 195 | return sourceLength - selectedLength + pastedLength > maxInputChars | |
| 196 | } | |
| 197 | ||
| 156 | 198 | override func deleteBackward() { |
| 157 | 199 | guard !isReadOnly else { |
| 158 | 200 | return |
| ... | ... | @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 368 | 410 | let onComposerPasteImages = EventDispatcher() |
| 369 | 411 | let onComposerContextPress = EventDispatcher() |
| 370 | 412 | let onComposerPasteContext = EventDispatcher() |
| 413 | let onComposerPasteText = EventDispatcher() | |
| 371 | 414 | let onComposerContentSizeChange = EventDispatcher() |
| 372 | 415 | |
| 373 | 416 | public required init(appContext: AppContext? = nil) { |
| ... | ... | @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 387 | 430 | self?.onComposerPasteImages(["uris": urls]) |
| 388 | 431 | } |
| 389 | 432 | textView.onPasteContext = { [weak self] context in |
| 390 | self?.onComposerPasteContext(context) | |
| 433 | guard let self else { return } | |
| 434 | let selection = self.sourceSelection() | |
| 435 | self.nativeEventCount += 1 | |
| 436 | var payload: [String: Any] = context | |
| 437 | payload["value"] = self.textView.serializedText() | |
| 438 | payload["eventCount"] = self.nativeEventCount | |
| 439 | payload["selection"] = ["start": selection.start, "end": selection.end] | |
| 440 | self.onComposerPasteContext(payload) | |
| 441 | } | |
| 442 | textView.onPasteText = { [weak self] text, _ in | |
| 443 | guard let self else { return } | |
| 444 | let selection = self.sourceSelection() | |
| 445 | self.nativeEventCount += 1 | |
| 446 | self.onComposerPasteText([ | |
| 447 | "value": self.textView.serializedText(), | |
| 448 | "eventCount": self.nativeEventCount, | |
| 449 | "text": text, | |
| 450 | "selection": ["start": selection.start, "end": selection.end], | |
| 451 | ]) | |
| 391 | 452 | } |
| 392 | 453 | textView.onAttributedMutation = { [weak self] in |
| 393 | 454 | self?.emitTextChange() |
| ... | ... | @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 596 | 657 | textView.spellCheckingType = spellCheck ? .yes : .no |
| 597 | 658 | } |
| 598 | 659 | |
| 660 | func setTextPasteThresholdBytes(_ threshold: Int) { | |
| 661 | textView.textPasteThresholdBytes = threshold | |
| 662 | } | |
| 663 | ||
| 664 | func setMaxInputChars(_ maxInputChars: Int) { | |
| 665 | textView.maxInputChars = maxInputChars | |
| 666 | } | |
| 667 | ||
| 599 | 668 | func focusEditor() { |
| 600 | 669 | textView.becomeFirstResponder() |
| 601 | 670 | } |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+9| ... | ... | @@ -33,6 +33,15 @@ using namespace facebook::react; |
| 33 | 33 | @end |
| 34 | 34 | |
| 35 | 35 | @implementation T3ContextCopyTextView |
| 36 | // Read-only text still supports selecting the entire document after selecting a word. | |
| 37 | - (BOOL)canPerformAction:(SEL)action withSender:(id)sender | |
| 38 | { | |
| 39 | if (action == @selector(selectAll:)) { | |
| 40 | return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; | |
| 41 | } | |
| 42 | return [super canPerformAction:action withSender:sender]; | |
| 43 | } | |
| 44 | ||
| 36 | 45 | - (void)copy:(id)sender |
| 37 | 46 | { |
| 38 | 47 | NSRange selected = self.selectedRange; |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+3-1| ... | ... | @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { |
| 226 | 226 | // The document picker types every pick as a plain file, so a picture arrives here as one. |
| 227 | 227 | // What it *is* decides how it presents, the same way videos are already recognised below. |
| 228 | 228 | if (attachment.type === "image" || imageMimeType(attachment) !== null) { |
| 229 | // A pasted-text marker does not fit the snapshot source a picture carries. | |
| 230 | const { source: _droppedSource, ...rest } = attachment; | |
| 229 | 231 | return ( |
| 230 | 232 | <ComposerImageAttachment |
| 231 | 233 | {...props} |
| 232 | 234 | attachment={ |
| 233 | 235 | attachment.type === "image" |
| 234 | 236 | ? attachment |
| 235 | : { ...attachment, type: "image", previewUri: attachment.fileUri } | |
| 237 | : { ...rest, type: "image", previewUri: attachment.fileUri } | |
| 236 | 238 | } |
| 237 | 239 | /> |
| 238 | 240 | ); |
apps/mobile/src/components/ComposerEditor.tsx+44-27| ... | ... | @@ -9,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native |
| 9 | 9 | import { |
| 10 | 10 | appendComposerDraftAttachments, |
| 11 | 11 | createComposerDraftContextHistory, |
| 12 | getComposerDraftAfterSelection, | |
| 12 | 13 | getComposerDraftSnapshot, |
| 13 | 14 | insertComposerDraftContext, |
| 15 | insertComposerDraftText, | |
| 14 | 16 | rememberComposerDraftSelection, |
| 15 | 17 | setComposerDraftContext, |
| 16 | 18 | setComposerContextImporting, |
| 17 | 19 | useComposerDraft, |
| 18 | 20 | } from "../state/use-composer-drafts"; |
| 19 | import { | |
| 20 | importComposerContextClipboard, | |
| 21 | type NativeContextClipboard, | |
| 22 | } from "../lib/composerContextClipboard"; | |
| 21 | import { importComposerContextClipboard } from "../lib/composerContextClipboard"; | |
| 23 | 22 | import { ComposerContextSheet } from "./ComposerContextSheet"; |
| 24 | 23 | import { AppText as Text } from "./AppText"; |
| 25 | 24 | import { |
| ... | ... | @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { |
| 34 | 33 | readonly onOpenMention?: (path: string) => void; |
| 35 | 34 | /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ |
| 36 | 35 | readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; |
| 36 | /** | |
| 37 | * A resting composer is a target to type in, not a document to navigate. Its chips go inert | |
| 38 | * so a draft full of them can still be tapped anywhere to start writing; the caller focuses | |
| 39 | * the editor instead. Chips become live again once the composer is open. | |
| 40 | */ | |
| 41 | readonly chipsInert?: boolean; | |
| 42 | /** Called instead of opening a chip while `chipsInert` is set. */ | |
| 43 | readonly onInertChipPress?: () => void; | |
| 37 | 44 | }; |
| 38 | 45 | |
| 39 | 46 | export function ComposerEditor({ |
| ... | ... | @@ -41,6 +48,8 @@ export function ComposerEditor({ |
| 41 | 48 | environmentId, |
| 42 | 49 | onOpenMention, |
| 43 | 50 | onOpenAttachment, |
| 51 | chipsInert, | |
| 52 | onInertChipPress, | |
| 44 | 53 | ...props |
| 45 | 54 | }: ComposerEditorProps) { |
| 46 | 55 | const draft = useComposerDraft(draftKey ?? null); |
| ... | ... | @@ -68,40 +77,35 @@ export function ComposerEditor({ |
| 68 | 77 | }, |
| 69 | 78 | [draftKey], |
| 70 | 79 | ); |
| 71 | const pasteContext = async (clipboard: NativeContextClipboard) => { | |
| 80 | const pasteContext = async ( | |
| 81 | clipboard: Parameters<NonNullable<NativeComposerEditorProps["onPasteContext"]>>[0], | |
| 82 | ) => { | |
| 72 | 83 | if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; |
| 84 | const insertion = { text: clipboard.value, ...clipboard.selection }; | |
| 73 | 85 | const controller = new AbortController(); |
| 74 | 86 | importRef.current = controller; |
| 75 | 87 | setImporting(true); |
| 76 | 88 | setComposerContextImporting(draftKey, true); |
| 77 | 89 | try { |
| 90 | const retained = getComposerDraftAfterSelection(draftKey, insertion); | |
| 78 | 91 | const result = await importComposerContextClipboard( |
| 79 | 92 | clipboard, |
| 80 | getComposerDraftSnapshot(draftKey).attachments.length, | |
| 93 | retained.attachments.length, | |
| 81 | 94 | controller.signal, |
| 82 | getComposerDraftSnapshot(draftKey).context?.records.length ?? 0, | |
| 95 | retained.context?.records.length ?? 0, | |
| 83 | 96 | ); |
| 84 | 97 | if (!result) { |
| 85 | insertComposerDraftContext(draftKey, { | |
| 86 | text: clipboard.text, | |
| 87 | context: { version: 1, records: [] }, | |
| 88 | }); | |
| 98 | insertComposerDraftText(draftKey, clipboard.text, insertion); | |
| 89 | 99 | return; |
| 90 | 100 | } |
| 91 | const rejected = appendComposerDraftAttachments(draftKey, result.attachments); | |
| 92 | const ids = new Set( | |
| 93 | getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), | |
| 94 | ); | |
| 95 | insertComposerDraftContext(draftKey, { | |
| 96 | text: result.text, | |
| 97 | context: { | |
| 98 | version: 1, | |
| 99 | records: result.context.records.filter( | |
| 100 | (record) => !("attachmentId" in record) || ids.has(record.attachmentId), | |
| 101 | ), | |
| 102 | }, | |
| 103 | }); | |
| 104 | if (result.failures.length > 0 || rejected > 0) | |
| 101 | if (!insertComposerDraftContext(draftKey, result, insertion)) { | |
| 102 | Alert.alert( | |
| 103 | "Could not paste context", | |
| 104 | "Remove some attachments or context items from the draft, then paste again.", | |
| 105 | ); | |
| 106 | return; | |
| 107 | } | |
| 108 | if (result.failures.length > 0) | |
| 105 | 109 | Alert.alert( |
| 106 | 110 | "Some attachments could not be copied", |
| 107 | 111 | "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", |
| ... | ... | @@ -161,6 +165,10 @@ export function ComposerEditor({ |
| 161 | 165 | onPasteContext={(clipboard) => void pasteContext(clipboard)} |
| 162 | 166 | context={draft.context} |
| 163 | 167 | onContextPress={(selection) => { |
| 168 | if (chipsInert) { | |
| 169 | onInertChipPress?.(); | |
| 170 | return; | |
| 171 | } | |
| 164 | 172 | const path = composerMentionPath(selection.source, draft.context); |
| 165 | 173 | if (path && onOpenMention) { |
| 166 | 174 | onOpenMention(path); |
| ... | ... | @@ -174,7 +182,12 @@ export function ComposerEditor({ |
| 174 | 182 | setSelected(selection); |
| 175 | 183 | }} |
| 176 | 184 | onSelectionChange={(selection) => { |
| 177 | if (draftKey) rememberComposerDraftSelection(draftKey, props.value, selection); | |
| 185 | if (draftKey) | |
| 186 | rememberComposerDraftSelection( | |
| 187 | draftKey, | |
| 188 | getComposerDraftSnapshot(draftKey).text, | |
| 189 | selection, | |
| 190 | ); | |
| 178 | 191 | props.onSelectionChange?.(selection); |
| 179 | 192 | }} |
| 180 | 193 | /> |
| ... | ... | @@ -229,4 +242,8 @@ export function ComposerEditor({ |
| 229 | 242 | </> |
| 230 | 243 | ); |
| 231 | 244 | } |
| 232 | export type { ComposerEditorHandle, ComposerEditorSelection } from "../native/T3ComposerEditor"; | |
| 245 | export type { | |
| 246 | ComposerEditorHandle, | |
| 247 | ComposerEditorSelection, | |
| 248 | ComposerTextPaste, | |
| 249 | } from "../native/T3ComposerEditor"; |
apps/mobile/src/features/files/AttachmentFileScreen.tsx+32-9| ... | ... | @@ -20,6 +20,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 20 | 20 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 21 | 21 | import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; |
| 22 | 22 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 23 | 24 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 24 | 25 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 25 | 26 | |
| ... | ... | @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { |
| 99 | 100 | </Text> |
| 100 | 101 | </View> |
| 101 | 102 | ) : null} |
| 102 | {table && document.rendered ? ( | |
| 103 | {table && document.activeMode === "table" ? ( | |
| 103 | 104 | <ScrollView className="flex-1"> |
| 104 | 105 | {table.truncated ? ( |
| 105 | 106 | <View className="border-b border-warning-border bg-warning px-4 py-2"> |
| ... | ... | @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { |
| 137 | 138 | </View> |
| 138 | 139 | </ScrollView> |
| 139 | 140 | </ScrollView> |
| 140 | ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( | |
| 141 | ) : document.activeMode === "markdown" && props.environmentId ? ( | |
| 141 | 142 | <FileMarkdownPreview |
| 142 | 143 | cwd="" |
| 143 | 144 | relativePath="" |
| ... | ... | @@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: { |
| 147 | 148 | captured |
| 148 | 149 | /> |
| 149 | 150 | ) : ( |
| 150 | <SourceFileSurface contents={content.text} path={props.name} /> | |
| 151 | <SourceFileSurface contents={content.text} path={props.name} selectable /> | |
| 151 | 152 | )} |
| 152 | 153 | </View> |
| 153 | 154 | ); |
| ... | ... | @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { |
| 175 | 176 | |
| 176 | 177 | export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 177 | 178 | const navigation = useNavigation(); |
| 179 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | |
| 178 | 180 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 179 | 181 | const isAndroid = Platform.OS === "android"; |
| 180 | 182 | const params = props.route.params; |
| ... | ... | @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 249 | 251 | handleBack(); |
| 250 | 252 | }, [draftKey, handleBack, params.attachmentId]); |
| 251 | 253 | |
| 252 | const { content, renderedMode, rendered, setRendered, share, sharing } = document; | |
| 254 | const { content, renderedMode, activeMode, setRendered, share, sharing } = document; | |
| 253 | 255 | const menuActions = useMemo( |
| 254 | 256 | () => |
| 255 | 257 | [ |
| ... | ... | @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 271 | 273 | onPress: () => setRendered(false), |
| 272 | 274 | } as const) |
| 273 | 275 | : null, |
| 276 | content && activeMode === "source" | |
| 277 | ? ({ | |
| 278 | id: "word-wrap", | |
| 279 | title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", | |
| 280 | icon: "text.alignleft", | |
| 281 | inline: false, | |
| 282 | onPress: () => setCodeWordBreak(!appearance.codeWordBreak), | |
| 283 | } as const) | |
| 284 | : null, | |
| 274 | 285 | content |
| 275 | 286 | ? ({ |
| 276 | 287 | id: "copy", |
| ... | ... | @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 312 | 323 | } as const) |
| 313 | 324 | : null, |
| 314 | 325 | ].filter((action) => action !== null), |
| 315 | [content, draftKey, removeFromDraft, renderedMode, setRendered, share, sharing, uri], | |
| 326 | [ | |
| 327 | appearance.codeWordBreak, | |
| 328 | setCodeWordBreak, | |
| 329 | content, | |
| 330 | draftKey, | |
| 331 | removeFromDraft, | |
| 332 | activeMode, | |
| 333 | renderedMode, | |
| 334 | setRendered, | |
| 335 | share, | |
| 336 | sharing, | |
| 337 | uri, | |
| 338 | ], | |
| 316 | 339 | ); |
| 317 | const activeMode = rendered ? "preview" : "source"; | |
| 340 | const selectedAction = activeMode === "source" ? "source" : "preview"; | |
| 318 | 341 | const androidMenuActions = useMemo<MenuAction[]>( |
| 319 | 342 | () => |
| 320 | 343 | menuActions.map((action) => ({ |
| 321 | 344 | id: action.id, |
| 322 | 345 | title: action.title, |
| 323 | 346 | image: action.icon, |
| 324 | state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined, | |
| 347 | state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, | |
| 325 | 348 | ...("destructive" in action ? { attributes: { destructive: true } } : {}), |
| 326 | 349 | })), |
| 327 | [activeMode, menuActions], | |
| 350 | [selectedAction, menuActions], | |
| 328 | 351 | ); |
| 329 | 352 | const handleAndroidMenuAction = useCallback( |
| 330 | 353 | (event: { nativeEvent: { event: string } }) => { |
| ... | ... | @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 372 | 395 | <NativeHeaderToolbar.MenuAction |
| 373 | 396 | key={action.id} |
| 374 | 397 | icon={action.icon} |
| 375 | isOn={action.id === activeMode} | |
| 398 | isOn={action.id === selectedAction} | |
| 376 | 399 | onPress={action.onPress} |
| 377 | 400 | > |
| 378 | 401 | {action.title} |
apps/mobile/src/features/files/SourceFileSurface.tsx+124-19| ... | ... | @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | 3 | import type { ComponentType } from "react"; |
| 4 | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; | |
| 5 | import { | |
| 6 | FlatList, | |
| 7 | RefreshControl, | |
| 8 | ScrollView, | |
| 9 | Text as NativeText, | |
| 10 | useWindowDimensions, | |
| 11 | View, | |
| 12 | } from "react-native"; | |
| 6 | 13 | |
| 7 | 14 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 15 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| ... | ... | @@ -23,13 +30,17 @@ import { |
| 23 | 30 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 24 | 31 | nativeSourceRowId, |
| 25 | 32 | } from "./nativeSourceFileAdapter"; |
| 26 | import { prepareSourceFileDocument } from "./source-file-document"; | |
| 33 | import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; | |
| 34 | ||
| 35 | import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; | |
| 27 | 36 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 28 | 37 | |
| 29 | 38 | interface SourceFileSurfaceProps { |
| 30 | 39 | readonly contents: string; |
| 31 | 40 | readonly path: string; |
| 32 | 41 | readonly initialLine?: number | null; |
| 42 | /** Keep the entire document in one native text-selection scope. */ | |
| 43 | readonly selectable?: boolean; | |
| 33 | 44 | /** Enables native pull-to-refresh on the source surface. */ |
| 34 | 45 | readonly onRefresh?: () => Promise<void> | void; |
| 35 | 46 | } |
| ... | ... | @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 129 | 140 | ? "ready" |
| 130 | 141 | : "highlighting"; |
| 131 | 142 | |
| 132 | return { lines, rowsJson, status, targetIndex, theme, tokens }; | |
| 143 | return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens }; | |
| 133 | 144 | } |
| 134 | 145 | |
| 135 | 146 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { |
| ... | ... | @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat |
| 146 | 157 | return null; |
| 147 | 158 | } |
| 148 | 159 | |
| 149 | function NativeSourceFileSurface( | |
| 150 | props: SourceFileSurfaceProps & { | |
| 151 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; | |
| 152 | }, | |
| 153 | ) { | |
| 154 | const { NativeView, onRefresh } = props; | |
| 155 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); | |
| 156 | const { themeAppearance, themeId } = useAppearancePreferences(); | |
| 157 | const appTheme = useUniwindTheme(); | |
| 158 | const { width: viewportWidth } = useWindowDimensions(); | |
| 159 | const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); | |
| 160 | function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { | |
| 160 | 161 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 161 | 162 | const handlePullToRefresh = useCallback(async () => { |
| 162 | 163 | if (!onRefresh) { |
| ... | ... | @@ -169,6 +170,21 @@ function NativeSourceFileSurface( |
| 169 | 170 | setIsPullRefreshing(false); |
| 170 | 171 | } |
| 171 | 172 | }, [onRefresh]); |
| 173 | return { isPullRefreshing, handlePullToRefresh }; | |
| 174 | } | |
| 175 | ||
| 176 | function NativeSourceFileSurface( | |
| 177 | props: SourceFileSurfaceProps & { | |
| 178 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; | |
| 179 | }, | |
| 180 | ) { | |
| 181 | const { NativeView, onRefresh } = props; | |
| 182 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); | |
| 183 | const { themeAppearance, themeId } = useAppearancePreferences(); | |
| 184 | const appTheme = useUniwindTheme(); | |
| 185 | const { width: viewportWidth } = useWindowDimensions(); | |
| 186 | const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); | |
| 187 | const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); | |
| 172 | 188 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); |
| 173 | 189 | const selectedRowIdsJson = useMemo( |
| 174 | 190 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), |
| ... | ... | @@ -212,9 +228,18 @@ function NativeSourceFileSurface( |
| 212 | 228 | } |
| 213 | 229 | |
| 214 | 230 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 231 | const foreground = useUniwindTheme()["--color-foreground"]; | |
| 215 | 232 | const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); |
| 216 | const { lines, status, targetIndex, tokens } = useSourceFileModel(props); | |
| 233 | const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); | |
| 234 | const selectableTokens = useMemo( | |
| 235 | () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null), | |
| 236 | [props.selectable, tokens], | |
| 237 | ); | |
| 217 | 238 | const listRef = useRef<FlatList<string>>(null); |
| 239 | const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); | |
| 240 | const refreshControl = props.onRefresh ? ( | |
| 241 | <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullToRefresh()} /> | |
| 242 | ) : undefined; | |
| 218 | 243 | |
| 219 | 244 | useEffect(() => { |
| 220 | 245 | if (targetIndex === null) { |
| ... | ... | @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 240 | 265 | [codeSurface, codeWordBreak, targetIndex, tokens], |
| 241 | 266 | ); |
| 242 | 267 | |
| 268 | // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, | |
| 269 | // which selects across every line, wraps, and lays out long documents through TextKit; on | |
| 270 | // Android the primitive is an RN `Text`, which selects across its nested children. Either | |
| 271 | // way "select all" takes the file rather than a line, which a `FlatList` row can never do | |
| 272 | // because each row is its own selection scope. | |
| 273 | const selectableBlock = props.selectable ? ( | |
| 274 | <MarkdownTextPrimitive | |
| 275 | uiTextView | |
| 276 | selectable | |
| 277 | style={{ | |
| 278 | color: foreground, | |
| 279 | fontFamily: REVIEW_MONO_FONT_FAMILY, | |
| 280 | fontSize: codeSurface.fontSize, | |
| 281 | lineHeight: codeSurface.rowHeight, | |
| 282 | }} | |
| 283 | > | |
| 284 | {selectableTokens | |
| 285 | ? lines.map((line, index) => { | |
| 286 | const lineTokens = selectableTokens[index] ?? null; | |
| 287 | const body = | |
| 288 | lineTokens && lineTokens.length > 0 | |
| 289 | ? lineTokens.map((token, tokenIndex) => ( | |
| 290 | <MarkdownTextPrimitive | |
| 291 | key={`${index}:${tokenIndex}`} | |
| 292 | style={{ | |
| 293 | color: token.color ?? foreground, | |
| 294 | fontWeight: | |
| 295 | token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", | |
| 296 | fontStyle: | |
| 297 | token.fontStyle !== null && (token.fontStyle & 1) === 1 | |
| 298 | ? "italic" | |
| 299 | : "normal", | |
| 300 | }} | |
| 301 | > | |
| 302 | {token.content} | |
| 303 | </MarkdownTextPrimitive> | |
| 304 | )) | |
| 305 | : line; | |
| 306 | return ( | |
| 307 | <MarkdownTextPrimitive key={index}> | |
| 308 | {body} | |
| 309 | {index < lines.length - 1 ? "\n" : ""} | |
| 310 | </MarkdownTextPrimitive> | |
| 311 | ); | |
| 312 | }) | |
| 313 | : normalizedContents} | |
| 314 | </MarkdownTextPrimitive> | |
| 315 | ) : null; | |
| 316 | ||
| 243 | 317 | const list = ( |
| 244 | 318 | <FlatList |
| 245 | 319 | ref={listRef} |
| 320 | refreshControl={refreshControl} | |
| 246 | 321 | data={lines} |
| 247 | 322 | keyExtractor={(_line, index) => String(index)} |
| 248 | 323 | initialNumToRender={80} |
| ... | ... | @@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 266 | 341 | /> |
| 267 | 342 | ); |
| 268 | 343 | |
| 344 | // Workspace files retain their numbered, virtualized rows, with or without a line target. | |
| 345 | // Attachments opt into one selection scope for the entire document. | |
| 346 | const usesLineList = !props.selectable; | |
| 347 | const padded = ( | |
| 348 | <ScrollView | |
| 349 | refreshControl={refreshControl} | |
| 350 | className="flex-1" | |
| 351 | contentContainerStyle={{ | |
| 352 | paddingBottom: codeSurface.rowHeight, | |
| 353 | paddingHorizontal: 12, | |
| 354 | paddingTop: 8, | |
| 355 | }} | |
| 356 | > | |
| 357 | {selectableBlock} | |
| 358 | </ScrollView> | |
| 359 | ); | |
| 360 | ||
| 269 | 361 | return ( |
| 270 | 362 | <View className="relative flex-1 bg-sheet"> |
| 271 | 363 | <SourceHighlightStatusView status={status} /> |
| 272 | {codeWordBreak ? ( | |
| 273 | list | |
| 364 | {usesLineList ? ( | |
| 365 | codeWordBreak ? ( | |
| 366 | list | |
| 367 | ) : ( | |
| 368 | <ScrollView horizontal bounces={false} className="flex-1"> | |
| 369 | {list} | |
| 370 | </ScrollView> | |
| 371 | ) | |
| 372 | ) : codeWordBreak ? ( | |
| 373 | padded | |
| 274 | 374 | ) : ( |
| 375 | // Without wrapping the text keeps its natural width and the reader pans to it. | |
| 275 | 376 | <ScrollView horizontal bounces={false} className="flex-1"> |
| 276 | {list} | |
| 377 | {padded} | |
| 277 | 378 | </ScrollView> |
| 278 | 379 | )} |
| 279 | 380 | </View> |
| ... | ... | @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 282 | 383 | |
| 283 | 384 | export function SourceFileSurface(props: SourceFileSurfaceProps) { |
| 284 | 385 | const NativeView = resolveNativeReviewDiffView(); |
| 285 | return NativeView ? ( | |
| 386 | const { codeWordBreak } = useAppearanceCodeSurface(); | |
| 387 | // The native canvas draws source lines without text selection or wrapping. Attachments | |
| 388 | // need one selectable text view in either wrap mode; workspace line navigation can still | |
| 389 | // use the canvas when wrapping is disabled. | |
| 390 | return NativeView && !codeWordBreak && !props.selectable ? ( | |
| 286 | 391 | <NativeSourceFileSurface {...props} NativeView={NativeView} /> |
| 287 | 392 | ) : ( |
| 288 | 393 | <JavaScriptSourceFileSurface {...props} /> |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+26| ... | ... | @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 583 | 583 | useAdaptiveWorkspacePaneRole("inspector"); |
| 584 | 584 | const navigation = useNavigation(); |
| 585 | 585 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 586 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | |
| 586 | 587 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 587 | 588 | const isAndroid = Platform.OS === "android"; |
| 588 | 589 | const params = props.route.params; |
| ... | ... | @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 763 | 764 | onPress: () => setModeOverride({ path: relativePath, mode: "source" }), |
| 764 | 765 | } as const) |
| 765 | 766 | : null, |
| 767 | // Only the source body wraps; a rendered preview lays itself out. | |
| 768 | resolvedActiveMode === "source" | |
| 769 | ? ({ | |
| 770 | id: "word-wrap", | |
| 771 | title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", | |
| 772 | icon: "text.alignleft", | |
| 773 | inline: false, | |
| 774 | onPress: () => setCodeWordBreak(!appearance.codeWordBreak), | |
| 775 | } as const) | |
| 776 | : null, | |
| 766 | 777 | ...(mediaSource |
| 767 | 778 | ? mediaActions.actions |
| 768 | 779 | .filter(({ id }) => id !== "open-file") |
| ... | ... | @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 783 | 794 | onPress: () => copyTextWithHaptic(relativePath), |
| 784 | 795 | } as const, |
| 785 | 796 | ]), |
| 797 | // Selecting a long file by hand is painful on a phone, so copying the whole thing is | |
| 798 | // the action most readers actually want. The attachment screen already offers it. | |
| 799 | fileData?.contents != null | |
| 800 | ? ({ | |
| 801 | id: "copy-contents", | |
| 802 | title: fileData.truncated ? "Copy preview" : "Copy contents", | |
| 803 | icon: "doc.on.doc", | |
| 804 | inline: false, | |
| 805 | onPress: () => copyTextWithHaptic(fileData.contents), | |
| 806 | } as const) | |
| 807 | : null, | |
| 786 | 808 | isPdfFile({ name: relativePath }) && previewUri !== null |
| 787 | 809 | ? ({ |
| 788 | 810 | id: "open-pdf", |
| ... | ... | @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 821 | 843 | : null, |
| 822 | 844 | ].filter((action) => action !== null); |
| 823 | 845 | }, [ |
| 846 | appearance.codeWordBreak, | |
| 847 | setCodeWordBreak, | |
| 824 | 848 | assetPreviewUri, |
| 825 | 849 | assetPreview.refresh, |
| 826 | 850 | previewUri, |
| ... | ... | @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 833 | 857 | resolvedActiveMode, |
| 834 | 858 | mediaSource, |
| 835 | 859 | mediaActions.actions, |
| 860 | fileData?.contents, | |
| 861 | fileData?.truncated, | |
| 836 | 862 | ]); |
| 837 | 863 | |
| 838 | 864 | const androidFileMenuActions = useMemo<MenuAction[]>( |
apps/mobile/src/features/files/source-file-document.test.ts+12-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { prepareSourceFileDocument } from "./source-file-document"; | |
| 3 | import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; | |
| 4 | 4 | |
| 5 | 5 | describe("prepareSourceFileDocument", () => { |
| 6 | 6 | it("normalizes and serializes source rows once for repeated consumers", () => { |
| ... | ... | @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { |
| 14 | 14 | expect(second).toBe(first); |
| 15 | 15 | }); |
| 16 | 16 | }); |
| 17 | ||
| 18 | it("bounds selectable highlighting without allocating spans for huge files", () => { | |
| 19 | const token = { content: "text", color: "#fff", fontStyle: null }; | |
| 20 | const small = [[token]]; | |
| 21 | expect(boundedSelectableSourceTokens(small)).toBe(small); | |
| 22 | expect(boundedSelectableSourceTokens(null)).toBeNull(); | |
| 23 | expect(boundedSelectableSourceTokens(Array.from({ length: 20_000 }, () => [token]))).toBeNull(); | |
| 24 | expect(boundedSelectableSourceTokens([Array.from({ length: 2_000 }, () => token)])).toBeNull(); | |
| 25 | const longPlainText = "full contents\n".repeat(50_000); | |
| 26 | expect(prepareSourceFileDocument(longPlainText).contents).toBe(longPlainText); | |
| 27 | }); |
apps/mobile/src/features/files/source-file-document.ts+15| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; | |
| 1 | 2 | import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; |
| 2 | 3 | |
| 3 | 4 | const MAX_CACHED_DOCUMENTS = 8; |
| ... | ... | @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument |
| 52 | 53 | |
| 53 | 54 | return document; |
| 54 | 55 | } |
| 56 | ||
| 57 | // A selectable document cannot virtualize its rows. Cap React spans instead; large | |
| 58 | // attachments remain fully selectable as one plain string, including every newline. | |
| 59 | export function boundedSelectableSourceTokens( | |
| 60 | tokens: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> | null, | |
| 61 | ): typeof tokens { | |
| 62 | if (!tokens) return null; | |
| 63 | let spans = tokens.length; | |
| 64 | for (const line of tokens) { | |
| 65 | spans += line.length; | |
| 66 | if (spans > 2_000) return null; | |
| 67 | } | |
| 68 | return tokens; | |
| 69 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+153-26| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | |
| 3 | import { | |
| 4 | nextPastedTextFileName, | |
| 5 | pastedTextDisposition, | |
| 6 | replaceTextSelection, | |
| 7 | } from "@t3tools/client-runtime/text-paste"; | |
| 2 | 8 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | 9 | import { |
| 4 | 10 | CommonActions, |
| ... | ... | @@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; |
| 22 | 28 | |
| 23 | 29 | import { |
| 24 | 30 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 31 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 25 | 32 | resolveEnvironmentMachineKind, |
| 26 | 33 | type EnvironmentId, |
| 27 | 34 | } from "@t3tools/contracts"; |
| 28 | 35 | |
| 29 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | |
| 36 | import { | |
| 37 | ComposerEditor, | |
| 38 | type ComposerEditorHandle, | |
| 39 | type ComposerTextPaste, | |
| 40 | } from "../../components/ComposerEditor"; | |
| 30 | 41 | import { composerContextImportsAtom } from "../../state/use-composer-drafts"; |
| 31 | 42 | import { |
| 32 | 43 | composerContextSendBlockReason, |
| ... | ... | @@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 41 | 52 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 42 | 53 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 43 | 54 | import { composerStripAttachments } from "../../lib/composerImages"; |
| 44 | import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; | |
| 45 | 55 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 46 | 56 | import { |
| 47 | 57 | composerAttachmentUploadBlockReason, |
| ... | ... | @@ -73,13 +83,17 @@ import { |
| 73 | 83 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 74 | 84 | import { |
| 75 | 85 | convertPastedImagesToAttachments, |
| 86 | createPastedTextComposerAttachment, | |
| 76 | 87 | pickComposerFiles, |
| 77 | 88 | pickComposerMedia, |
| 89 | removePersistedComposerAttachmentFile, | |
| 78 | 90 | type DraftComposerFileAttachment, |
| 79 | 91 | } from "../../lib/composerImages"; |
| 80 | 92 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 81 | 93 | import { |
| 82 | 94 | clearComposerDraftContent, |
| 95 | captureComposerDraftInsertion, | |
| 96 | countComposerDraftAttachmentsAfterSelection, | |
| 83 | 97 | getComposerDraftSnapshot, |
| 84 | 98 | mergeComposerDraftContent, |
| 85 | 99 | restoreComposerDraftSnapshot, |
| ... | ... | @@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: { |
| 297 | 311 | const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>()); |
| 298 | 312 | const activeShareImportTokenRef = useRef<symbol | null>(null); |
| 299 | 313 | const shareImportMountedRef = useRef(true); |
| 314 | const pendingPastedTextAttachmentCountRef = useRef(0); | |
| 315 | const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); | |
| 316 | const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set<string> }>({ | |
| 317 | draftKey: null, | |
| 318 | names: new Set(), | |
| 319 | }); | |
| 300 | 320 | const latestDraftKeyRef = useRef(flow.draftKey); |
| 301 | 321 | const latestIncomingShareIdRef = useRef(props.incomingShareId); |
| 302 | 322 | latestDraftKeyRef.current = flow.draftKey; |
| ... | ... | @@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: { |
| 344 | 364 | : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; |
| 345 | 365 | // Media needs its thumbnail; every other file already reads as its inline chip. |
| 346 | 366 | const stripAttachments = useMemo( |
| 347 | () => | |
| 348 | composerStripAttachments( | |
| 349 | flow.attachments, | |
| 350 | new Set( | |
| 351 | collectComposerContextReferences(flow.prompt).map( | |
| 352 | (occurrence) => occurrence.contextId as string, | |
| 353 | ), | |
| 354 | ), | |
| 355 | ), | |
| 356 | [flow.attachments, flow.prompt], | |
| 367 | () => composerStripAttachments(flow.attachments), | |
| 368 | [flow.attachments], | |
| 357 | 369 | ); |
| 358 | 370 | const composerMenu = useComposerCommandMenu({ |
| 359 | 371 | draftMessage: flow.prompt, |
| ... | ... | @@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: { |
| 903 | 915 | return; |
| 904 | 916 | } |
| 905 | 917 | const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; |
| 918 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | |
| 906 | 919 | const result = await pickComposerMedia({ |
| 907 | existingCount: flow.attachments.length, | |
| 920 | existingCount: | |
| 921 | flow.draftKey && insertion | |
| 922 | ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) | |
| 923 | : flow.attachments.length, | |
| 908 | 924 | maxVideoBytes: |
| 909 | 925 | capabilities?.attachmentUploads === true |
| 910 | 926 | ? capabilities.fileAttachments?.maxUploadBytes |
| 911 | 927 | : undefined, |
| 912 | 928 | }); |
| 913 | 929 | const rejectedCount = |
| 914 | result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0; | |
| 930 | result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0; | |
| 915 | 931 | const problems = [ |
| 916 | 932 | ...(result.error ? [result.error] : []), |
| 917 | 933 | ...(rejectedCount > 0 |
| ... | ... | @@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: { |
| 933 | 949 | Alert.alert("File attachments are not available on this server."); |
| 934 | 950 | return; |
| 935 | 951 | } |
| 952 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | |
| 936 | 953 | const result = await pickComposerFiles({ |
| 937 | existingCount: flow.attachments.length, | |
| 954 | existingCount: | |
| 955 | flow.draftKey && insertion | |
| 956 | ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) | |
| 957 | : flow.attachments.length, | |
| 938 | 958 | maxBytes, |
| 939 | 959 | }); |
| 940 | const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; | |
| 960 | const rejectedCount = | |
| 961 | result.files.length > 0 ? flow.appendAttachments(result.files, insertion) : 0; | |
| 941 | 962 | // The picker error and the live-cap rejection can both happen in one |
| 942 | 963 | // pick; report both in a single alert. |
| 943 | 964 | const problems = [ |
| ... | ... | @@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: { |
| 954 | 975 | const handleNativePasteImages = useCallback( |
| 955 | 976 | async (uris: ReadonlyArray<string>) => { |
| 956 | 977 | try { |
| 978 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | |
| 957 | 979 | const images = await convertPastedImagesToAttachments({ |
| 958 | 980 | uris, |
| 959 | existingCount: flow.attachments.length, | |
| 981 | existingCount: | |
| 982 | flow.draftKey && insertion | |
| 983 | ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) | |
| 984 | : flow.attachments.length, | |
| 960 | 985 | }); |
| 961 | 986 | if (images.length > 0) { |
| 962 | flow.appendAttachments(images); | |
| 987 | flow.appendAttachments(images, insertion); | |
| 963 | 988 | } |
| 964 | 989 | } catch (error) { |
| 965 | 990 | console.error("[native paste] error converting images", error); |
| ... | ... | @@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: { |
| 968 | 993 | [flow], |
| 969 | 994 | ); |
| 970 | 995 | |
| 996 | const handleNativePasteText = useCallback( | |
| 997 | async (paste: ComposerTextPaste) => { | |
| 998 | const draftKey = flow.draftKey; | |
| 999 | if (!draftKey) return; | |
| 1000 | const insertion = { text: paste.value, ...paste.selection }; | |
| 1001 | const insertPaste = () => { | |
| 1002 | const insertion = replaceTextSelection({ | |
| 1003 | value: paste.value, | |
| 1004 | selection: paste.selection, | |
| 1005 | text: paste.text, | |
| 1006 | }); | |
| 1007 | const selection = { start: insertion.cursor, end: insertion.cursor }; | |
| 1008 | flow.setPrompt(insertion.value); | |
| 1009 | composerMenu.onSelectionChange(selection); | |
| 1010 | }; | |
| 1011 | const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; | |
| 1012 | const advertisedMax = | |
| 1013 | capabilities?.attachmentUploads === true | |
| 1014 | ? capabilities.fileAttachments?.maxUploadBytes | |
| 1015 | : undefined; | |
| 1016 | const maxBytes = | |
| 1017 | advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); | |
| 1018 | const wouldExceedInputLimit = | |
| 1019 | paste.value.length - | |
| 1020 | Math.max(0, paste.selection.end - paste.selection.start) + | |
| 1021 | paste.text.length > | |
| 1022 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS; | |
| 1023 | const canAttach = | |
| 1024 | maxBytes !== null && | |
| 1025 | countComposerDraftAttachmentsAfterSelection(draftKey, insertion) < | |
| 1026 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS && | |
| 1027 | new TextEncoder().encode(paste.text).byteLength <= maxBytes; | |
| 1028 | if ( | |
| 1029 | pastedTextDisposition({ | |
| 1030 | text: paste.text, | |
| 1031 | wouldExceedInputLimit, | |
| 1032 | canAttach: true, | |
| 1033 | }) === "attachment" | |
| 1034 | ) { | |
| 1035 | if (canAttach && maxBytes !== null) { | |
| 1036 | pendingPastedTextAttachmentCountRef.current += 1; | |
| 1037 | setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); | |
| 1038 | try { | |
| 1039 | if (pastedTextFileNamesRef.current.draftKey !== draftKey) { | |
| 1040 | pastedTextFileNamesRef.current = { draftKey, names: new Set() }; | |
| 1041 | } | |
| 1042 | const reservedNames = pastedTextFileNamesRef.current.names; | |
| 1043 | for (const attachment of flow.attachments) reservedNames.add(attachment.name); | |
| 1044 | const name = nextPastedTextFileName([...reservedNames]); | |
| 1045 | reservedNames.add(name); | |
| 1046 | const attachment = await createPastedTextComposerAttachment({ | |
| 1047 | text: paste.text, | |
| 1048 | name, | |
| 1049 | maxBytes, | |
| 1050 | }); | |
| 1051 | if (latestDraftKeyRef.current !== draftKey) { | |
| 1052 | await removePersistedComposerAttachmentFile(attachment.fileUri); | |
| 1053 | return; | |
| 1054 | } | |
| 1055 | if (flow.appendAttachments([attachment], insertion) > 0) { | |
| 1056 | await removePersistedComposerAttachmentFile(attachment.fileUri); | |
| 1057 | Alert.alert( | |
| 1058 | "Could not attach pasted text", | |
| 1059 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, | |
| 1060 | ); | |
| 1061 | } | |
| 1062 | } catch (error) { | |
| 1063 | Alert.alert( | |
| 1064 | "Could not attach pasted text", | |
| 1065 | error instanceof Error ? error.message : "Try again.", | |
| 1066 | ); | |
| 1067 | } finally { | |
| 1068 | pendingPastedTextAttachmentCountRef.current = Math.max( | |
| 1069 | 0, | |
| 1070 | pendingPastedTextAttachmentCountRef.current - 1, | |
| 1071 | ); | |
| 1072 | setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); | |
| 1073 | } | |
| 1074 | } else if (!wouldExceedInputLimit) { | |
| 1075 | insertPaste(); | |
| 1076 | } else { | |
| 1077 | Alert.alert( | |
| 1078 | wouldExceedInputLimit | |
| 1079 | ? "Pasted text is too large for this message" | |
| 1080 | : "Could not attach pasted text", | |
| 1081 | wouldExceedInputLimit | |
| 1082 | ? "Remove some text or an attachment, then paste again." | |
| 1083 | : "Remove an attachment or use a smaller paste, then try again.", | |
| 1084 | ); | |
| 1085 | } | |
| 1086 | return; | |
| 1087 | } | |
| 1088 | ||
| 1089 | insertPaste(); | |
| 1090 | }, | |
| 1091 | [composerMenu, flow, selectedEnvironmentServerConfig], | |
| 1092 | ); | |
| 1093 | ||
| 971 | 1094 | async function handleStart(): Promise<void> { |
| 972 | if (voiceInput.blocksSubmission) return; | |
| 1095 | if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; | |
| 973 | 1096 | const selectedProject = flow.selectedProject; |
| 974 | 1097 | const draftKey = flow.draftKey; |
| 975 | 1098 | if (!selectedProject || !draftKey) { |
| ... | ... | @@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: { |
| 1139 | 1262 | isIncomingShareReady && |
| 1140 | 1263 | !isImportingShare && |
| 1141 | 1264 | !flow.submitting && |
| 1265 | pendingPastedTextAttachmentCount === 0 && | |
| 1142 | 1266 | !voiceInput.blocksSubmission && |
| 1143 | 1267 | !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); |
| 1144 | 1268 | const openDraftDocument = (attachment: ComposerDocumentAttachment) => { |
| ... | ... | @@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: { |
| 1195 | 1319 | onFocus={() => setIsComposerFocused(true)} |
| 1196 | 1320 | onBlur={() => setIsComposerFocused(false)} |
| 1197 | 1321 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 1322 | onPasteText={(paste) => void handleNativePasteText(paste)} | |
| 1198 | 1323 | placeholder="Ask anything…" |
| 1199 | 1324 | singleLineCentered={false} |
| 1200 | 1325 | contentInsetVertical={0} |
| ... | ... | @@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: { |
| 1486 | 1611 | <ComposerActionButton |
| 1487 | 1612 | accessibilityLabel={ |
| 1488 | 1613 | attachmentBlockReason ?? |
| 1489 | (flow.submitting | |
| 1490 | ? "Starting task" | |
| 1491 | : attachmentsUploading | |
| 1492 | ? "Queue task, sends when uploads finish" | |
| 1493 | : environmentConnected | |
| 1494 | ? "Start task" | |
| 1495 | : "Queue task") | |
| 1614 | (pendingPastedTextAttachmentCount > 0 | |
| 1615 | ? "Attaching pasted text" | |
| 1616 | : flow.submitting | |
| 1617 | ? "Starting task" | |
| 1618 | : attachmentsUploading | |
| 1619 | ? "Queue task, sends when uploads finish" | |
| 1620 | : environmentConnected | |
| 1621 | ? "Start task" | |
| 1622 | : "Queue task") | |
| 1496 | 1623 | } |
| 1497 | 1624 | disabled={!canStart} |
| 1498 | 1625 | icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} |
apps/mobile/src/features/threads/ThreadComposer.tsx+103-25| ... | ... | @@ -1,14 +1,19 @@ |
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | |
| 1 | 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import type { | |
| 4 | EnvironmentId, | |
| 5 | MessageId, | |
| 6 | ModelSelection, | |
| 7 | OrchestrationThreadShell, | |
| 8 | ProviderInteractionMode, | |
| 9 | RuntimeMode, | |
| 10 | ServerConfig as T3ServerConfig, | |
| 11 | UsageLimitsReport, | |
| 4 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | |
| 5 | import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; | |
| 6 | import { | |
| 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 9 | type EnvironmentId, | |
| 10 | type MessageId, | |
| 11 | type ModelSelection, | |
| 12 | type OrchestrationThreadShell, | |
| 13 | type ProviderInteractionMode, | |
| 14 | type RuntimeMode, | |
| 15 | type ServerConfig as T3ServerConfig, | |
| 16 | type UsageLimitsReport, | |
| 12 | 17 | } from "@t3tools/contracts"; |
| 13 | 18 | import { |
| 14 | 19 | collectProviderUsageLimits, |
| ... | ... | @@ -46,7 +51,10 @@ import Animated, { |
| 46 | 51 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 47 | 52 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 48 | 53 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 49 | import { composerContextImportsAtom } from "../../state/use-composer-drafts"; | |
| 54 | import { | |
| 55 | composerContextImportsAtom, | |
| 56 | countComposerDraftAttachmentsAfterSelection, | |
| 57 | } from "../../state/use-composer-drafts"; | |
| 50 | 58 | import type { ComposerDocumentAttachment } from "../../lib/composerContext"; |
| 51 | 59 | import { useProject } from "../../state/entities"; |
| 52 | 60 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| ... | ... | @@ -72,7 +80,6 @@ import { |
| 72 | 80 | type DraftComposerAttachment, |
| 73 | 81 | type DraftComposerFileAttachment, |
| 74 | 82 | } from "../../lib/composerImages"; |
| 75 | import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; | |
| 76 | 83 | import { |
| 77 | 84 | buildModelOptions, |
| 78 | 85 | groupByProvider, |
| ... | ... | @@ -135,6 +142,7 @@ export interface ThreadComposerProps { |
| 135 | 142 | readonly onPickDraftMedia: () => Promise<void>; |
| 136 | 143 | readonly onPickDraftFiles: () => Promise<void>; |
| 137 | 144 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 145 | readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>; | |
| 138 | 146 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 139 | 147 | readonly onStopThread: () => void; |
| 140 | 148 | readonly onSendMessage: () => Promise<MessageId | null>; |
| ... | ... | @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 278 | 286 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 279 | 287 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 280 | 288 | const [isFocused, setIsFocused] = useState(false); |
| 289 | const pendingPastedTextAttachmentCountRef = useRef(0); | |
| 290 | const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); | |
| 281 | 291 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 282 | 292 | editorRef: inputRef, |
| 283 | 293 | isEditorFocused: isFocused, |
| ... | ... | @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 291 | 301 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); |
| 292 | 302 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 293 | 303 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; |
| 294 | // Attachment context ids are the attachment id, so the prompt alone says which attachments | |
| 295 | // already read as an inline chip and need no strip tile. | |
| 304 | // Only media belongs above the composer; every other file reads as its inline chip. | |
| 296 | 305 | const stripAttachments = useMemo( |
| 297 | () => | |
| 298 | composerStripAttachments( | |
| 299 | props.draftAttachments, | |
| 300 | new Set( | |
| 301 | collectComposerContextReferences(props.draftMessage).map( | |
| 302 | (occurrence) => occurrence.contextId as string, | |
| 303 | ), | |
| 304 | ), | |
| 305 | ), | |
| 306 | [props.draftAttachments, props.draftMessage], | |
| 306 | () => composerStripAttachments(props.draftAttachments), | |
| 307 | [props.draftAttachments], | |
| 307 | 308 | ); |
| 308 | 309 | const showStopAction = |
| 309 | 310 | !hasContent && |
| ... | ... | @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 422 | 423 | states: uploadStates, |
| 423 | 424 | }); |
| 424 | 425 | const contextImports = useAtomValue(composerContextImportsAtom); |
| 425 | const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; | |
| 426 | const sendBlockedReason = | |
| 427 | props.sendBlockedReason ?? | |
| 428 | (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? | |
| 429 | attachmentBlockReason; | |
| 426 | 430 | const canSend = |
| 427 | 431 | hasContent && |
| 428 | 432 | !contextImports[composerOwnerKey] && |
| ... | ... | @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 478 | 482 | onEditorFocusChange?.(false); |
| 479 | 483 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); |
| 480 | 484 | const handleSend = useCallback(async () => { |
| 485 | if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; | |
| 481 | 486 | // Typed out in full rather than picked from the menu. Attachments mean the |
| 482 | 487 | // user is sending a prompt, so those go through as usual. |
| 483 | 488 | if ( |
| ... | ... | @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 488 | 493 | if (openUsageLimits()) onChangeDraftMessage(""); |
| 489 | 494 | return; |
| 490 | 495 | } |
| 491 | if (voiceInput.blocksSubmission) return; | |
| 492 | 496 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 493 | 497 | if (inFlightThreadIdsRef.current.has(threadKey)) return; |
| 494 | 498 | inFlightThreadIdsRef.current.add(threadKey); |
| ... | ... | @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 736 | 740 | }); |
| 737 | 741 | }} |
| 738 | 742 | onOpenAttachment={openDraftDocument} |
| 743 | // A rested composer full of chips left almost nowhere to tap to start typing: | |
| 744 | // every chip opened its file instead. Collapsed, they focus the editor. | |
| 745 | chipsInert={!isExpanded} | |
| 746 | onInertChipPress={() => inputRef.current?.focus()} | |
| 739 | 747 | ref={inputRef} |
| 740 | 748 | multiline |
| 741 | 749 | value={props.draftMessage} |
| ... | ... | @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 745 | 753 | onChangeText={props.onChangeDraftMessage} |
| 746 | 754 | onSelectionChange={composerMenu.onSelectionChange} |
| 747 | 755 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} |
| 756 | onPasteText={(paste) => { | |
| 757 | const insertPaste = () => { | |
| 758 | const insertion = replaceTextSelection({ | |
| 759 | value: paste.value, | |
| 760 | selection: paste.selection, | |
| 761 | text: paste.text, | |
| 762 | }); | |
| 763 | const selection = { start: insertion.cursor, end: insertion.cursor }; | |
| 764 | props.onChangeDraftMessage(insertion.value); | |
| 765 | composerMenu.onSelectionChange(selection); | |
| 766 | }; | |
| 767 | const capabilities = props.serverConfig?.environment.capabilities; | |
| 768 | const advertisedMax = | |
| 769 | capabilities?.attachmentUploads === true | |
| 770 | ? capabilities.fileAttachments?.maxUploadBytes | |
| 771 | : undefined; | |
| 772 | const maxBytes = | |
| 773 | advertisedMax === undefined | |
| 774 | ? null | |
| 775 | : clampFileAttachmentUploadBytes(advertisedMax); | |
| 776 | const wouldExceedInputLimit = | |
| 777 | paste.value.length - | |
| 778 | Math.max(0, paste.selection.end - paste.selection.start) + | |
| 779 | paste.text.length > | |
| 780 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS; | |
| 781 | const canAttach = | |
| 782 | maxBytes !== null && | |
| 783 | countComposerDraftAttachmentsAfterSelection(composerOwnerKey, { | |
| 784 | text: paste.value, | |
| 785 | ...paste.selection, | |
| 786 | }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && | |
| 787 | new TextEncoder().encode(paste.text).byteLength <= maxBytes; | |
| 788 | if ( | |
| 789 | pastedTextDisposition({ | |
| 790 | text: paste.text, | |
| 791 | wouldExceedInputLimit, | |
| 792 | canAttach: true, | |
| 793 | }) === "attachment" | |
| 794 | ) { | |
| 795 | if (canAttach) { | |
| 796 | pendingPastedTextAttachmentCountRef.current += 1; | |
| 797 | setPendingPastedTextAttachmentCount( | |
| 798 | pendingPastedTextAttachmentCountRef.current, | |
| 799 | ); | |
| 800 | const finishAttachment = () => { | |
| 801 | pendingPastedTextAttachmentCountRef.current = Math.max( | |
| 802 | 0, | |
| 803 | pendingPastedTextAttachmentCountRef.current - 1, | |
| 804 | ); | |
| 805 | setPendingPastedTextAttachmentCount( | |
| 806 | pendingPastedTextAttachmentCountRef.current, | |
| 807 | ); | |
| 808 | }; | |
| 809 | void props.onNativePasteText(paste).then(finishAttachment, finishAttachment); | |
| 810 | } else if (!wouldExceedInputLimit) { | |
| 811 | insertPaste(); | |
| 812 | } else { | |
| 813 | Alert.alert( | |
| 814 | wouldExceedInputLimit | |
| 815 | ? "Pasted text is too large for this message" | |
| 816 | : "Could not attach pasted text", | |
| 817 | wouldExceedInputLimit | |
| 818 | ? "Remove some text or an attachment, then paste again." | |
| 819 | : "Remove an attachment or use a smaller paste, then try again.", | |
| 820 | ); | |
| 821 | } | |
| 822 | return; | |
| 823 | } | |
| 824 | insertPaste(); | |
| 825 | }} | |
| 748 | 826 | placeholder={props.placeholder} |
| 749 | 827 | onFocus={handleFocus} |
| 750 | 828 | onBlur={handleBlur} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | |
| 1 | 2 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | 3 | import { |
| 3 | 4 | appendCodexArtifactTemplateUsePrompt, |
| ... | ... | @@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps { |
| 155 | 156 | readonly onPickDraftMedia: () => Promise<void>; |
| 156 | 157 | readonly onPickDraftFiles: () => Promise<void>; |
| 157 | 158 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 159 | readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>; | |
| 158 | 160 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 159 | 161 | readonly onStopThread: () => void; |
| 160 | 162 | readonly onSendMessage: () => Promise<MessageId | null>; |
| ... | ... | @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1049 | 1051 | onPickDraftMedia={props.onPickDraftMedia} |
| 1050 | 1052 | onPickDraftFiles={props.onPickDraftFiles} |
| 1051 | 1053 | onNativePasteImages={props.onNativePasteImages} |
| 1054 | onNativePasteText={props.onNativePasteText} | |
| 1052 | 1055 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 1053 | 1056 | onStopThread={props.onStopThread} |
| 1054 | 1057 | onSendMessage={handleSendMessage} |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1| ... | ... | @@ -891,6 +891,7 @@ function ThreadRouteContent( |
| 891 | 891 | onPickDraftMedia={composer.onPickDraftMedia} |
| 892 | 892 | onPickDraftFiles={composer.onPickDraftFiles} |
| 893 | 893 | onNativePasteImages={composer.onNativePasteImages} |
| 894 | onNativePasteText={composer.onNativePasteText} | |
| 894 | 895 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 895 | 896 | serverConfig={serverConfig} |
| 896 | 897 | onStopThread={handleStopThread} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+10-2| ... | ... | @@ -44,6 +44,7 @@ import { projectEnvironment } from "../../state/projects"; |
| 44 | 44 | import { useEnvironmentQuery } from "../../state/query"; |
| 45 | 45 | import { |
| 46 | 46 | appendComposerDraftAttachments, |
| 47 | type ComposerDraftInsertion, | |
| 47 | 48 | clearComposerDraft, |
| 48 | 49 | composerDraftsAtom, |
| 49 | 50 | createNewTaskDraft, |
| ... | ... | @@ -203,7 +204,10 @@ type NewTaskFlowContextValue = { |
| 203 | 204 | readonly setPrompt: (value: string) => void; |
| 204 | 205 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void; |
| 205 | 206 | /** Appends draft attachments; returns how many the live cap rejected. */ |
| 206 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => number; | |
| 207 | readonly appendAttachments: ( | |
| 208 | attachments: ReadonlyArray<DraftComposerAttachment>, | |
| 209 | insertion?: ComposerDraftInsertion, | |
| 210 | ) => number; | |
| 207 | 211 | readonly removeAttachment: (imageId: string) => void; |
| 208 | 212 | readonly clearAttachments: () => void; |
| 209 | 213 | readonly setSubmitting: (value: boolean) => void; |
| ... | ... | @@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 600 | 604 | // Returns how many attachments the live cap rejected so the caller can |
| 601 | 605 | // tell the user (a concurrent add can fill the draft mid-pick). |
| 602 | 606 | const appendAttachments = useCallback( |
| 603 | (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => { | |
| 607 | ( | |
| 608 | nextAttachments: ReadonlyArray<DraftComposerAttachment>, | |
| 609 | insertion?: ComposerDraftInsertion, | |
| 610 | ): number => { | |
| 604 | 611 | if (!selectedProjectDraftKey) { |
| 605 | 612 | return 0; |
| 606 | 613 | } |
| 607 | 614 | return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { |
| 608 | 615 | appendReference: true, |
| 616 | insertion, | |
| 609 | 617 | }); |
| 610 | 618 | }, |
| 611 | 619 | [selectedProjectDraftKey], |
apps/mobile/src/lib/appearancePreferences.test.ts+7| ... | ... | @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { |
| 90 | 90 | expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | it("preserves the no-wrap default unless wrapping is explicitly enabled", () => { | |
| 94 | expect(resolveAppearancePreferences(undefined).codeWordBreak).toBe(false); | |
| 95 | expect(resolveAppearancePreferences({}).codeWordBreak).toBe(false); | |
| 96 | expect(resolveAppearancePreferences({ codeWordBreak: null }).codeWordBreak).toBe(false); | |
| 97 | expect(resolveAppearancePreferences({ codeWordBreak: false }).codeWordBreak).toBe(false); | |
| 98 | }); | |
| 99 | ||
| 93 | 100 | it("returns the authored text scale at the 16pt default", () => { |
| 94 | 101 | expect(DEFAULT_BASE_FONT_SIZE).toBe(16); |
| 95 | 102 |
apps/mobile/src/lib/attachmentDocument.ts+8-3| ... | ... | @@ -11,6 +11,7 @@ import type { FileBackedComposerAttachment } from "./composerImages"; |
| 11 | 11 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; |
| 12 | 12 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; |
| 13 | 13 | import { useRefreshAssetUrl } from "../state/assets"; |
| 14 | import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; | |
| 14 | 15 | |
| 15 | 16 | const isLocalUri = (uri: string) => /^(file|content):/.test(uri); |
| 16 | 17 | |
| ... | ... | @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { |
| 32 | 33 | }) { |
| 33 | 34 | const kind = filePreviewKind(input); |
| 34 | 35 | const delimiter = filePreviewDelimiter(input); |
| 35 | const renderedMode = | |
| 36 | kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; | |
| 37 | 36 | const shareController = useRef<AbortController | null>(null); |
| 38 | 37 | useEffect(() => () => shareController.current?.abort(), []); |
| 39 | 38 | const resource = useMemo( |
| ... | ... | @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { |
| 61 | 60 | const [contentError, setContentError] = useState<string | null>(null); |
| 62 | 61 | const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); |
| 63 | 62 | const [rendered, setRendered] = useState(true); |
| 63 | const presentation = attachmentDocumentPresentation({ | |
| 64 | kind, | |
| 65 | hasTable: table !== null, | |
| 66 | hasEnvironment: input.environmentId !== null, | |
| 67 | rendered, | |
| 68 | }); | |
| 64 | 69 | const [revision, setRevision] = useState(0); |
| 65 | 70 | const [sharing, setSharing] = useState(false); |
| 66 | 71 | const uri = input.attachment ? localUri : remoteUri; |
| ... | ... | @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { |
| 185 | 190 | }; |
| 186 | 191 | return { |
| 187 | 192 | kind, |
| 188 | renderedMode, | |
| 193 | ...presentation, | |
| 189 | 194 | uri, |
| 190 | 195 | /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ |
| 191 | 196 | resource, |
apps/mobile/src/lib/attachmentDocumentPresentation.test.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { expect, it } from "vite-plus/test"; | |
| 2 | import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; | |
| 3 | ||
| 4 | it.each([ | |
| 5 | { | |
| 6 | kind: "markdown", | |
| 7 | hasTable: false, | |
| 8 | hasEnvironment: false, | |
| 9 | rendered: true, | |
| 10 | renderedMode: null, | |
| 11 | activeMode: "source", | |
| 12 | }, | |
| 13 | { | |
| 14 | kind: "markdown", | |
| 15 | hasTable: false, | |
| 16 | hasEnvironment: true, | |
| 17 | rendered: true, | |
| 18 | renderedMode: "markdown", | |
| 19 | activeMode: "markdown", | |
| 20 | }, | |
| 21 | { | |
| 22 | kind: "markdown", | |
| 23 | hasTable: false, | |
| 24 | hasEnvironment: true, | |
| 25 | rendered: false, | |
| 26 | renderedMode: "markdown", | |
| 27 | activeMode: "source", | |
| 28 | }, | |
| 29 | { | |
| 30 | kind: "text", | |
| 31 | hasTable: false, | |
| 32 | hasEnvironment: true, | |
| 33 | rendered: true, | |
| 34 | renderedMode: null, | |
| 35 | activeMode: "source", | |
| 36 | }, | |
| 37 | { | |
| 38 | kind: "text", | |
| 39 | hasTable: true, | |
| 40 | hasEnvironment: false, | |
| 41 | rendered: true, | |
| 42 | renderedMode: "table", | |
| 43 | activeMode: "table", | |
| 44 | }, | |
| 45 | { | |
| 46 | kind: "text", | |
| 47 | hasTable: true, | |
| 48 | hasEnvironment: true, | |
| 49 | rendered: false, | |
| 50 | renderedMode: "table", | |
| 51 | activeMode: "source", | |
| 52 | }, | |
| 53 | { | |
| 54 | kind: "html", | |
| 55 | hasTable: false, | |
| 56 | hasEnvironment: false, | |
| 57 | rendered: true, | |
| 58 | renderedMode: "html", | |
| 59 | activeMode: "html", | |
| 60 | }, | |
| 61 | { | |
| 62 | kind: "html", | |
| 63 | hasTable: false, | |
| 64 | hasEnvironment: true, | |
| 65 | rendered: false, | |
| 66 | renderedMode: "html", | |
| 67 | activeMode: "source", | |
| 68 | }, | |
| 69 | ] as const)("matches the available preview for %j", ({ renderedMode, activeMode, ...input }) => { | |
| 70 | expect(attachmentDocumentPresentation(input)).toEqual({ renderedMode, activeMode }); | |
| 71 | }); |
apps/mobile/src/lib/attachmentDocumentPresentation.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import type { FilePreviewKind } from "@t3tools/shared/filePreview"; | |
| 2 | ||
| 3 | /** The available preview and selected body must agree, including source-only draft files. */ | |
| 4 | export function attachmentDocumentPresentation(input: { | |
| 5 | kind: FilePreviewKind; | |
| 6 | hasTable: boolean; | |
| 7 | hasEnvironment: boolean; | |
| 8 | rendered: boolean; | |
| 9 | }) { | |
| 10 | const renderedMode = input.hasTable | |
| 11 | ? "table" | |
| 12 | : input.kind === "markdown" && input.hasEnvironment | |
| 13 | ? "markdown" | |
| 14 | : input.kind === "html" | |
| 15 | ? "html" | |
| 16 | : null; | |
| 17 | return { | |
| 18 | renderedMode, | |
| 19 | activeMode: input.rendered && renderedMode !== null ? renderedMode : "source", | |
| 20 | } as const; | |
| 21 | } |
apps/mobile/src/lib/attachmentUpload.test.ts+10-2| ... | ... | @@ -360,7 +360,14 @@ describe("prepareTurnAttachments", () => { |
| 360 | 360 | }); |
| 361 | 361 | |
| 362 | 362 | it("uploads generic file bytes directly and keeps mixed attachment order", async () => { |
| 363 | const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); | |
| 363 | const pastedFile = { | |
| 364 | ...file, | |
| 365 | source: { _tag: "pasted-text" as const }, | |
| 366 | }; | |
| 367 | const prepared = await prepareTurnAttachments({ | |
| 368 | environmentId, | |
| 369 | attachments: [pastedFile, image], | |
| 370 | }); | |
| 364 | 371 | |
| 365 | 372 | expect(mocks.upload).toHaveBeenCalledWith( |
| 366 | 373 | "file:///documents/report.pdf", |
| ... | ... | @@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => { |
| 379 | 386 | name: "report.pdf", |
| 380 | 387 | mimeType: "application/pdf", |
| 381 | 388 | sizeBytes: 42, |
| 389 | source: { _tag: "pasted-text" }, | |
| 382 | 390 | }); |
| 383 | 391 | expect(prepared.attachments[1]?.type).toBe("image"); |
| 384 | 392 | expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); |
| 385 | 393 | expect(prepared.draftAttachments[0]).toEqual({ |
| 386 | ...file, | |
| 394 | ...pastedFile, | |
| 387 | 395 | uploadedAttachmentId: MINTED_ID, |
| 388 | 396 | uploadEnvironmentId: environmentId, |
| 389 | 397 | }); |
apps/mobile/src/lib/attachmentUpload.ts+5-1| ... | ... | @@ -198,7 +198,11 @@ function uploadedReference( |
| 198 | 198 | // chat view with nothing to show a thumbnail from, on every client. |
| 199 | 199 | return isComposerImageAttachment(attachment) |
| 200 | 200 | ? { type: "image", ...fields } |
| 201 | : { type: "file", ...fields }; | |
| 201 | : { | |
| 202 | type: "file", | |
| 203 | ...fields, | |
| 204 | ...(attachment.source ? { source: attachment.source } : {}), | |
| 205 | }; | |
| 202 | 206 | } |
| 203 | 207 | |
| 204 | 208 | function attachmentUploadInput(attachment: DraftComposerAttachment) { |
apps/mobile/src/lib/composer-image-schema.ts+2-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 5 | 5 | id: Schema.String, |
| ... | ... | @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ |
| 29 | 29 | mimeType: Schema.String, |
| 30 | 30 | sizeBytes: Schema.Number, |
| 31 | 31 | fileUri: Schema.String, |
| 32 | source: Schema.optional(PastedTextAttachmentSource), | |
| 32 | 33 | uploadedAttachmentId: Schema.optional(Schema.String), |
| 33 | 34 | uploadEnvironmentId: Schema.optional(EnvironmentId), |
| 34 | 35 | }); |
apps/mobile/src/lib/composerImages.test.ts+139-11| ... | ... | @@ -1,14 +1,28 @@ |
| 1 | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | const files = new Map<string, { base64: string; deleted: boolean }>(); | |
| 4 | const files = new Map<string, { base64: string; deleted: boolean; text?: string }>(); | |
| 5 | ||
| 6 | const clipboard = vi.hoisted(() => ({ | |
| 7 | hasImageAsync: vi.fn(), | |
| 8 | getImageAsync: vi.fn(), | |
| 9 | hasStringAsync: vi.fn(), | |
| 10 | getStringAsync: vi.fn(), | |
| 11 | })); | |
| 12 | ||
| 13 | vi.mock("expo-clipboard", () => clipboard); | |
| 5 | 14 | |
| 6 | 15 | vi.mock("expo-file-system", () => ({ |
| 7 | 16 | File: class { |
| 8 | 17 | readonly uri: string; |
| 18 | readonly name: string; | |
| 19 | readonly parentDirectory: { readonly uri: string }; | |
| 9 | 20 | |
| 10 | constructor(uri: string) { | |
| 11 | this.uri = uri; | |
| 21 | constructor(parent: string | { readonly uri: string }, name?: string) { | |
| 22 | const parentUri = typeof parent === "string" ? parent : parent.uri; | |
| 23 | this.uri = name ? `${parentUri}/${name}` : parentUri; | |
| 24 | this.name = name ?? this.uri.split("/").at(-1) ?? "file"; | |
| 25 | this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) }; | |
| 12 | 26 | } |
| 13 | 27 | |
| 14 | 28 | get exists(): boolean { |
| ... | ... | @@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({ |
| 29 | 43 | entry.deleted = true; |
| 30 | 44 | } |
| 31 | 45 | } |
| 46 | ||
| 47 | create(): void { | |
| 48 | files.set(this.uri, { base64: "", deleted: false }); | |
| 49 | } | |
| 50 | ||
| 51 | write(text: string): void { | |
| 52 | files.set(this.uri, { base64: "", deleted: false, text }); | |
| 53 | } | |
| 54 | ||
| 55 | moveSync(destination: { readonly uri: string }): void { | |
| 56 | const entry = files.get(this.uri); | |
| 57 | if (!entry) throw new Error("missing staged file"); | |
| 58 | files.set(destination.uri, entry); | |
| 59 | files.delete(this.uri); | |
| 60 | } | |
| 32 | 61 | }, |
| 62 | Directory: class { | |
| 63 | readonly uri: string; | |
| 64 | ||
| 65 | constructor(parent: string, name: string) { | |
| 66 | this.uri = `${parent}/${name}`; | |
| 67 | } | |
| 68 | ||
| 69 | create(): void {} | |
| 70 | }, | |
| 71 | Paths: { document: "file:///documents" }, | |
| 33 | 72 | })); |
| 34 | 73 | |
| 35 | 74 | vi.mock("./uuid", () => ({ |
| 36 | 75 | uuidv4: () => "attachment-id", |
| 37 | 76 | })); |
| 38 | 77 | |
| 39 | import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; | |
| 78 | import { | |
| 79 | convertPastedImagesToAttachments, | |
| 80 | createPastedTextComposerAttachment, | |
| 81 | isOwnedPastedImageUri, | |
| 82 | pasteComposerClipboard, | |
| 83 | } from "./composerImages"; | |
| 84 | ||
| 85 | describe("composer clipboard paste", () => { | |
| 86 | beforeEach(() => { | |
| 87 | vi.clearAllMocks(); | |
| 88 | clipboard.hasImageAsync.mockResolvedValue(false); | |
| 89 | clipboard.hasStringAsync.mockResolvedValue(true); | |
| 90 | clipboard.getStringAsync.mockResolvedValue("clipboard text"); | |
| 91 | clipboard.getImageAsync.mockResolvedValue({ data: "data:image/png;base64,aGVsbG8=" }); | |
| 92 | }); | |
| 93 | ||
| 94 | it("returns only the image when the clipboard contains both image and text", async () => { | |
| 95 | clipboard.hasImageAsync.mockResolvedValue(true); | |
| 96 | const result = await pasteComposerClipboard({ existingCount: 0 }); | |
| 97 | expect(result).toEqual({ | |
| 98 | images: [expect.objectContaining({ type: "image", name: "pasted-image.png" })], | |
| 99 | text: null, | |
| 100 | error: null, | |
| 101 | }); | |
| 102 | expect(clipboard.getStringAsync).not.toHaveBeenCalled(); | |
| 103 | }); | |
| 104 | ||
| 105 | it("does not paste alternate text when the image cannot fit", async () => { | |
| 106 | clipboard.hasImageAsync.mockResolvedValue(true); | |
| 107 | expect( | |
| 108 | await pasteComposerClipboard({ existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }), | |
| 109 | ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") }); | |
| 110 | expect(clipboard.getStringAsync).not.toHaveBeenCalled(); | |
| 111 | }); | |
| 112 | ||
| 113 | it("returns plain text without image chips", async () => { | |
| 114 | expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ | |
| 115 | images: [], | |
| 116 | text: "clipboard text", | |
| 117 | error: null, | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | it("reports an empty text clipboard", async () => { | |
| 122 | clipboard.getStringAsync.mockResolvedValue(""); | |
| 123 | expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ | |
| 124 | images: [], | |
| 125 | text: null, | |
| 126 | error: "Clipboard is empty.", | |
| 127 | }); | |
| 128 | }); | |
| 129 | }); | |
| 40 | 130 | |
| 41 | 131 | describe("native pasted image cleanup", () => { |
| 42 | 132 | beforeEach(() => { |
| ... | ... | @@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => { |
| 91 | 181 | expect(files.get(overflow)?.deleted).toBe(true); |
| 92 | 182 | expect(files.get(userOwned)?.deleted).toBe(false); |
| 93 | 183 | }); |
| 184 | ||
| 185 | it("persists folded text unchanged in the app-owned attachment directory", async () => { | |
| 186 | const text = "first line\nUnicode: 🙂\n"; | |
| 187 | const attachment = await createPastedTextComposerAttachment({ | |
| 188 | text, | |
| 189 | name: "pasted-text.txt", | |
| 190 | maxBytes: 1024, | |
| 191 | }); | |
| 192 | ||
| 193 | expect(attachment).toEqual({ | |
| 194 | id: "attachment-id", | |
| 195 | type: "file", | |
| 196 | name: "pasted-text.txt", | |
| 197 | mimeType: "text/plain;charset=utf-8", | |
| 198 | sizeBytes: new TextEncoder().encode(text).byteLength, | |
| 199 | fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt", | |
| 200 | source: { _tag: "pasted-text" }, | |
| 201 | }); | |
| 202 | expect(files.get(attachment.fileUri)?.text).toBe(text); | |
| 203 | }); | |
| 94 | 204 | }); |
| 95 | 205 | |
| 96 | 206 | describe("composerStripAttachments", () => { |
| ... | ... | @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { |
| 119 | 229 | fileUri: "file:///notes.txt", |
| 120 | 230 | }; |
| 121 | 231 | |
| 122 | it("keeps media even when it already has an inline chip", async () => { | |
| 232 | it("keeps media, because a thumbnail is the only way to see it", async () => { | |
| 123 | 233 | const { composerStripAttachments } = await import("./composerImages"); |
| 124 | const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"])); | |
| 125 | // A thumbnail is the only way to see media, so it stays regardless of the chip. | |
| 234 | const kept = composerStripAttachments([image, video] as never); | |
| 126 | 235 | expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); |
| 127 | 236 | }); |
| 128 | 237 | |
| 129 | it("drops a plain file once its inline chip represents it", async () => { | |
| 238 | it("never shows a non-media file above the composer", async () => { | |
| 130 | 239 | const { composerStripAttachments } = await import("./composerImages"); |
| 131 | expect(composerStripAttachments([doc] as never, new Set(["doc-1"]))).toEqual([]); | |
| 240 | // A document reads as its inline chip. A tile with a generic glyph says less than the | |
| 241 | // chip does, so it is not a fallback worth having, chip present or not. | |
| 242 | expect(composerStripAttachments([doc] as never)).toEqual([]); | |
| 243 | }); | |
| 244 | ||
| 245 | it("keeps media beside a document rather than dropping the whole strip", async () => { | |
| 246 | const { composerStripAttachments } = await import("./composerImages"); | |
| 247 | expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([ | |
| 248 | "img-1", | |
| 249 | "vid-1", | |
| 250 | ]); | |
| 132 | 251 | }); |
| 133 | 252 | |
| 134 | it("keeps a plain file that has no inline chip", async () => { | |
| 253 | it("treats a picture picked through the document picker as media", async () => { | |
| 135 | 254 | const { composerStripAttachments } = await import("./composerImages"); |
| 136 | expect(composerStripAttachments([doc] as never, new Set()).map((a) => a.id)).toEqual(["doc-1"]); | |
| 255 | // The document picker types every pick as a plain file; what it *is* decides the strip. | |
| 256 | const pickedImage = { | |
| 257 | id: "pick-1", | |
| 258 | type: "file" as const, | |
| 259 | name: "photo.png", | |
| 260 | mimeType: "image/png", | |
| 261 | sizeBytes: 30, | |
| 262 | fileUri: "file:///photo.png", | |
| 263 | }; | |
| 264 | expect(composerStripAttachments([pickedImage] as never).map((a) => a.id)).toEqual(["pick-1"]); | |
| 137 | 265 | }); |
| 138 | 266 | }); |
| 139 | 267 |
apps/mobile/src/lib/composerImages.ts+57-16| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 9 | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 10 | 10 | type EnvironmentId, |
| 11 | type PastedTextAttachmentSource, | |
| 11 | 12 | type UploadChatImageAttachment, |
| 12 | 13 | } from "@t3tools/contracts"; |
| 13 | 14 | import type { DocumentPickerResult } from "expo-document-picker"; |
| ... | ... | @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; |
| 21 | 22 | import { videoMimeType } from "@t3tools/shared/video"; |
| 22 | 23 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 23 | 24 | import { uuidv4 } from "./uuid"; |
| 25 | import { writeFileAtomically } from "./atomic-file"; | |
| 24 | 26 | |
| 25 | 27 | export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> { |
| 26 | 28 | readonly id: string; |
| ... | ... | @@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment { |
| 40 | 42 | readonly mimeType: string; |
| 41 | 43 | readonly sizeBytes: number; |
| 42 | 44 | readonly fileUri: string; |
| 45 | readonly source?: PastedTextAttachmentSource; | |
| 43 | 46 | readonly uploadedAttachmentId?: string; |
| 44 | 47 | readonly uploadEnvironmentId?: EnvironmentId; |
| 45 | 48 | } |
| 46 | 49 | |
| 50 | export async function createPastedTextComposerAttachment(input: { | |
| 51 | readonly text: string; | |
| 52 | readonly name: string; | |
| 53 | readonly maxBytes: number; | |
| 54 | }): Promise<DraftComposerFileAttachment> { | |
| 55 | const bytes = new TextEncoder().encode(input.text).byteLength; | |
| 56 | if (bytes <= 0) { | |
| 57 | throw new Error("Clipboard is empty."); | |
| 58 | } | |
| 59 | if (bytes > input.maxBytes) { | |
| 60 | throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); | |
| 61 | } | |
| 62 | ||
| 63 | const { Directory, File, Paths } = await import("expo-file-system"); | |
| 64 | const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY); | |
| 65 | directory.create({ idempotent: true, intermediates: true }); | |
| 66 | const file = new File(directory, `${uuidv4()}-${input.name}`); | |
| 67 | await writeFileAtomically(file, input.text); | |
| 68 | return { | |
| 69 | id: uuidv4(), | |
| 70 | type: "file", | |
| 71 | name: input.name, | |
| 72 | mimeType: "text/plain;charset=utf-8", | |
| 73 | sizeBytes: bytes, | |
| 74 | fileUri: file.uri, | |
| 75 | source: { _tag: "pasted-text" }, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 47 | 79 | export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; |
| 48 | 80 | |
| 49 | 81 | /** |
| 50 | * What the strip above the composer shows. Media previews there because a thumbnail is the | |
| 51 | * only way to see it; every other file is already legible as its inline chip, so it only | |
| 52 | * falls back to the strip when the prompt carries no reference to it. Mirrors web's | |
| 53 | * `composerOtherFilesForPresentation`. | |
| 82 | * What the strip above the composer shows: media, and nothing else. A thumbnail is the only | |
| 83 | * way to see a picture or a video, so those always preview there. Everything else reads as | |
| 84 | * its inline chip, which carries the name, the type and the size in the line of prose the | |
| 85 | * file belongs to — a square tile showing a generic document glyph says strictly less. | |
| 86 | * | |
| 87 | * The chip is not optional for a non-media file. Every path that attaches one also writes | |
| 88 | * its reference, so a file with no chip means the draft lost it rather than that the strip | |
| 89 | * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces | |
| 90 | * whose attachments never get chips (a question answer) pass them to the strip directly | |
| 91 | * instead of through this filter. | |
| 54 | 92 | */ |
| 55 | 93 | export function composerStripAttachments( |
| 56 | 94 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 57 | inlineAttachmentIds: ReadonlySet<string>, | |
| 58 | 95 | ): ReadonlyArray<DraftComposerAttachment> { |
| 59 | 96 | return attachments.filter( |
| 60 | (attachment) => | |
| 61 | isComposerImageAttachment(attachment) || | |
| 62 | videoMimeType(attachment) !== null || | |
| 63 | !inlineAttachmentIds.has(attachment.id), | |
| 97 | (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null, | |
| 64 | 98 | ); |
| 65 | 99 | } |
| 66 | 100 | |
| ... | ... | @@ -539,11 +573,19 @@ export async function pickComposerMedia(input: { |
| 539 | 573 | }; |
| 540 | 574 | } |
| 541 | 575 | |
| 542 | export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ | |
| 543 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; | |
| 544 | readonly text: string | null; | |
| 545 | readonly error: string | null; | |
| 546 | }> { | |
| 576 | /** Clipboard images take priority over their alternate text representation. */ | |
| 577 | export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise< | |
| 578 | | { | |
| 579 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; | |
| 580 | readonly text: null; | |
| 581 | readonly error: string | null; | |
| 582 | } | |
| 583 | | { | |
| 584 | readonly images: readonly []; | |
| 585 | readonly text: string; | |
| 586 | readonly error: null; | |
| 587 | } | |
| 588 | > { | |
| 547 | 589 | let clipboard: Awaited<ReturnType<typeof loadClipboard>>; |
| 548 | 590 | try { |
| 549 | 591 | clipboard = await loadClipboard(); |
| ... | ... | @@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu |
| 605 | 647 | const text = await clipboard.getStringAsync(); |
| 606 | 648 | return { |
| 607 | 649 | images: [], |
| 608 | text: text.length > 0 ? text : null, | |
| 609 | error: text.length > 0 ? null : "Clipboard is empty.", | |
| 650 | ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }), | |
| 610 | 651 | }; |
| 611 | 652 | } |
| 612 | 653 |
apps/mobile/src/native/T3ComposerEditor.ios.tsx+47-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; | |
| 2 | import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; | |
| 1 | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 2 | 4 | import { composerContextEditorTokens } from "../lib/composerContext"; |
| 3 | 5 | import { requireNativeView } from "expo"; |
| ... | ... | @@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ |
| 52 | 54 | readonly uris: ReadonlyArray<string>; |
| 53 | 55 | }>; |
| 54 | 56 | |
| 57 | type NativePasteTextEvent = NativeSyntheticEvent<{ | |
| 58 | readonly value: string; | |
| 59 | readonly eventCount: number; | |
| 60 | readonly text: string; | |
| 61 | readonly selection: ComposerEditorSelection; | |
| 62 | }>; | |
| 63 | ||
| 55 | 64 | interface NativeComposerEditorRef { |
| 56 | 65 | focus: () => Promise<void>; |
| 57 | 66 | blur: () => Promise<void>; |
| ... | ... | @@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { |
| 81 | 90 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, |
| 82 | 91 | ) => void; |
| 83 | 92 | readonly onComposerPasteContext?: ( |
| 84 | event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, | |
| 93 | event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, | |
| 85 | 94 | ) => void; |
| 95 | readonly textPasteThresholdBytes: number; | |
| 96 | readonly maxInputChars: number; | |
| 97 | readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; | |
| 86 | 98 | readonly onComposerFocus?: () => void; |
| 87 | 99 | readonly onComposerBlur?: () => void; |
| 88 | 100 | readonly onComposerSubmit?: () => void; |
| ... | ... | @@ -108,6 +120,7 @@ export function ComposerEditor({ |
| 108 | 120 | onChangeText, |
| 109 | 121 | onSelectionChange, |
| 110 | 122 | onPasteImages, |
| 123 | onPasteText, | |
| 111 | 124 | onFocus, |
| 112 | 125 | onBlur, |
| 113 | 126 | onSubmit, |
| ... | ... | @@ -282,6 +295,8 @@ export function ComposerEditor({ |
| 282 | 295 | autoFocus={props.autoFocus ?? false} |
| 283 | 296 | autoCorrect={props.autoCorrect ?? true} |
| 284 | 297 | spellCheck={props.spellCheck ?? true} |
| 298 | textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} | |
| 299 | maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} | |
| 285 | 300 | style={style as StyleProp<ViewStyle>} |
| 286 | 301 | onComposerChange={(event) => { |
| 287 | 302 | const acknowledgedEventCount = acceptNativeEvent( |
| ... | ... | @@ -315,7 +330,36 @@ export function ComposerEditor({ |
| 315 | 330 | }} |
| 316 | 331 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} |
| 317 | 332 | onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} |
| 318 | onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} | |
| 333 | onComposerPasteContext={(event) => { | |
| 334 | const paste = event.nativeEvent; | |
| 335 | const acknowledgedEventCount = acceptNativeEvent( | |
| 336 | paste.eventCount, | |
| 337 | paste.value, | |
| 338 | paste.selection, | |
| 339 | ); | |
| 340 | if (acknowledgedEventCount === false) return; | |
| 341 | onChangeText(paste.value); | |
| 342 | onSelectionChange?.(paste.selection); | |
| 343 | props.onPasteContext?.(paste); | |
| 344 | setMostRecentEventCount(acknowledgedEventCount); | |
| 345 | forceNativeEventRender((sequence) => sequence + 1); | |
| 346 | }} | |
| 347 | onComposerPasteText={(event) => { | |
| 348 | const paste = event.nativeEvent; | |
| 349 | const acknowledgedEventCount = acceptNativeEvent( | |
| 350 | paste.eventCount, | |
| 351 | paste.value, | |
| 352 | paste.selection, | |
| 353 | ); | |
| 354 | if (acknowledgedEventCount === false) return; | |
| 355 | // Synchronize the draft before an async paste captures its insertion target. | |
| 356 | // React props can still precede the last native keystroke. | |
| 357 | onChangeText(paste.value); | |
| 358 | onSelectionChange?.(paste.selection); | |
| 359 | onPasteText?.(paste); | |
| 360 | setMostRecentEventCount(acknowledgedEventCount); | |
| 361 | forceNativeEventRender((sequence) => sequence + 1); | |
| 362 | }} | |
| 319 | 363 | onComposerFocus={onFocus} |
| 320 | 364 | onComposerBlur={onBlur} |
| 321 | 365 | onComposerSubmit={onSubmit} |
| ... | ... | @@ -327,4 +371,5 @@ export type { |
| 327 | 371 | ComposerEditorHandle, |
| 328 | 372 | ComposerEditorProps, |
| 329 | 373 | ComposerEditorSelection, |
| 374 | ComposerTextPaste, | |
| 330 | 375 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.native.tsx+47-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; | |
| 2 | import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; | |
| 1 | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 2 | 4 | import { composerContextEditorTokens } from "../lib/composerContext"; |
| 3 | 5 | import { requireNativeView } from "expo"; |
| ... | ... | @@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ |
| 55 | 57 | readonly uris: ReadonlyArray<string>; |
| 56 | 58 | }>; |
| 57 | 59 | |
| 60 | type NativePasteTextEvent = NativeSyntheticEvent<{ | |
| 61 | readonly value: string; | |
| 62 | readonly eventCount: number; | |
| 63 | readonly text: string; | |
| 64 | readonly selection: ComposerEditorSelection; | |
| 65 | }>; | |
| 66 | ||
| 58 | 67 | interface NativeComposerEditorRef { |
| 59 | 68 | focus: () => Promise<void>; |
| 60 | 69 | blur: () => Promise<void>; |
| ... | ... | @@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { |
| 85 | 94 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, |
| 86 | 95 | ) => void; |
| 87 | 96 | readonly onComposerPasteContext?: ( |
| 88 | event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, | |
| 97 | event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, | |
| 89 | 98 | ) => void; |
| 99 | readonly textPasteThresholdBytes: number; | |
| 100 | readonly maxInputChars: number; | |
| 101 | readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; | |
| 90 | 102 | readonly onComposerFocus?: () => void; |
| 91 | 103 | readonly onComposerBlur?: () => void; |
| 92 | 104 | } |
| ... | ... | @@ -111,6 +123,7 @@ export function ComposerEditor({ |
| 111 | 123 | onChangeText, |
| 112 | 124 | onSelectionChange, |
| 113 | 125 | onPasteImages, |
| 126 | onPasteText, | |
| 114 | 127 | onFocus, |
| 115 | 128 | onBlur, |
| 116 | 129 | contentInsetVertical = 0, |
| ... | ... | @@ -291,6 +304,8 @@ export function ComposerEditor({ |
| 291 | 304 | autoFocus={props.autoFocus ?? false} |
| 292 | 305 | autoCorrect={props.autoCorrect ?? true} |
| 293 | 306 | spellCheck={props.spellCheck ?? true} |
| 307 | textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} | |
| 308 | maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} | |
| 294 | 309 | style={{ flex: 1, minHeight: 0 }} |
| 295 | 310 | onComposerChange={(event) => { |
| 296 | 311 | const acknowledgedEventCount = acceptNativeEvent( |
| ... | ... | @@ -325,7 +340,36 @@ export function ComposerEditor({ |
| 325 | 340 | }} |
| 326 | 341 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} |
| 327 | 342 | onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} |
| 328 | onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} | |
| 343 | onComposerPasteContext={(event) => { | |
| 344 | const paste = event.nativeEvent; | |
| 345 | const acknowledgedEventCount = acceptNativeEvent( | |
| 346 | paste.eventCount, | |
| 347 | paste.value, | |
| 348 | paste.selection, | |
| 349 | ); | |
| 350 | if (acknowledgedEventCount === false) return; | |
| 351 | onChangeText(paste.value); | |
| 352 | onSelectionChange?.(paste.selection); | |
| 353 | props.onPasteContext?.(paste); | |
| 354 | setMostRecentEventCount(acknowledgedEventCount); | |
| 355 | forceNativeEventRender((sequence) => sequence + 1); | |
| 356 | }} | |
| 357 | onComposerPasteText={(event) => { | |
| 358 | const paste = event.nativeEvent; | |
| 359 | const acknowledgedEventCount = acceptNativeEvent( | |
| 360 | paste.eventCount, | |
| 361 | paste.value, | |
| 362 | paste.selection, | |
| 363 | ); | |
| 364 | if (acknowledgedEventCount === false) return; | |
| 365 | // Synchronize the draft before an async paste captures its insertion target. | |
| 366 | // React props can still precede the last native keystroke. | |
| 367 | onChangeText(paste.value); | |
| 368 | onSelectionChange?.(paste.selection); | |
| 369 | onPasteText?.(paste); | |
| 370 | setMostRecentEventCount(acknowledgedEventCount); | |
| 371 | forceNativeEventRender((sequence) => sequence + 1); | |
| 372 | }} | |
| 329 | 373 | onComposerFocus={onFocus} |
| 330 | 374 | onComposerBlur={onBlur} |
| 331 | 375 | /> |
| ... | ... | @@ -337,4 +381,5 @@ export type { |
| 337 | 381 | ComposerEditorHandle, |
| 338 | 382 | ComposerEditorProps, |
| 339 | 383 | ComposerEditorSelection, |
| 384 | ComposerTextPaste, | |
| 340 | 385 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.tsx+2| ... | ... | @@ -12,6 +12,7 @@ export function ComposerEditor({ |
| 12 | 12 | skills: _skills, |
| 13 | 13 | selection, |
| 14 | 14 | onPasteImages, |
| 15 | onPasteText: _onPasteText, | |
| 15 | 16 | style, |
| 16 | 17 | textStyle, |
| 17 | 18 | contentInsetVertical = 0, |
| ... | ... | @@ -65,4 +66,5 @@ export type { |
| 65 | 66 | ComposerEditorHandle, |
| 66 | 67 | ComposerEditorProps, |
| 67 | 68 | ComposerEditorSelection, |
| 69 | ComposerTextPaste, | |
| 68 | 70 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.types.ts+14-5| ... | ... | @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { |
| 7 | 7 | readonly end: number; |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | export type ComposerTextPaste = { | |
| 11 | readonly value: string; | |
| 12 | readonly eventCount: number; | |
| 13 | readonly text: string; | |
| 14 | readonly selection: ComposerEditorSelection; | |
| 15 | }; | |
| 16 | ||
| 10 | 17 | export interface ComposerEditorHandle { |
| 11 | 18 | focus: () => void; |
| 12 | 19 | blur: () => void; |
| ... | ... | @@ -18,11 +25,12 @@ export interface ComposerEditorProps { |
| 18 | 25 | readonly value: string; |
| 19 | 26 | readonly context?: OrchestrationMessageContext; |
| 20 | 27 | readonly clipboardFragment?: string; |
| 21 | readonly onPasteContext?: (clipboard: { | |
| 22 | readonly text: string; | |
| 23 | readonly fragment: string; | |
| 24 | readonly html: string; | |
| 25 | }) => void; | |
| 28 | readonly onPasteContext?: ( | |
| 29 | clipboard: ComposerTextPaste & { | |
| 30 | readonly fragment: string; | |
| 31 | readonly html: string; | |
| 32 | }, | |
| 33 | ) => void; | |
| 26 | 34 | readonly skills?: ReadonlyArray< |
| 27 | 35 | Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> & |
| 28 | 36 | Partial<Pick<ServerProviderSkill, "path">> |
| ... | ... | @@ -50,6 +58,7 @@ export interface ComposerEditorProps { |
| 50 | 58 | readonly start: number; |
| 51 | 59 | readonly end: number; |
| 52 | 60 | }) => void; |
| 61 | readonly onPasteText?: (paste: ComposerTextPaste) => void; | |
| 53 | 62 | readonly onFocus?: () => void; |
| 54 | 63 | readonly onBlur?: () => void; |
| 55 | 64 | /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ |
apps/mobile/src/native/composerEditorRevision.test.ts+22| ... | ... | @@ -192,3 +192,25 @@ describe("assumeComposerControlledState", () => { |
| 192 | 192 | ); |
| 193 | 193 | }); |
| 194 | 194 | }); |
| 195 | ||
| 196 | describe("typing immediately before an intercepted paste", () => { | |
| 197 | it("keeps the pre-paste React value behind the native paste revision", () => { | |
| 198 | const snapshots = [ | |
| 199 | { eventCount: 0, value: "", selection: { start: 0, end: 0 } }, | |
| 200 | { eventCount: 1, value: "typed", selection: { start: 5, end: 5 } }, | |
| 201 | { eventCount: 2, value: "typed", selection: { start: 0, end: 5 } }, | |
| 202 | ]; | |
| 203 | // Both native platforms stamp the paste with its current value and selection. | |
| 204 | // A render still carrying the typing caret cannot overwrite that selection. | |
| 205 | expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 2, snapshots)).toBe( | |
| 206 | 1, | |
| 207 | ); | |
| 208 | expect(isComposerNativeEcho("typed", { start: 0, end: 5 }, 2, snapshots)).toBe(true); | |
| 209 | // The replacement is a parent edit at the acknowledged paste revision. | |
| 210 | expect(resolveComposerControlledEventCount("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe( | |
| 211 | 2, | |
| 212 | ); | |
| 213 | expect(isComposerNativeEcho("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(false); | |
| 214 | expect(acknowledgeComposerNativeEvent(2, 1)).toBeNull(); | |
| 215 | }); | |
| 216 | }); |
apps/mobile/src/state/use-composer-drafts.test.ts+405-39| ... | ... | @@ -148,10 +148,14 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ |
| 148 | 148 | })); |
| 149 | 149 | |
| 150 | 150 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 151 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | |
| 151 | 152 | import { appAtomRegistry } from "./atom-registry"; |
| 152 | 153 | import { threadOutboxManager } from "./thread-outbox"; |
| 153 | 154 | import { |
| 154 | 155 | appendComposerDraftAttachments, |
| 156 | captureComposerDraftInsertion, | |
| 157 | countComposerDraftAttachmentsAfterSelection, | |
| 158 | getComposerDraftAfterSelection, | |
| 155 | 159 | archiveCloudComposerDrafts, |
| 156 | 160 | clearComposerDraftContent, |
| 157 | 161 | clearComposerDraftContentState, |
| ... | ... | @@ -181,6 +185,7 @@ import { |
| 181 | 185 | retargetNewTaskDraft, |
| 182 | 186 | setComposerDraftText, |
| 183 | 187 | insertComposerDraftContext, |
| 188 | insertComposerDraftText, | |
| 184 | 189 | rememberComposerDraftSelection, |
| 185 | 190 | setComposerDraftAttachmentUpload, |
| 186 | 191 | waitForComposerDraftsLoaded, |
| ... | ... | @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { |
| 233 | 238 | } |
| 234 | 239 | |
| 235 | 240 | describe("mobile composer drafts", () => { |
| 241 | it.each([false, true])( | |
| 242 | "restores visible file chips from legacy drafts (archived: %s)", | |
| 243 | async (archived) => { | |
| 244 | const file = { | |
| 245 | type: "file" as const, | |
| 246 | id: "legacy-file", | |
| 247 | name: "notes.txt", | |
| 248 | mimeType: "text/plain", | |
| 249 | sizeBytes: 10, | |
| 250 | fileUri: "file:///notes.txt", | |
| 251 | }; | |
| 252 | const image = { ...file, id: "photo", name: "photo.png", mimeType: "image/png" }; | |
| 253 | const video = { ...file, id: "video", name: "clip.mp4", mimeType: "video/mp4" }; | |
| 254 | const legacy = { text: "Review these", attachments: [file, image, video] }; | |
| 255 | const document = { | |
| 256 | schemaVersion: 1, | |
| 257 | drafts: archived ? {} : { thread: legacy }, | |
| 258 | ...(archived | |
| 259 | ? { signedOutDrafts: { account: { drafts: { thread: legacy }, queuedMessages: [] } } } | |
| 260 | : {}), | |
| 261 | }; | |
| 262 | const decoded = decodePersistedComposerState(document); | |
| 263 | const restored = archived | |
| 264 | ? decoded.cloudDrafts.signedOut.account?.drafts.thread | |
| 265 | : decoded.drafts.thread; | |
| 266 | expect(restored?.text).toBe("Review these [notes.txt](t3-context://v1/file/legacy-file) "); | |
| 267 | expect(restored?.attachments).toEqual(legacy.attachments); | |
| 268 | expect(restored?.context?.records).toEqual([ | |
| 269 | expect.objectContaining({ kind: "file", attachmentId: file.id }), | |
| 270 | ]); | |
| 271 | expect( | |
| 272 | decodePersistedComposerState({ schemaVersion: 1, drafts: { thread: restored } }).drafts | |
| 273 | .thread, | |
| 274 | ).toEqual(restored); | |
| 275 | appAtomRegistry.set(composerDraftsAtom, { thread: restored! }); | |
| 276 | setComposerDraftText("thread", "Review these"); | |
| 277 | expect(getComposerDraftSnapshot("thread").attachments).toEqual([image, video]); | |
| 278 | await releaseUnusedComposerAttachmentFiles([file]); | |
| 279 | }, | |
| 280 | ); | |
| 281 | ||
| 282 | it("restores a missing file reference without duplicating its record or replacing another record's id", () => { | |
| 283 | const file = { | |
| 284 | type: "file" as const, | |
| 285 | id: "file", | |
| 286 | name: "notes.txt", | |
| 287 | mimeType: "text/plain", | |
| 288 | sizeBytes: 10, | |
| 289 | fileUri: "file:///notes.txt", | |
| 290 | }; | |
| 291 | const existing = { | |
| 292 | version: 1, | |
| 293 | contextId: "original", | |
| 294 | kind: "file", | |
| 295 | label: file.name, | |
| 296 | attachmentId: file.id, | |
| 297 | name: file.name, | |
| 298 | mimeType: file.mimeType, | |
| 299 | sizeBytes: file.sizeBytes, | |
| 300 | }; | |
| 301 | const skill = { version: 1, contextId: "file", kind: "skill", label: "Skill", name: "skill" }; | |
| 302 | for (const records of [[existing, skill], [skill]]) { | |
| 303 | const restored = decodePersistedComposerState({ | |
| 304 | schemaVersion: 1, | |
| 305 | drafts: { | |
| 306 | thread: { text: "", attachments: [file], context: { version: 1, records } }, | |
| 307 | }, | |
| 308 | }).drafts.thread; | |
| 309 | expect(restored?.context?.records).toHaveLength(2); | |
| 310 | expect(restored?.context?.records).toContainEqual(skill); | |
| 311 | expect(restored?.text).toBe( | |
| 312 | `[notes.txt](t3-context://v1/file/${records.length === 2 ? "original" : "file_2"}) `, | |
| 313 | ); | |
| 314 | } | |
| 315 | }); | |
| 236 | 316 | it.each([false, true])( |
| 237 | 317 | "restores deleted file chips and releases undo history (uploaded: %s)", |
| 238 | 318 | async (uploaded) => { |
| ... | ... | @@ -303,6 +383,30 @@ describe("mobile composer drafts", () => { |
| 303 | 383 | expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); |
| 304 | 384 | }); |
| 305 | 385 | |
| 386 | it("gives a folded paste a chip that survives the send", () => { | |
| 387 | const key = "environment-1:thread-1"; | |
| 388 | // What `createPastedTextComposerAttachment` produces for a long paste. | |
| 389 | const pasted = { | |
| 390 | type: "file" as const, | |
| 391 | id: "pasted-1", | |
| 392 | name: "pasted-text.txt", | |
| 393 | mimeType: "text/plain", | |
| 394 | sizeBytes: 40_000, | |
| 395 | fileUri: "file:///pasted-text.txt", | |
| 396 | }; | |
| 397 | appendComposerDraftAttachments(key, [pasted], { appendReference: true }); | |
| 398 | ||
| 399 | const draft = getComposerDraftSnapshot(key); | |
| 400 | // Visible in the composer before sending, not only once the message lands. | |
| 401 | expect(draft.text).toContain("pasted-text.txt"); | |
| 402 | expect(draft.context?.records).toMatchObject([ | |
| 403 | { kind: "file", attachmentId: pasted.id, name: pasted.name }, | |
| 404 | ]); | |
| 405 | // The reference points at the record, so the chip stays a chip in the sent message. | |
| 406 | const [record] = draft.context?.records ?? []; | |
| 407 | expect(draft.text).toContain(String(record?.contextId)); | |
| 408 | }); | |
| 409 | ||
| 306 | 410 | it("drops chips and records for attachments a replace no longer keeps", () => { |
| 307 | 411 | const key = "new-task:draft-1"; |
| 308 | 412 | const kept = { |
| ... | ... | @@ -426,41 +530,57 @@ describe("mobile composer drafts", () => { |
| 426 | 530 | expect(reloaded.cloudDrafts.signedOut).toEqual({}); |
| 427 | 531 | }); |
| 428 | 532 | |
| 429 | it("removes a file only after its last reference is deleted, while retaining images", async () => { | |
| 430 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 431 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 432 | const cleanup = Promise.withResolvers<void>(); | |
| 433 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 434 | cleanup.resolve(); | |
| 435 | }); | |
| 436 | const key = "environment-1:remove-context-files"; | |
| 437 | const file = { | |
| 438 | id: "file-1", | |
| 439 | type: "file" as const, | |
| 440 | name: "notes.txt", | |
| 441 | mimeType: "text/plain", | |
| 442 | sizeBytes: 4, | |
| 443 | fileUri: "file:///notes.txt", | |
| 444 | }; | |
| 445 | const image = { | |
| 446 | ...file, | |
| 447 | id: "image-1", | |
| 448 | type: "image" as const, | |
| 449 | name: "image.png", | |
| 450 | mimeType: "image/png", | |
| 451 | fileUri: "file:///image.png", | |
| 452 | previewUri: "file:///image.png", | |
| 453 | }; | |
| 454 | appendComposerDraftAttachments(key, [file, image], { appendReference: true }); | |
| 455 | const fileLink = "[notes.txt](t3-context://v1/file/file-1)"; | |
| 456 | setComposerDraftText(key, `${fileLink} ${fileLink}`); | |
| 457 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | |
| 458 | setComposerDraftText(key, fileLink); | |
| 459 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | |
| 460 | setComposerDraftText(key, "plain text"); | |
| 461 | expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); | |
| 462 | await cleanup.promise; | |
| 463 | }); | |
| 533 | it.each([ | |
| 534 | { name: "notes.txt", mimeType: "text/plain" }, | |
| 535 | { name: "document-photo.png", mimeType: "image/png" }, | |
| 536 | { name: "recording.mp4", mimeType: "video/mp4" }, | |
| 537 | ])( | |
| 538 | "removes $name after its last reference is deleted, while retaining native images", | |
| 539 | async ({ name, mimeType }) => { | |
| 540 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 541 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 542 | const cleanup = Promise.withResolvers<void>(); | |
| 543 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 544 | cleanup.resolve(); | |
| 545 | }); | |
| 546 | const key = "environment-1:remove-context-files"; | |
| 547 | const file = { | |
| 548 | id: "file-1", | |
| 549 | type: "file" as const, | |
| 550 | name, | |
| 551 | mimeType, | |
| 552 | sizeBytes: 4, | |
| 553 | fileUri: `file:///${name}`, | |
| 554 | }; | |
| 555 | const image = { | |
| 556 | ...file, | |
| 557 | id: "image-1", | |
| 558 | type: "image" as const, | |
| 559 | name: "image.png", | |
| 560 | mimeType: "image/png", | |
| 561 | fileUri: "file:///image.png", | |
| 562 | previewUri: "file:///image.png", | |
| 563 | }; | |
| 564 | appendComposerDraftAttachments(key, [file, image], { appendReference: true }); | |
| 565 | const fileLink = formatComposerContextReference( | |
| 566 | getComposerDraftSnapshot(key).context!.records[0]!, | |
| 567 | ); | |
| 568 | setComposerDraftText(key, `${fileLink} ${fileLink}`); | |
| 569 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | |
| 570 | setComposerDraftText(key, fileLink); | |
| 571 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | |
| 572 | expect( | |
| 573 | countComposerDraftAttachmentsAfterSelection(key, { | |
| 574 | text: fileLink, | |
| 575 | start: 0, | |
| 576 | end: fileLink.length, | |
| 577 | }), | |
| 578 | ).toBe(1); | |
| 579 | setComposerDraftText(key, "plain text"); | |
| 580 | expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); | |
| 581 | await cleanup.promise; | |
| 582 | }, | |
| 583 | ); | |
| 464 | 584 | |
| 465 | 585 | it("rejects attachments atomically when no context slots remain", async () => { |
| 466 | 586 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | ... | @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { |
| 506 | 626 | expect(getComposerDraftSnapshot(key)).toEqual(before); |
| 507 | 627 | await cleanup.promise; |
| 508 | 628 | }); |
| 629 | it.each(["environment-1:thread", "environment-1:new-task:draft"])( | |
| 630 | "preserves the captured paste selection across async writes in %s", | |
| 631 | async (key) => { | |
| 632 | const file = { | |
| 633 | id: "paste", | |
| 634 | type: "file" as const, | |
| 635 | name: "pasted-text.txt", | |
| 636 | mimeType: "text/plain", | |
| 637 | sizeBytes: 40_000, | |
| 638 | fileUri: "file:///paste.txt", | |
| 639 | }; | |
| 640 | setComposerDraftText(key, "before selected after"); | |
| 641 | const insertion = captureComposerDraftInsertion(key, { start: 7, end: 15 }); | |
| 642 | const write = Promise.withResolvers<typeof file>(); | |
| 643 | const pending = write.promise.then((attachment) => | |
| 644 | appendComposerDraftAttachments(key, [attachment], { appendReference: true, insertion }), | |
| 645 | ); | |
| 646 | rememberComposerDraftSelection(key, insertion.text, { start: 0, end: 6 }); | |
| 647 | rememberComposerDraftSelection("another-draft", "unrelated", { start: 0, end: 9 }); | |
| 648 | write.resolve(file); | |
| 649 | expect(await pending).toBe(0); | |
| 650 | const draft = getComposerDraftSnapshot(key); | |
| 651 | expect(draft.text).toBe("before [pasted-text.txt](t3-context://v1/file/paste) after"); | |
| 652 | expect(draft.attachments).toEqual([file]); | |
| 653 | }, | |
| 654 | ); | |
| 655 | ||
| 656 | it("preserves edits made while a paste is pending instead of deleting stale offsets", async () => { | |
| 657 | const key = "environment-1:typing-during-paste"; | |
| 658 | setComposerDraftText(key, "old selection"); | |
| 659 | const insertion = captureComposerDraftInsertion(key, { start: 0, end: 13 }); | |
| 660 | const write = Promise.withResolvers<void>(); | |
| 661 | const pending = write.promise.then(() => insertComposerDraftText(key, " pasted", insertion)); | |
| 662 | setComposerDraftText(key, "keep newly typed text"); | |
| 663 | write.resolve(); | |
| 664 | await pending; | |
| 665 | expect(getComposerDraftSnapshot(key).text).toBe("keep newly typed text pasted"); | |
| 666 | }); | |
| 667 | ||
| 668 | it.each(["attachment", "context", "imported context"])( | |
| 669 | "releases a selected file when replaced by %s", | |
| 670 | async (kind) => { | |
| 671 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 672 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 673 | const cleanup = Promise.withResolvers<void>(); | |
| 674 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 675 | cleanup.resolve(); | |
| 676 | return undefined; | |
| 677 | }); | |
| 678 | const key = "environment-1:replace-file"; | |
| 679 | const files = Array.from({ length: 8 }, (_, index) => ({ | |
| 680 | id: `file-${index}`, | |
| 681 | type: "file" as const, | |
| 682 | name: `notes-${index}.txt`, | |
| 683 | mimeType: "text/plain", | |
| 684 | sizeBytes: 4, | |
| 685 | fileUri: `file:///notes-${index}.txt`, | |
| 686 | })); | |
| 687 | appendComposerDraftAttachments(key, files, { appendReference: true }); | |
| 688 | const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; | |
| 689 | const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); | |
| 690 | expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); | |
| 691 | expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); | |
| 692 | const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; | |
| 693 | if (kind === "attachment") { | |
| 694 | expect( | |
| 695 | appendComposerDraftAttachments(key, [replacement], { appendReference: true, insertion }), | |
| 696 | ).toBe(0); | |
| 697 | } else if (kind === "imported context") { | |
| 698 | const record = { | |
| 699 | version: 1 as const, | |
| 700 | kind: "file" as const, | |
| 701 | contextId: ComposerContextId.make("replacement"), | |
| 702 | attachmentId: replacement.id, | |
| 703 | label: replacement.name, | |
| 704 | name: replacement.name, | |
| 705 | mimeType: replacement.mimeType, | |
| 706 | sizeBytes: replacement.sizeBytes, | |
| 707 | }; | |
| 708 | expect( | |
| 709 | insertComposerDraftContext( | |
| 710 | key, | |
| 711 | { | |
| 712 | text: formatComposerContextReference(record), | |
| 713 | context: { version: 1, records: [record] }, | |
| 714 | attachments: [replacement], | |
| 715 | }, | |
| 716 | insertion, | |
| 717 | ), | |
| 718 | ).toBe(true); | |
| 719 | expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); | |
| 720 | expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); | |
| 721 | expect(getComposerDraftSnapshot(key).text).toBe( | |
| 722 | `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, | |
| 723 | ); | |
| 724 | } else { | |
| 725 | insertComposerDraftContext( | |
| 726 | key, | |
| 727 | { text: "replacement", context: { version: 1, records: [] } }, | |
| 728 | insertion, | |
| 729 | ); | |
| 730 | } | |
| 731 | const draft = getComposerDraftSnapshot(key); | |
| 732 | expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); | |
| 733 | expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); | |
| 734 | expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); | |
| 735 | await cleanup.promise; | |
| 736 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); | |
| 737 | }, | |
| 738 | ); | |
| 739 | ||
| 740 | it("rejects an imported file atomically when edits during import use up its replacement slot", async () => { | |
| 741 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | |
| 742 | onTestFinished(() => outboxLoad.mockRestore()); | |
| 743 | const cleanup = Promise.withResolvers<void>(); | |
| 744 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | |
| 745 | cleanup.resolve(); | |
| 746 | return undefined; | |
| 747 | }); | |
| 748 | const key = "environment-1:concurrent-import"; | |
| 749 | const files = Array.from({ length: 8 }, (_, index) => ({ | |
| 750 | id: `existing-${index}`, | |
| 751 | type: "file" as const, | |
| 752 | name: `notes-${index}.txt`, | |
| 753 | mimeType: "text/plain", | |
| 754 | sizeBytes: 4, | |
| 755 | fileUri: `file:///notes-${index}.txt`, | |
| 756 | })); | |
| 757 | appendComposerDraftAttachments(key, files, { appendReference: true }); | |
| 758 | const firstLink = "[notes-0.txt](t3-context://v1/file/existing-0)"; | |
| 759 | const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); | |
| 760 | setComposerDraftText(key, `New edit ${insertion.text}`); | |
| 761 | const edited = getComposerDraftSnapshot(key); | |
| 762 | const imported = { ...files[0]!, id: "imported", fileUri: "file:///imported.txt" }; | |
| 763 | const record = { | |
| 764 | version: 1 as const, | |
| 765 | kind: "file" as const, | |
| 766 | contextId: ComposerContextId.make("imported"), | |
| 767 | attachmentId: imported.id, | |
| 768 | label: imported.name, | |
| 769 | name: imported.name, | |
| 770 | mimeType: imported.mimeType, | |
| 771 | sizeBytes: imported.sizeBytes, | |
| 772 | }; | |
| 773 | expect( | |
| 774 | insertComposerDraftContext( | |
| 775 | key, | |
| 776 | { | |
| 777 | text: formatComposerContextReference(record), | |
| 778 | context: { version: 1, records: [record] }, | |
| 779 | attachments: [imported], | |
| 780 | }, | |
| 781 | insertion, | |
| 782 | ), | |
| 783 | ).toBe(false); | |
| 784 | expect(getComposerDraftSnapshot(key)).toEqual(edited); | |
| 785 | await cleanup.promise; | |
| 786 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(imported.fileUri); | |
| 787 | }); | |
| 788 | ||
| 789 | it("retains a file when replacing only one of its repeated references", () => { | |
| 790 | const key = "environment-1:repeat-reference"; | |
| 791 | const file = { | |
| 792 | id: "repeat", | |
| 793 | type: "file" as const, | |
| 794 | name: "notes.txt", | |
| 795 | mimeType: "text/plain", | |
| 796 | sizeBytes: 4, | |
| 797 | fileUri: "file:///repeat.txt", | |
| 798 | }; | |
| 799 | appendComposerDraftAttachments(key, [file], { appendReference: true }); | |
| 800 | const link = getComposerDraftSnapshot(key).text; | |
| 801 | setComposerDraftText(key, `${link} ${link}`); | |
| 802 | const insertion = captureComposerDraftInsertion(key, { start: 0, end: link.length }); | |
| 803 | expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(1); | |
| 804 | insertComposerDraftContext( | |
| 805 | key, | |
| 806 | { text: "replaced", context: { version: 1, records: [] } }, | |
| 807 | insertion, | |
| 808 | ); | |
| 809 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file]); | |
| 810 | expect(getComposerDraftSnapshot(key).text).toBe(`replaced ${link}`); | |
| 811 | }); | |
| 812 | ||
| 509 | 813 | it("inserts context at the saved caret and retains its payload through persistence and restore", () => { |
| 510 | 814 | const draftKey = "context-environment:context-thread"; |
| 511 | 815 | const record = { |
| ... | ... | @@ -564,7 +868,25 @@ describe("mobile composer drafts", () => { |
| 564 | 868 | }, |
| 565 | 869 | }).drafts, |
| 566 | 870 | ).toEqual({ |
| 567 | "environment-1:thread-1": { text: "Review this file", attachments: [file] }, | |
| 871 | "environment-1:thread-1": { | |
| 872 | text: "Review this file [report.pdf](t3-context://v1/file/file-1) ", | |
| 873 | attachments: [file], | |
| 874 | context: { | |
| 875 | version: 1, | |
| 876 | records: [ | |
| 877 | { | |
| 878 | version: 1, | |
| 879 | contextId: file.id, | |
| 880 | kind: "file", | |
| 881 | label: file.name, | |
| 882 | attachmentId: file.id, | |
| 883 | name: file.name, | |
| 884 | mimeType: file.mimeType, | |
| 885 | sizeBytes: file.sizeBytes, | |
| 886 | }, | |
| 887 | ], | |
| 888 | }, | |
| 889 | }, | |
| 568 | 890 | }); |
| 569 | 891 | }); |
| 570 | 892 | |
| ... | ... | @@ -882,8 +1204,34 @@ describe("mobile composer drafts", () => { |
| 882 | 1204 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); |
| 883 | 1205 | onTestFinished(() => enqueue.mockRestore()); |
| 884 | 1206 | await restoreCloudComposerDrafts("account-a"); |
| 885 | expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); | |
| 886 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); | |
| 1207 | expect(getComposerDraftSnapshot(key)).toEqual( | |
| 1208 | type === "image" | |
| 1209 | ? { text: "Unsent notes", attachments: [file] } | |
| 1210 | : { | |
| 1211 | text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ", | |
| 1212 | attachments: [file], | |
| 1213 | context: { | |
| 1214 | version: 1, | |
| 1215 | records: [ | |
| 1216 | { | |
| 1217 | version: 1, | |
| 1218 | contextId: file.id, | |
| 1219 | kind: "file", | |
| 1220 | label: file.name, | |
| 1221 | attachmentId: file.id, | |
| 1222 | name: file.name, | |
| 1223 | mimeType: file.mimeType, | |
| 1224 | sizeBytes: file.sizeBytes, | |
| 1225 | }, | |
| 1226 | ], | |
| 1227 | }, | |
| 1228 | }, | |
| 1229 | ); | |
| 1230 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe( | |
| 1231 | type === "image" | |
| 1232 | ? "Edited queued task" | |
| 1233 | : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ", | |
| 1234 | ); | |
| 887 | 1235 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); |
| 888 | 1236 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); |
| 889 | 1237 | const persisted = decodePersistedComposerState( |
| ... | ... | @@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => { |
| 2314 | 2662 | await fresh.releaseUnusedComposerAttachmentFiles([file]); |
| 2315 | 2663 | |
| 2316 | 2664 | expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ |
| 2317 | "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, | |
| 2665 | "environment-1:thread-1": { | |
| 2666 | text: "Persisted draft [report.pdf](t3-context://v1/file/file-cold-start) ", | |
| 2667 | attachments: [file], | |
| 2668 | context: { | |
| 2669 | version: 1, | |
| 2670 | records: [ | |
| 2671 | { | |
| 2672 | version: 1, | |
| 2673 | contextId: file.id, | |
| 2674 | kind: "file", | |
| 2675 | label: file.name, | |
| 2676 | attachmentId: file.id, | |
| 2677 | name: file.name, | |
| 2678 | mimeType: file.mimeType, | |
| 2679 | sizeBytes: file.sizeBytes, | |
| 2680 | }, | |
| 2681 | ], | |
| 2682 | }, | |
| 2683 | }, | |
| 2318 | 2684 | }); |
| 2319 | 2685 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); |
| 2320 | 2686 | }); |
apps/mobile/src/state/use-composer-drafts.ts+213-51| ... | ... | @@ -24,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity"; |
| 24 | 24 | import { writeFileAtomically } from "../lib/atomic-file"; |
| 25 | 25 | import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; |
| 26 | 26 | import { |
| 27 | collectComposerContextReferences, | |
| 27 | 28 | formatComposerContextReference, |
| 28 | 29 | sanitizeComposerContextLabel, |
| 29 | 30 | replaceComposerContextReferences, |
| 30 | 31 | } from "@t3tools/shared/composerContextReferences"; |
| 31 | 32 | import { imageMimeType } from "@t3tools/shared/image"; |
| 33 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 32 | 34 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 33 | 35 | import { |
| 34 | 36 | composerAttachmentFileReferenceKey, |
| ... | ... | @@ -95,6 +97,89 @@ export function readComposerDraftSelection( |
| 95 | 97 | return { start: lastComposerSelection.start, end: lastComposerSelection.end }; |
| 96 | 98 | } |
| 97 | 99 | |
| 100 | export interface ComposerDraftInsertion { | |
| 101 | readonly text: string; | |
| 102 | readonly start: number; | |
| 103 | readonly end: number; | |
| 104 | } | |
| 105 | ||
| 106 | /** Capture the paste target before any clipboard reads, downloads, or file writes. */ | |
| 107 | export function captureComposerDraftInsertion( | |
| 108 | draftKey: string, | |
| 109 | selection?: { start: number; end: number }, | |
| 110 | ): ComposerDraftInsertion { | |
| 111 | const { text } = getComposerDraftSnapshot(draftKey); | |
| 112 | return { | |
| 113 | text, | |
| 114 | ...(selection ?? | |
| 115 | readComposerDraftSelection(draftKey, text) ?? { start: text.length, end: text.length }), | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | function contextInsertionRange( | |
| 120 | draftKey: string, | |
| 121 | draft: ComposerDraft, | |
| 122 | target?: ComposerDraftInsertion, | |
| 123 | ) { | |
| 124 | const captured = | |
| 125 | target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null); | |
| 126 | // Edits made during a file write must not be replaced using stale offsets. A changed | |
| 127 | // draft receives the file at its end; moving only the caret preserves the captured range. | |
| 128 | const selection = captured?.text === draft.text ? captured : null; | |
| 129 | const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); | |
| 130 | return { start, end: Math.max(start, Math.min(selection?.end ?? start, draft.text.length)) }; | |
| 131 | } | |
| 132 | ||
| 133 | function draftWithoutInsertionSelection( | |
| 134 | draftKey: string, | |
| 135 | draft: ComposerDraft, | |
| 136 | target?: ComposerDraftInsertion, | |
| 137 | ) { | |
| 138 | const { start, end } = contextInsertionRange(draftKey, draft, target); | |
| 139 | const text = `${draft.text.slice(0, start)} ${draft.text.slice(end)}`; | |
| 140 | return withReferencedContextFiles(draft, text, referencedComposerContext(text, draft.context)); | |
| 141 | } | |
| 142 | ||
| 143 | export function countComposerDraftAttachmentsAfterSelection( | |
| 144 | draftKey: string, | |
| 145 | target: ComposerDraftInsertion, | |
| 146 | ): number { | |
| 147 | return getComposerDraftAfterSelection(draftKey, target).attachments.length; | |
| 148 | } | |
| 149 | ||
| 150 | export function getComposerDraftAfterSelection( | |
| 151 | draftKey: string, | |
| 152 | target: ComposerDraftInsertion, | |
| 153 | ): ComposerDraft { | |
| 154 | return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target); | |
| 155 | } | |
| 156 | ||
| 157 | function withReferencedContextFiles( | |
| 158 | draft: ComposerDraft, | |
| 159 | text: string, | |
| 160 | context: OrchestrationMessageContext | undefined, | |
| 161 | ): ComposerDraft { | |
| 162 | const previousIds = new Set( | |
| 163 | draft.context?.records.flatMap((record) => | |
| 164 | "attachmentId" in record ? [record.attachmentId] : [], | |
| 165 | ), | |
| 166 | ); | |
| 167 | const retainedIds = new Set( | |
| 168 | context?.records.flatMap((record) => ("attachmentId" in record ? [record.attachmentId] : [])), | |
| 169 | ); | |
| 170 | return { | |
| 171 | ...draft, | |
| 172 | text, | |
| 173 | context, | |
| 174 | attachments: draft.attachments.filter( | |
| 175 | (attachment) => | |
| 176 | attachment.type === "image" || | |
| 177 | !previousIds.has(attachment.id) || | |
| 178 | retainedIds.has(attachment.id), | |
| 179 | ), | |
| 180 | }; | |
| 181 | } | |
| 182 | ||
| 98 | 183 | /** Retains file bytes while native text undo can restore their references. */ |
| 99 | 184 | export function createComposerDraftContextHistory() { |
| 100 | 185 | const restoreContext = createComposerContextHistory(); |
| ... | ... | @@ -162,16 +247,36 @@ export function setComposerDraftContext( |
| 162 | 247 | |
| 163 | 248 | export function insertComposerDraftContext( |
| 164 | 249 | draftKey: string, |
| 165 | content: { text: string; context: OrchestrationMessageContext }, | |
| 250 | content: { | |
| 251 | text: string; | |
| 252 | context: OrchestrationMessageContext; | |
| 253 | attachments?: ReadonlyArray<DraftComposerAttachment>; | |
| 254 | }, | |
| 255 | target?: ComposerDraftInsertion, | |
| 166 | 256 | ): boolean { |
| 167 | 257 | let inserted = false; |
| 258 | let removed: ReadonlyArray<DraftComposerAttachment> = []; | |
| 168 | 259 | updateComposerDrafts((current) => { |
| 169 | 260 | const draft = normalizeDraft(current[draftKey]); |
| 170 | const nextDraft = draftWithInsertedContext(draftKey, draft, content); | |
| 261 | const attachments = content.attachments ?? []; | |
| 262 | const retained = draftWithoutInsertionSelection(draftKey, draft, target); | |
| 263 | if ( | |
| 264 | attachments.length > 0 && | |
| 265 | retained.attachments.length + attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS | |
| 266 | ) | |
| 267 | return current; | |
| 268 | const nextDraft = draftWithInsertedContext( | |
| 269 | draftKey, | |
| 270 | { ...draft, attachments: [...draft.attachments, ...attachments] }, | |
| 271 | content, | |
| 272 | target, | |
| 273 | ); | |
| 171 | 274 | if (!nextDraft) return current; |
| 172 | 275 | inserted = true; |
| 276 | removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); | |
| 173 | 277 | return { ...current, [draftKey]: nextDraft }; |
| 174 | 278 | }); |
| 279 | scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); | |
| 175 | 280 | return inserted; |
| 176 | 281 | } |
| 177 | 282 | |
| ... | ... | @@ -179,13 +284,9 @@ function draftWithInsertedContext( |
| 179 | 284 | draftKey: string, |
| 180 | 285 | draft: ComposerDraft, |
| 181 | 286 | content: { text: string; context: OrchestrationMessageContext }, |
| 287 | target?: ComposerDraftInsertion, | |
| 182 | 288 | ): ComposerDraft | null { |
| 183 | const selection = | |
| 184 | lastComposerSelection?.draftKey === draftKey && lastComposerSelection.text === draft.text | |
| 185 | ? lastComposerSelection | |
| 186 | : null; | |
| 187 | const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); | |
| 188 | const end = Math.max(start, Math.min(selection?.end ?? start, draft.text.length)); | |
| 289 | const { start, end } = contextInsertionRange(draftKey, draft, target); | |
| 189 | 290 | const before = draft.text.slice(0, start); |
| 190 | 291 | const after = draft.text.slice(end); |
| 191 | 292 | 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( |
| 200 | 301 | start: start + insertion.length, |
| 201 | 302 | end: start + insertion.length, |
| 202 | 303 | }; |
| 203 | return { ...draft, text, context }; | |
| 304 | return withReferencedContextFiles(draft, text, context); | |
| 204 | 305 | } |
| 205 | 306 | |
| 206 | 307 | export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()( |
| ... | ... | @@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void { |
| 352 | 453 | persistRetryNeeded = false; |
| 353 | 454 | } |
| 354 | 455 | |
| 456 | function attachmentContextRecord( | |
| 457 | attachment: DraftComposerAttachment, | |
| 458 | contextId = ComposerContextId.make(attachment.id), | |
| 459 | ) { | |
| 460 | const common = { | |
| 461 | version: 1 as const, | |
| 462 | contextId, | |
| 463 | label: sanitizeComposerContextLabel(attachment.name, attachment.type), | |
| 464 | attachmentId: attachment.id, | |
| 465 | name: attachment.name, | |
| 466 | mimeType: attachment.mimeType, | |
| 467 | sizeBytes: attachment.sizeBytes, | |
| 468 | }; | |
| 469 | // A picture picked through the document picker is typed as a plain file, but the | |
| 470 | // record has to say what it is or no client will offer to open it as an image. | |
| 471 | return attachment.type === "image" || imageMimeType(attachment) !== null | |
| 472 | ? { ...common, kind: "image" as const } | |
| 473 | : { ...common, kind: "file" as const }; | |
| 474 | } | |
| 475 | ||
| 476 | /** Older drafts stored documents only in the attachment strip. Restore their missing chips. */ | |
| 477 | function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft { | |
| 478 | const records = [...(draft.context?.records ?? [])]; | |
| 479 | const usedIds = new Set<string>(records.map((record) => record.contextId)); | |
| 480 | const referenced = new Set( | |
| 481 | collectComposerContextReferences(draft.text).map( | |
| 482 | (reference) => `${reference.kind}:${reference.contextId}`, | |
| 483 | ), | |
| 484 | ); | |
| 485 | let text = draft.text; | |
| 486 | let changed = false; | |
| 487 | for (const attachment of draft.attachments) { | |
| 488 | if ( | |
| 489 | attachment.type === "image" || | |
| 490 | imageMimeType(attachment) !== null || | |
| 491 | videoMimeType(attachment) !== null | |
| 492 | ) | |
| 493 | continue; | |
| 494 | let record = records.find( | |
| 495 | (candidate) => | |
| 496 | candidate.kind === "file" && | |
| 497 | "attachmentId" in candidate && | |
| 498 | candidate.attachmentId === attachment.id, | |
| 499 | ); | |
| 500 | if (!record) { | |
| 501 | const baseId = attachment.id.replace(/[^a-z0-9_-]/gi, "_").slice(0, 110) || "file"; | |
| 502 | let contextId = baseId; | |
| 503 | for (let suffix = 2; usedIds.has(contextId); suffix += 1) contextId = `${baseId}_${suffix}`; | |
| 504 | usedIds.add(contextId); | |
| 505 | record = attachmentContextRecord(attachment, ComposerContextId.make(contextId)); | |
| 506 | records.push(record); | |
| 507 | changed = true; | |
| 508 | } | |
| 509 | const key = `${record.kind}:${record.contextId}`; | |
| 510 | if (referenced.has(key)) continue; | |
| 511 | text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatComposerContextReference(record)} `; | |
| 512 | referenced.add(key); | |
| 513 | changed = true; | |
| 514 | } | |
| 515 | return changed ? { ...draft, text, context: { version: 1, records } } : draft; | |
| 516 | } | |
| 517 | ||
| 355 | 518 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| 356 | 519 | if (!draft) { |
| 357 | 520 | return EMPTY_DRAFT; |
| ... | ... | @@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft( |
| 421 | 584 | draft: ComposerDraft, |
| 422 | 585 | now: string, |
| 423 | 586 | ): readonly [key: string, draft: ComposerDraft] { |
| 587 | const restored = restoreMissingComposerFileReferences(draft); | |
| 424 | 588 | const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; |
| 425 | 589 | if (legacy === null) { |
| 426 | return [key, draft]; | |
| 590 | return [key, restored]; | |
| 427 | 591 | } |
| 428 | 592 | return [ |
| 429 | 593 | newTaskDraftKey(newDraftId()), |
| 430 | 594 | { |
| 431 | ...draft, | |
| 595 | ...restored, | |
| 432 | 596 | project: { |
| 433 | 597 | environmentId: EnvironmentIdSchema.make(legacy.environmentId), |
| 434 | 598 | projectId: ProjectIdSchema.make(legacy.projectId), |
| ... | ... | @@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void { |
| 1089 | 1253 | updateComposerDrafts((current) => { |
| 1090 | 1254 | const existing = normalizeDraft(current[draftKey]); |
| 1091 | 1255 | const context = referencedComposerContext(value, existing.context); |
| 1092 | const retainedIds = new Set(context?.records.map((record) => record.contextId)); | |
| 1093 | const removedFileIds = new Set( | |
| 1094 | existing.context?.records.flatMap((record) => | |
| 1095 | record.kind === "file" && "attachmentId" in record && !retainedIds.has(record.contextId) | |
| 1096 | ? [record.attachmentId] | |
| 1097 | : [], | |
| 1098 | ), | |
| 1099 | ); | |
| 1100 | removed = existing.attachments.filter( | |
| 1101 | (attachment) => attachment.type !== "image" && removedFileIds.has(attachment.id), | |
| 1102 | ); | |
| 1103 | const draft = { | |
| 1104 | ...existing, | |
| 1105 | text: value, | |
| 1106 | context, | |
| 1107 | attachments: existing.attachments.filter((attachment) => !removed.includes(attachment)), | |
| 1108 | }; | |
| 1256 | const draft = withReferencedContextFiles(existing, value, context); | |
| 1257 | removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment)); | |
| 1109 | 1258 | return withComposerDraft(current, draftKey, draft); |
| 1110 | 1259 | }); |
| 1111 | 1260 | scheduleUnusedComposerAttachmentCleanup(removed); |
| 1112 | 1261 | } |
| 1113 | 1262 | |
| 1263 | export function insertComposerDraftText( | |
| 1264 | draftKey: string, | |
| 1265 | value: string, | |
| 1266 | target: ComposerDraftInsertion, | |
| 1267 | ): void { | |
| 1268 | const draft = getComposerDraftSnapshot(draftKey); | |
| 1269 | const { start, end } = contextInsertionRange(draftKey, draft, target); | |
| 1270 | const text = draft.text.slice(0, start) + value + draft.text.slice(end); | |
| 1271 | setComposerDraftText(draftKey, text); | |
| 1272 | rememberComposerDraftSelection(draftKey, text, { | |
| 1273 | start: start + value.length, | |
| 1274 | end: start + value.length, | |
| 1275 | }); | |
| 1276 | } | |
| 1277 | ||
| 1114 | 1278 | export function appendComposerDraftText(draftKey: string, value: string): void { |
| 1115 | 1279 | updateComposerDrafts((current) => { |
| 1116 | 1280 | const existing = normalizeDraft(current[draftKey]); |
| ... | ... | @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( |
| 1137 | 1301 | options?: { |
| 1138 | 1302 | readonly allowOverflow?: boolean; |
| 1139 | 1303 | readonly appendReference?: boolean; |
| 1304 | readonly insertion?: ComposerDraftInsertion; | |
| 1140 | 1305 | readonly maxAttachments?: number; |
| 1141 | 1306 | }, |
| 1142 | 1307 | ): number { |
| ... | ... | @@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments( |
| 1144 | 1309 | return 0; |
| 1145 | 1310 | } |
| 1146 | 1311 | let rejected: ReadonlyArray<DraftComposerAttachment> = []; |
| 1312 | let removed: ReadonlyArray<DraftComposerAttachment> = []; | |
| 1147 | 1313 | updateComposerDrafts((current) => { |
| 1148 | 1314 | const existing = normalizeDraft(current[draftKey]); |
| 1315 | const retained = options?.appendReference | |
| 1316 | ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) | |
| 1317 | : existing; | |
| 1149 | 1318 | const remaining = options?.allowOverflow |
| 1150 | 1319 | ? attachments.length |
| 1151 | 1320 | : Math.max( |
| ... | ... | @@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments( |
| 1153 | 1322 | Math.min( |
| 1154 | 1323 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 1155 | 1324 | options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 1156 | ) - existing.attachments.length, | |
| 1325 | ) - retained.attachments.length, | |
| 1157 | 1326 | ); |
| 1158 | 1327 | const contextCapacity = options?.appendReference |
| 1159 | ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (existing.context?.records.length ?? 0)) | |
| 1328 | ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (retained.context?.records.length ?? 0)) | |
| 1160 | 1329 | : attachments.length; |
| 1161 | 1330 | const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); |
| 1162 | 1331 | rejected = attachments.slice(accepted.length); |
| ... | ... | @@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments( |
| 1165 | 1334 | } |
| 1166 | 1335 | let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; |
| 1167 | 1336 | if (options?.appendReference) { |
| 1168 | const records = accepted.map((attachment) => { | |
| 1169 | const common = { | |
| 1170 | version: 1 as const, | |
| 1171 | contextId: ComposerContextId.make(attachment.id), | |
| 1172 | label: sanitizeComposerContextLabel(attachment.name, attachment.type), | |
| 1173 | attachmentId: attachment.id, | |
| 1174 | name: attachment.name, | |
| 1175 | mimeType: attachment.mimeType, | |
| 1176 | sizeBytes: attachment.sizeBytes, | |
| 1177 | }; | |
| 1178 | // A picture picked through the document picker is typed as a plain file, but the | |
| 1179 | // record has to say what it is or no client will offer to open it as an image. | |
| 1180 | return attachment.type === "image" || imageMimeType(attachment) !== null | |
| 1181 | ? { ...common, kind: "image" as const } | |
| 1182 | : { ...common, kind: "file" as const }; | |
| 1183 | }); | |
| 1184 | const inserted = draftWithInsertedContext(draftKey, draft, { | |
| 1185 | text: records.map(formatComposerContextReference).join(" "), | |
| 1186 | context: { version: 1, records }, | |
| 1187 | }); | |
| 1337 | const records = accepted.map((attachment) => attachmentContextRecord(attachment)); | |
| 1338 | const inserted = draftWithInsertedContext( | |
| 1339 | draftKey, | |
| 1340 | draft, | |
| 1341 | { | |
| 1342 | text: records.map(formatComposerContextReference).join(" "), | |
| 1343 | context: { version: 1, records }, | |
| 1344 | }, | |
| 1345 | options?.insertion, | |
| 1346 | ); | |
| 1188 | 1347 | if (!inserted) { |
| 1189 | 1348 | rejected = attachments; |
| 1190 | 1349 | return current; |
| 1191 | 1350 | } |
| 1192 | 1351 | draft = { ...inserted, attachments: [...inserted.attachments] }; |
| 1352 | removed = existing.attachments.filter( | |
| 1353 | (attachment) => !draft.attachments.includes(attachment), | |
| 1354 | ); | |
| 1193 | 1355 | } |
| 1194 | 1356 | return { |
| 1195 | 1357 | ...current, |
| 1196 | 1358 | [draftKey]: draft, |
| 1197 | 1359 | }; |
| 1198 | 1360 | }); |
| 1199 | scheduleUnusedComposerAttachmentCleanup(rejected); | |
| 1361 | scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); | |
| 1200 | 1362 | return rejected.length; |
| 1201 | 1363 | } |
| 1202 | 1364 |
apps/mobile/src/state/use-thread-composer-state.ts+161-9| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 4 | import { Alert } from "react-native"; |
| 4 | 5 | |
| 5 | 6 | import { |
| ... | ... | @@ -7,6 +8,7 @@ import { |
| 7 | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 8 | 9 | MessageId, |
| 9 | 10 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 11 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 10 | 12 | type EnvironmentId, |
| 11 | 13 | type ModelSelection, |
| 12 | 14 | type ProviderInteractionMode, |
| ... | ... | @@ -14,6 +16,8 @@ import { |
| 14 | 16 | type ThreadId, |
| 15 | 17 | } from "@t3tools/contracts"; |
| 16 | 18 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 19 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | |
| 20 | import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; | |
| 17 | 21 | import { |
| 18 | 22 | parseCodexFeedbackCommand, |
| 19 | 23 | submitCodexFeedback, |
| ... | ... | @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; |
| 29 | 33 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; |
| 30 | 34 | import { |
| 31 | 35 | convertPastedImagesToAttachments, |
| 36 | createPastedTextComposerAttachment, | |
| 32 | 37 | pasteComposerClipboard, |
| 33 | 38 | pickComposerFiles, |
| 34 | 39 | pickComposerMedia, |
| 40 | removePersistedComposerAttachmentFile, | |
| 35 | 41 | } from "../lib/composerImages"; |
| 36 | 42 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 37 | 43 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| ... | ... | @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 42 | 48 | import { pendingThreadCreationMessage } from "./pending-thread-creation"; |
| 43 | 49 | import { |
| 44 | 50 | appendComposerDraftAttachments, |
| 45 | appendComposerDraftText, | |
| 51 | captureComposerDraftInsertion, | |
| 52 | countComposerDraftAttachmentsAfterSelection, | |
| 53 | insertComposerDraftText, | |
| 46 | 54 | insertComposerDraftContext, |
| 47 | 55 | clearComposerDraftContent, |
| 48 | 56 | composerDraftsAtom, |
| ... | ... | @@ -132,6 +140,23 @@ export function useThreadComposerState() { |
| 132 | 140 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { |
| 133 | 141 | reportFailure: false, |
| 134 | 142 | }); |
| 143 | const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set<string> }>({ | |
| 144 | threadKey: null, | |
| 145 | names: new Set(), | |
| 146 | }); | |
| 147 | const reservePastedTextFileName = useCallback( | |
| 148 | (threadKey: string, existingNames: ReadonlyArray<string>) => { | |
| 149 | if (pastedTextFileNamesRef.current.threadKey !== threadKey) { | |
| 150 | pastedTextFileNamesRef.current = { threadKey, names: new Set() }; | |
| 151 | } | |
| 152 | const names = pastedTextFileNamesRef.current.names; | |
| 153 | for (const name of existingNames) names.add(name); | |
| 154 | const nextName = nextPastedTextFileName([...names]); | |
| 155 | names.add(nextName); | |
| 156 | return nextName; | |
| 157 | }, | |
| 158 | [], | |
| 159 | ); | |
| 135 | 160 | |
| 136 | 161 | useEffect(() => { |
| 137 | 162 | ensureComposerDraftsLoaded(); |
| ... | ... | @@ -479,9 +504,10 @@ export function useThreadComposerState() { |
| 479 | 504 | } |
| 480 | 505 | |
| 481 | 506 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 507 | const insertion = captureComposerDraftInsertion(threadKey); | |
| 482 | 508 | const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; |
| 483 | 509 | const result = await pickComposerMedia({ |
| 484 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | |
| 510 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), | |
| 485 | 511 | maxVideoBytes: |
| 486 | 512 | capabilities?.attachmentUploads === true |
| 487 | 513 | ? capabilities.fileAttachments?.maxUploadBytes |
| ... | ... | @@ -489,6 +515,7 @@ export function useThreadComposerState() { |
| 489 | 515 | }); |
| 490 | 516 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { |
| 491 | 517 | appendReference: true, |
| 518 | insertion, | |
| 492 | 519 | }); |
| 493 | 520 | const problems = [ |
| 494 | 521 | ...(result.error ? [result.error] : []), |
| ... | ... | @@ -514,13 +541,15 @@ export function useThreadComposerState() { |
| 514 | 541 | } |
| 515 | 542 | |
| 516 | 543 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 544 | const insertion = captureComposerDraftInsertion(threadKey); | |
| 517 | 545 | // pickComposerFiles clamps the advertised limit to the contract maximum. |
| 518 | 546 | const result = await pickComposerFiles({ |
| 519 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | |
| 547 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), | |
| 520 | 548 | maxBytes, |
| 521 | 549 | }); |
| 522 | 550 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { |
| 523 | 551 | appendReference: true, |
| 552 | insertion, | |
| 524 | 553 | }); |
| 525 | 554 | // The picker error and the live-cap rejection can both happen in one |
| 526 | 555 | // pick; report both in a single alert. |
| ... | ... | @@ -541,14 +570,81 @@ export function useThreadComposerState() { |
| 541 | 570 | } |
| 542 | 571 | |
| 543 | 572 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 573 | const insertion = captureComposerDraftInsertion(threadKey); | |
| 544 | 574 | const result = await pasteComposerClipboard({ |
| 545 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | |
| 575 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), | |
| 546 | 576 | }); |
| 547 | 577 | const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { |
| 548 | 578 | appendReference: true, |
| 579 | insertion, | |
| 549 | 580 | }); |
| 550 | 581 | if (result.text) { |
| 551 | appendComposerDraftText(threadKey, result.text); | |
| 582 | const currentDraft = getComposerDraftSnapshot(threadKey); | |
| 583 | const currentAttachments = currentDraft.attachments; | |
| 584 | const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; | |
| 585 | const advertisedMax = | |
| 586 | capabilities?.attachmentUploads === true | |
| 587 | ? capabilities.fileAttachments?.maxUploadBytes | |
| 588 | : undefined; | |
| 589 | const maxBytes = | |
| 590 | advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); | |
| 591 | const wouldExceedInputLimit = | |
| 592 | currentDraft.text.length - | |
| 593 | (currentDraft.text === insertion.text | |
| 594 | ? Math.max(0, insertion.end - insertion.start) | |
| 595 | : 0) + | |
| 596 | result.text.length > | |
| 597 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS; | |
| 598 | const shouldFold = | |
| 599 | pastedTextDisposition({ | |
| 600 | text: result.text, | |
| 601 | wouldExceedInputLimit, | |
| 602 | canAttach: true, | |
| 603 | }) === "attachment"; | |
| 604 | const canAttach = | |
| 605 | maxBytes !== null && | |
| 606 | countComposerDraftAttachmentsAfterSelection(threadKey, insertion) < | |
| 607 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS && | |
| 608 | new TextEncoder().encode(result.text).byteLength <= maxBytes; | |
| 609 | if (shouldFold && canAttach && maxBytes !== null) { | |
| 610 | try { | |
| 611 | const attachment = await createPastedTextComposerAttachment({ | |
| 612 | text: result.text, | |
| 613 | name: reservePastedTextFileName( | |
| 614 | threadKey, | |
| 615 | currentAttachments.map((item) => item.name), | |
| 616 | ), | |
| 617 | maxBytes, | |
| 618 | }); | |
| 619 | // Same reference the pasted images above get: a folded paste is only visible | |
| 620 | // as its chip until the message is sent. | |
| 621 | if ( | |
| 622 | appendComposerDraftAttachments(threadKey, [attachment], { | |
| 623 | appendReference: true, | |
| 624 | insertion, | |
| 625 | }) > 0 | |
| 626 | ) { | |
| 627 | await removePersistedComposerAttachmentFile(attachment.fileUri); | |
| 628 | setPendingConnectionError( | |
| 629 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, | |
| 630 | ); | |
| 631 | } | |
| 632 | } catch (error) { | |
| 633 | setPendingConnectionError( | |
| 634 | error instanceof Error ? error.message : "Could not attach pasted text.", | |
| 635 | ); | |
| 636 | } | |
| 637 | } else if (shouldFold && !wouldExceedInputLimit) { | |
| 638 | insertComposerDraftText(threadKey, result.text, insertion); | |
| 639 | } else if (shouldFold) { | |
| 640 | setPendingConnectionError( | |
| 641 | wouldExceedInputLimit | |
| 642 | ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again." | |
| 643 | : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.", | |
| 644 | ); | |
| 645 | } else { | |
| 646 | insertComposerDraftText(threadKey, result.text, insertion); | |
| 647 | } | |
| 552 | 648 | } |
| 553 | 649 | if (result.error) { |
| 554 | 650 | setPendingConnectionError(result.error); |
| ... | ... | @@ -557,7 +653,12 @@ export function useThreadComposerState() { |
| 557 | 653 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, |
| 558 | 654 | ); |
| 559 | 655 | } |
| 560 | }, [composerDrafts, selectedThreadShell]); | |
| 656 | }, [ | |
| 657 | composerDrafts, | |
| 658 | reservePastedTextFileName, | |
| 659 | selectedEnvironmentRuntime?.serverConfig, | |
| 660 | selectedThreadShell, | |
| 661 | ]); | |
| 561 | 662 | |
| 562 | 663 | const onNativePasteImages = useCallback( |
| 563 | 664 | async (uris: ReadonlyArray<string>) => { |
| ... | ... | @@ -566,13 +667,14 @@ export function useThreadComposerState() { |
| 566 | 667 | } |
| 567 | 668 | |
| 568 | 669 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 670 | const insertion = captureComposerDraftInsertion(threadKey); | |
| 569 | 671 | try { |
| 570 | 672 | const images = await convertPastedImagesToAttachments({ |
| 571 | 673 | uris, |
| 572 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | |
| 674 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), | |
| 573 | 675 | }); |
| 574 | 676 | if (images.length > 0) { |
| 575 | appendComposerDraftAttachments(threadKey, images, { appendReference: true }); | |
| 677 | appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion }); | |
| 576 | 678 | } |
| 577 | 679 | } catch (error) { |
| 578 | 680 | console.error("[native paste] error converting images", { |
| ... | ... | @@ -586,6 +688,55 @@ export function useThreadComposerState() { |
| 586 | 688 | [composerDrafts, selectedThreadShell], |
| 587 | 689 | ); |
| 588 | 690 | |
| 691 | const onNativePasteText = useCallback( | |
| 692 | async (paste: ComposerTextPaste) => { | |
| 693 | if (!selectedThreadShell) return; | |
| 694 | const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; | |
| 695 | const advertisedMax = | |
| 696 | capabilities?.attachmentUploads === true | |
| 697 | ? capabilities.fileAttachments?.maxUploadBytes | |
| 698 | : undefined; | |
| 699 | if (advertisedMax === undefined) return; | |
| 700 | ||
| 701 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | |
| 702 | const insertion = { text: paste.value, ...paste.selection }; | |
| 703 | const currentAttachments = getComposerDraftSnapshot(threadKey).attachments; | |
| 704 | try { | |
| 705 | const attachment = await createPastedTextComposerAttachment({ | |
| 706 | text: paste.text, | |
| 707 | name: reservePastedTextFileName( | |
| 708 | threadKey, | |
| 709 | currentAttachments.map((item) => item.name), | |
| 710 | ), | |
| 711 | maxBytes: clampFileAttachmentUploadBytes(advertisedMax), | |
| 712 | }); | |
| 713 | // The chip is how a folded paste stays visible: without it the attachment is in the | |
| 714 | // draft but nothing in the composer says so until the message is sent. Web folds | |
| 715 | // through its ordinary attach path, which always writes a reference; match that. | |
| 716 | const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment], { | |
| 717 | appendReference: true, | |
| 718 | insertion, | |
| 719 | }); | |
| 720 | if (rejectedCount > 0) { | |
| 721 | await removePersistedComposerAttachmentFile(attachment.fileUri); | |
| 722 | setPendingConnectionError( | |
| 723 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, | |
| 724 | ); | |
| 725 | } | |
| 726 | } catch (error) { | |
| 727 | setPendingConnectionError( | |
| 728 | error instanceof Error ? error.message : "Could not attach pasted text.", | |
| 729 | ); | |
| 730 | } | |
| 731 | }, | |
| 732 | [ | |
| 733 | composerDrafts, | |
| 734 | reservePastedTextFileName, | |
| 735 | selectedEnvironmentRuntime?.serverConfig, | |
| 736 | selectedThreadShell, | |
| 737 | ], | |
| 738 | ); | |
| 739 | ||
| 589 | 740 | const onRemoveDraftImage = useCallback( |
| 590 | 741 | (imageId: string) => { |
| 591 | 742 | if (!selectedThreadShell) { |
| ... | ... | @@ -663,6 +814,7 @@ export function useThreadComposerState() { |
| 663 | 814 | onPickDraftFiles, |
| 664 | 815 | onPasteIntoDraft, |
| 665 | 816 | onNativePasteImages, |
| 817 | onNativePasteText, | |
| 666 | 818 | onRemoveDraftImage, |
| 667 | 819 | onSendMessage, |
| 668 | 820 | onUpdateModelSelection, |
apps/server/src/provider/Layers/ProviderService.test.ts+46| ... | ... | @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 2228 | 2228 | assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); |
| 2229 | 2229 | assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); |
| 2230 | 2230 | |
| 2231 | const pastedTextAttachment = { | |
| 2232 | type: "file" as const, | |
| 2233 | id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", | |
| 2234 | name: "pasted-text.txt", | |
| 2235 | mimeType: "text/plain;charset=utf-8", | |
| 2236 | sizeBytes: 32_768, | |
| 2237 | source: { _tag: "pasted-text" as const }, | |
| 2238 | }; | |
| 2239 | routing.codex.sendTurn.mockClear(); | |
| 2240 | yield* provider.sendTurn({ | |
| 2241 | threadId: session.threadId, | |
| 2242 | input: "Investigate this crash", | |
| 2243 | attachments: [pastedTextAttachment], | |
| 2244 | }); | |
| 2245 | const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; | |
| 2246 | assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); | |
| 2247 | assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); | |
| 2248 | assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); | |
| 2249 | ||
| 2231 | 2250 | yield* provider.stopSession({ threadId: session.threadId }); |
| 2232 | 2251 | }), |
| 2233 | 2252 | ); |
| ... | ... | @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { |
| 4683 | 4702 | |
| 4684 | 4703 | const validation = makeProviderServiceLayer(); |
| 4685 | 4704 | validation.layer("ProviderServiceLive validation", (it) => { |
| 4705 | it.effect("rejects input that leaves no room for pasted-text attachment context", () => | |
| 4706 | Effect.gen(function* () { | |
| 4707 | const provider = yield* ProviderService.ProviderService; | |
| 4708 | const attachment = { | |
| 4709 | type: "file" as const, | |
| 4710 | id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", | |
| 4711 | name: "pasted-text.txt", | |
| 4712 | mimeType: "text/plain;charset=utf-8", | |
| 4713 | sizeBytes: 32_768, | |
| 4714 | source: { _tag: "pasted-text" as const }, | |
| 4715 | }; | |
| 4716 | validation.codex.sendTurn.mockClear(); | |
| 4717 | ||
| 4718 | const failure = yield* provider | |
| 4719 | .sendTurn({ | |
| 4720 | threadId: asThreadId("thread-pasted-text-context-limit"), | |
| 4721 | input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), | |
| 4722 | attachments: [attachment], | |
| 4723 | }) | |
| 4724 | .pipe(Effect.flip); | |
| 4725 | ||
| 4726 | assert.instanceOf(failure, ProviderValidationError); | |
| 4727 | assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); | |
| 4728 | assert.equal(validation.codex.sendTurn.mock.calls.length, 0); | |
| 4729 | }), | |
| 4730 | ); | |
| 4731 | ||
| 4686 | 4732 | it.effect("rejects citation-expanded input over the provider character limit", () => |
| 4687 | 4733 | Effect.gen(function* () { |
| 4688 | 4734 | const provider = yield* ProviderService.ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+22-7| ... | ... | @@ -1593,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1593 | 1593 | |
| 1594 | 1594 | // Every attachment gets an on-disk path in the prompt so the model's tools |
| 1595 | 1595 | // can dereference the actual file. All attachments then go to the adapter, |
| 1596 | // and each adapter decides what its provider ingests natively: OpenCode | |
| 1597 | // sends generic files as file parts, the others send images only and rely | |
| 1598 | // on the path line for everything else. Unresolvable ids are skipped here | |
| 1599 | // and surface as adapter errors when the file is read. | |
| 1596 | // and each adapter decides what its provider ingests natively. Folded | |
| 1597 | // clipboard text remains path-only everywhere: eagerly embedding it would | |
| 1598 | // spend the same context the client deliberately preserved by folding it. | |
| 1599 | // Unresolvable ids are skipped here and surface as adapter errors when the | |
| 1600 | // file is read. | |
| 1600 | 1601 | let inputTextWithAttachmentContext = inputTextWithCitations; |
| 1601 | 1602 | const appendAttachmentContext = (context: string | undefined) => { |
| 1602 | if (context === undefined) return; | |
| 1603 | if (context === undefined) return true; | |
| 1603 | 1604 | const candidate = inputTextWithAttachmentContext |
| 1604 | 1605 | ? `${inputTextWithAttachmentContext}\n\n${context}` |
| 1605 | 1606 | : context; |
| 1606 | 1607 | if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { |
| 1607 | 1608 | inputTextWithAttachmentContext = candidate; |
| 1609 | return true; | |
| 1608 | 1610 | } |
| 1611 | return false; | |
| 1609 | 1612 | }; |
| 1610 | 1613 | for (const attachment of attachments) { |
| 1611 | 1614 | const attachmentPath = resolveAttachmentPath({ |
| 1612 | 1615 | attachmentsDir: serverConfig.attachmentsDir, |
| 1613 | 1616 | attachment, |
| 1614 | 1617 | }); |
| 1615 | appendAttachmentContext( | |
| 1618 | const isPastedText = | |
| 1619 | attachment.type === "file" && | |
| 1620 | "source" in attachment && | |
| 1621 | attachment.source?._tag === "pasted-text"; | |
| 1622 | const appended = appendAttachmentContext( | |
| 1616 | 1623 | attachmentPath === null |
| 1617 | 1624 | ? undefined |
| 1618 | : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, | |
| 1625 | : isPastedText | |
| 1626 | ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` | |
| 1627 | : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, | |
| 1619 | 1628 | ); |
| 1629 | if (isPastedText && !appended) { | |
| 1630 | return yield* toValidationError( | |
| 1631 | "ProviderService.sendTurn", | |
| 1632 | `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, | |
| 1633 | ); | |
| 1634 | } | |
| 1620 | 1635 | } |
| 1621 | 1636 | for (const attachment of attachments) { |
| 1622 | 1637 | const source = |
apps/server/src/provider/acp/AntigravityAcpSupport.test.ts+45| ... | ... | @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { |
| 279 | 279 | }), |
| 280 | 280 | ); |
| 281 | 281 | |
| 282 | it.effect("keeps folded clipboard text out of native context", () => | |
| 283 | Effect.gen(function* () { | |
| 284 | const fixture = yield* makeAttachmentFixture(); | |
| 285 | const pastedText = { | |
| 286 | ...textAttachment, | |
| 287 | name: "pasted-text.txt", | |
| 288 | mimeType: "text/plain", | |
| 289 | source: { _tag: "pasted-text" as const }, | |
| 290 | } satisfies ChatAttachment; | |
| 291 | yield* fixture.write(pastedText, "A very large crash report"); | |
| 292 | ||
| 293 | const prompt = yield* buildAntigravityPrompt({ | |
| 294 | input: "Inspect the pasted text only as needed.", | |
| 295 | attachments: [pastedText], | |
| 296 | attachmentsDir: fixture.attachmentsDir, | |
| 297 | }); | |
| 298 | ||
| 299 | expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); | |
| 300 | }), | |
| 301 | ); | |
| 302 | ||
| 303 | it.effect("rejects missing folded clipboard text", () => | |
| 304 | Effect.gen(function* () { | |
| 305 | const fixture = yield* makeAttachmentFixture(); | |
| 306 | const pastedText = { | |
| 307 | ...textAttachment, | |
| 308 | name: "pasted-text.txt", | |
| 309 | mimeType: "text/plain", | |
| 310 | source: { _tag: "pasted-text" as const }, | |
| 311 | } satisfies ChatAttachment; | |
| 312 | ||
| 313 | const error = yield* buildAntigravityPrompt({ | |
| 314 | input: "Inspect the pasted text only as needed.", | |
| 315 | attachments: [pastedText], | |
| 316 | attachmentsDir: fixture.attachmentsDir, | |
| 317 | }).pipe(Effect.flip); | |
| 318 | ||
| 319 | expect(error).toMatchObject({ | |
| 320 | _tag: "AcpRequestError", | |
| 321 | code: -32602, | |
| 322 | errorMessage: "Could not read attachment 'pasted-text.txt'.", | |
| 323 | }); | |
| 324 | }), | |
| 325 | ); | |
| 326 | ||
| 282 | 327 | it.effect("sends supported audio files as native audio content", () => |
| 283 | 328 | Effect.gen(function* () { |
| 284 | 329 | const fixture = yield* makeAttachmentFixture(); |
apps/server/src/provider/acp/AntigravityAcpSupport.ts+15| ... | ... | @@ -264,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi |
| 264 | 264 | let totalBytes = 0; |
| 265 | 265 | |
| 266 | 266 | for (const attachment of input.attachments ?? []) { |
| 267 | const isPastedText = | |
| 268 | attachment.type === "file" && | |
| 269 | "source" in attachment && | |
| 270 | attachment.source?._tag === "pasted-text"; | |
| 271 | // ProviderService has already put the file path in the text block. Keep a | |
| 272 | // folded clipboard paste lazy so the agent can search or sample it rather | |
| 273 | // than paying to embed the entire resource in context immediately. | |
| 267 | 274 | const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; |
| 268 | 275 | const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); |
| 269 | 276 | const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); |
| ... | ... | @@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi |
| 296 | 303 | ), |
| 297 | 304 | ), |
| 298 | 305 | ); |
| 306 | if (isPastedText) { | |
| 307 | if (info.type !== "File") { | |
| 308 | return yield* EffectAcpErrors.AcpRequestError.invalidParams( | |
| 309 | `Could not read attachment '${attachment.name}'.`, | |
| 310 | ); | |
| 311 | } | |
| 312 | continue; | |
| 313 | } | |
| 299 | 314 | const size = Number(info.size); |
| 300 | 315 | const limit = image |
| 301 | 316 | ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+14| ... | ... | @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { |
| 318 | 318 | ["application/pdf", "text/markdown", "image/png"], |
| 319 | 319 | ); |
| 320 | 320 | }); |
| 321 | ||
| 322 | it("keeps folded clipboard text on the lazy path fallback", () => { | |
| 323 | const parts = toOpenCodeFileParts({ | |
| 324 | attachments: [ | |
| 325 | { | |
| 326 | ...attachment("text/plain"), | |
| 327 | source: { _tag: "pasted-text" as const }, | |
| 328 | }, | |
| 329 | ], | |
| 330 | resolveAttachmentPath: () => "/tmp/pasted-text.txt", | |
| 331 | }); | |
| 332 | ||
| 333 | NodeAssert.deepEqual(parts, []); | |
| 334 | }); | |
| 321 | 335 | }); |
apps/server/src/provider/opencodeRuntime.ts+7| ... | ... | @@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: { |
| 462 | 462 | const parts: Array<FilePartInput> = []; |
| 463 | 463 | |
| 464 | 464 | for (const attachment of input.attachments ?? []) { |
| 465 | if ( | |
| 466 | attachment.type === "file" && | |
| 467 | "source" in attachment && | |
| 468 | attachment.source?._tag === "pasted-text" | |
| 469 | ) { | |
| 470 | continue; | |
| 471 | } | |
| 465 | 472 | if (!isOpenCodeNativeFilePart(attachment)) { |
| 466 | 473 | continue; |
| 467 | 474 | } |
apps/web/src/components/ChatView.tsx+25-6| ... | ... | @@ -46,6 +46,8 @@ import { |
| 46 | 46 | } from "@t3tools/contracts"; |
| 47 | 47 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 48 | 48 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 49 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; | |
| 50 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | |
| 49 | 51 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 50 | 52 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 51 | 53 | import { |
| ... | ... | @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; |
| 95 | 97 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 96 | 98 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 97 | 99 | import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; |
| 100 | import { isMacPlatform } from "../lib/utils"; | |
| 98 | 101 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 99 | 102 | import { useShallow } from "zustand/react/shallow"; |
| 100 | 103 | import { |
| ... | ... | @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1608 | 1611 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 1609 | 1612 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1610 | 1613 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1614 | const pasteAsTextShortcutUntilRef = useRef(0); | |
| 1611 | 1615 | const [restingComposerControlsHost, setRestingComposerControlsHost] = |
| 1612 | 1616 | useState<HTMLDivElement | null>(null); |
| 1613 | 1617 | const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); |
| ... | ... | @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { |
| 6519 | 6523 | // so a paste that follows has no editable target and would be dropped. |
| 6520 | 6524 | // Route it to the composer like a typed key, which also expands it. |
| 6521 | 6525 | useEffect(() => { |
| 6526 | const keyHandler = (event: KeyboardEvent) => { | |
| 6527 | if ( | |
| 6528 | shouldRedirectInputToComposer(event) && | |
| 6529 | isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) | |
| 6530 | ) { | |
| 6531 | pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; | |
| 6532 | } | |
| 6533 | }; | |
| 6522 | 6534 | const handler = (event: ClipboardEvent) => { |
| 6523 | 6535 | if (!activeThreadId || isCommandPaletteOpen()) return; |
| 6524 | 6536 | if (getTerminalFocusOwner() !== null) return; |
| 6525 | 6537 | if (composerRef.current?.isModelPickerOpen()) return; |
| 6526 | 6538 | const text = pasteTextToFocusComposer(event); |
| 6527 | if (text === null) return; | |
| 6539 | const clipboardData = event.clipboardData; | |
| 6540 | if (text === null || clipboardData === null) return; | |
| 6541 | const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; | |
| 6542 | pasteAsTextShortcutUntilRef.current = 0; | |
| 6528 | 6543 | if ( |
| 6529 | composerRef.current?.insertTextAtEnd( | |
| 6530 | text, | |
| 6531 | event.clipboardData ? { clipboardData: event.clipboardData } : undefined, | |
| 6532 | ) | |
| 6544 | ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && | |
| 6545 | composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || | |
| 6546 | composerRef.current?.insertTextAtEnd(text, { clipboardData }) | |
| 6533 | 6547 | ) { |
| 6534 | 6548 | event.preventDefault(); |
| 6535 | 6549 | event.stopPropagation(); |
| 6536 | 6550 | } |
| 6537 | 6551 | }; |
| 6552 | window.addEventListener("keydown", keyHandler, true); | |
| 6538 | 6553 | window.addEventListener("paste", handler, true); |
| 6539 | return () => window.removeEventListener("paste", handler, true); | |
| 6554 | return () => { | |
| 6555 | window.removeEventListener("keydown", keyHandler, true); | |
| 6556 | window.removeEventListener("paste", handler, true); | |
| 6557 | }; | |
| 6540 | 6558 | }, [activeThreadId, composerRef]); |
| 6541 | 6559 | |
| 6542 | 6560 | const [pendingRevert, setPendingRevert] = useState<{ |
| ... | ... | @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7257 | 7275 | mimeType: attachment.mimeType, |
| 7258 | 7276 | sizeBytes: attachment.sizeBytes, |
| 7259 | 7277 | downloadable: false, |
| 7278 | ...(attachment.source ? { source: attachment.source } : {}), | |
| 7260 | 7279 | }, |
| 7261 | 7280 | ); |
| 7262 | 7281 | const shouldAnchorFirstMessage = |
apps/web/src/components/ComposerPromptEditor.test.ts+96-84| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | |
| 19 | 19 | import { |
| 20 | 20 | importPastedComposerText, |
| 21 | readPastedComposerContext, | |
| 21 | 22 | registerComposerInlineTokenPaste, |
| 22 | 23 | } from "./composerInlineTokenPaste"; |
| 23 | 24 | import { |
| ... | ... | @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { |
| 498 | 499 | }); |
| 499 | 500 | |
| 500 | 501 | describe("context reference paste", () => { |
| 501 | it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { | |
| 502 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | |
| 503 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | |
| 504 | editor.update( | |
| 505 | () => { | |
| 506 | const paragraph = $createParagraphNode(); | |
| 507 | $getRoot().append(paragraph); | |
| 508 | paragraph.selectEnd(); | |
| 509 | }, | |
| 510 | { discrete: true }, | |
| 511 | ); | |
| 512 | const imported: string[] = []; | |
| 513 | const importFragment = ( | |
| 514 | fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, | |
| 515 | ) => { | |
| 516 | imported.push(...fragment.records.map((record) => record.contextId)); | |
| 517 | return new Map([["img-old", "img-new"]]); | |
| 518 | }; | |
| 519 | registerComposerInlineTokenPaste(editor, { | |
| 520 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | |
| 521 | createCitationNode: $createComposerCitationNode, | |
| 522 | createContextReferenceNode: (reference) => | |
| 523 | $createTextNode(`<context:${reference.contextId}>`), | |
| 524 | getExpandedAbsoluteOffsetForPoint: () => 0, | |
| 525 | importContextFragment: importFragment, | |
| 526 | }); | |
| 527 | const event = new TestClipboardEvent( | |
| 528 | " and [T](t3-context://v1/terminal/ctx-t)", | |
| 529 | { | |
| 530 | "web application/x-t3-context-fragment+json": JSON.stringify({ | |
| 531 | version: 1, | |
| 532 | source: { environmentId: "env-1" }, | |
| 533 | records: [ | |
| 534 | { | |
| 535 | version: 1, | |
| 536 | contextId: "img-old", | |
| 537 | kind: "image", | |
| 538 | label: "shot", | |
| 539 | attachmentId: "a", | |
| 540 | name: "shot.png", | |
| 541 | mimeType: "image/png", | |
| 542 | sizeBytes: 1, | |
| 543 | }, | |
| 544 | { | |
| 545 | version: 1, | |
| 546 | contextId: "ctx-t", | |
| 547 | kind: "terminal", | |
| 548 | label: "T", | |
| 549 | terminalId: "t", | |
| 550 | terminalLabel: "T", | |
| 551 | lineStart: 1, | |
| 552 | lineEnd: 1, | |
| 553 | text: "x", | |
| 554 | }, | |
| 555 | { | |
| 556 | version: 1, | |
| 557 | contextId: "img-unrelated", | |
| 558 | kind: "image", | |
| 559 | label: "other", | |
| 560 | attachmentId: "b", | |
| 561 | name: "other.png", | |
| 562 | mimeType: "image/png", | |
| 563 | sizeBytes: 1, | |
| 564 | }, | |
| 565 | ], | |
| 566 | }), | |
| 567 | }, | |
| 568 | ); | |
| 569 | if (focus === "blurred") { | |
| 570 | expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( | |
| 571 | " and [T](t3-context://v1/terminal/ctx-t)", | |
| 502 | it.each([ | |
| 503 | { focus: "focused", prefix: "" }, | |
| 504 | { focus: "blurred", prefix: "" }, | |
| 505 | { focus: "focused", prefix: "log ".repeat(10_000) }, | |
| 506 | { focus: "blurred", prefix: "log ".repeat(10_000) }, | |
| 507 | ])( | |
| 508 | "imports structured paste when $focus with $prefix.length extra characters", | |
| 509 | ({ focus, prefix }) => { | |
| 510 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | |
| 511 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | |
| 512 | editor.update( | |
| 513 | () => { | |
| 514 | const paragraph = $createParagraphNode(); | |
| 515 | $getRoot().append(paragraph); | |
| 516 | paragraph.selectEnd(); | |
| 517 | }, | |
| 518 | { discrete: true }, | |
| 519 | ); | |
| 520 | const imported: string[] = []; | |
| 521 | const importFragment = ( | |
| 522 | fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, | |
| 523 | ) => { | |
| 524 | imported.push(...fragment.records.map((record) => record.contextId)); | |
| 525 | return new Map([["img-old", "img-new"]]); | |
| 526 | }; | |
| 527 | registerComposerInlineTokenPaste(editor, { | |
| 528 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | |
| 529 | createCitationNode: $createComposerCitationNode, | |
| 530 | createContextReferenceNode: (reference) => | |
| 531 | $createTextNode(`<context:${reference.contextId}>`), | |
| 532 | getExpandedAbsoluteOffsetForPoint: () => 0, | |
| 533 | importContextFragment: importFragment, | |
| 534 | }); | |
| 535 | const event = new TestClipboardEvent( | |
| 536 | `${prefix} and [T](t3-context://v1/terminal/ctx-t)`, | |
| 537 | { | |
| 538 | "web application/x-t3-context-fragment+json": JSON.stringify({ | |
| 539 | version: 1, | |
| 540 | source: { environmentId: "env-1" }, | |
| 541 | records: [ | |
| 542 | { | |
| 543 | version: 1, | |
| 544 | contextId: "img-old", | |
| 545 | kind: "image", | |
| 546 | label: "shot", | |
| 547 | attachmentId: "a", | |
| 548 | name: "shot.png", | |
| 549 | mimeType: "image/png", | |
| 550 | sizeBytes: 1, | |
| 551 | }, | |
| 552 | { | |
| 553 | version: 1, | |
| 554 | contextId: "ctx-t", | |
| 555 | kind: "terminal", | |
| 556 | label: "T", | |
| 557 | terminalId: "t", | |
| 558 | terminalLabel: "T", | |
| 559 | lineStart: 1, | |
| 560 | lineEnd: 1, | |
| 561 | text: "x", | |
| 562 | }, | |
| 563 | { | |
| 564 | version: 1, | |
| 565 | contextId: "img-unrelated", | |
| 566 | kind: "image", | |
| 567 | label: "other", | |
| 568 | attachmentId: "b", | |
| 569 | name: "other.png", | |
| 570 | mimeType: "image/png", | |
| 571 | sizeBytes: 1, | |
| 572 | }, | |
| 573 | ], | |
| 574 | }), | |
| 575 | }, | |
| 576 | ); | |
| 577 | expect( | |
| 578 | readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), | |
| 579 | ).toEqual(["img-old", "ctx-t"]); | |
| 580 | if (focus === "blurred") { | |
| 581 | expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( | |
| 582 | `${prefix} and [T](t3-context://v1/terminal/ctx-t)`, | |
| 583 | ); | |
| 584 | expect(imported).toEqual(["img-old", "ctx-t"]); | |
| 585 | return; | |
| 586 | } | |
| 587 | editor.update( | |
| 588 | () => { | |
| 589 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | |
| 590 | }, | |
| 591 | { discrete: true }, | |
| 572 | 592 | ); |
| 573 | 593 | expect(imported).toEqual(["img-old", "ctx-t"]); |
| 574 | return; | |
| 575 | } | |
| 576 | editor.update( | |
| 577 | () => { | |
| 578 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | |
| 579 | }, | |
| 580 | { discrete: true }, | |
| 581 | ); | |
| 582 | expect(imported).toEqual(["img-old", "ctx-t"]); | |
| 583 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | |
| 584 | "<context:img-new> and <context:ctx-t>", | |
| 585 | ); | |
| 586 | }); | |
| 594 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | |
| 595 | `${prefix}<context:img-new> and <context:ctx-t>`, | |
| 596 | ); | |
| 597 | }, | |
| 598 | ); | |
| 587 | 599 | |
| 588 | 600 | it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { |
| 589 | 601 | const copied = upgradeLegacyContextMessage( |
apps/web/src/components/ComposerPromptEditor.tsx+6-1| ... | ... | @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { |
| 838 | 838 | focus: () => void; |
| 839 | 839 | focusAt: (cursor: number) => void; |
| 840 | 840 | focusAtEnd: () => void; |
| 841 | readSelectionRange: () => { start: number; end: number }; | |
| 841 | 842 | requestCitationComment: (request: ComposerCitationCommentRequest) => void; |
| 842 | 843 | readSnapshot: () => { |
| 843 | 844 | value: string; |
| ... | ... | @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ |
| 1854 | 1855 | ), |
| 1855 | 1856 | ); |
| 1856 | 1857 | }, |
| 1858 | readSelectionRange: () => { | |
| 1859 | readSnapshot(); | |
| 1860 | return selectionRangeRef.current; | |
| 1861 | }, | |
| 1857 | 1862 | requestCitationComment: (request) => { |
| 1858 | 1863 | citationCommentRequestRef.current = request; |
| 1859 | 1864 | const target = editor |
| ... | ... | @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ |
| 2021 | 2026 | }} |
| 2022 | 2027 | onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} |
| 2023 | 2028 | onBlur={onPageScrollRelease} |
| 2024 | onPaste={onPaste} | |
| 2029 | onPasteCapture={onPaste} | |
| 2025 | 2030 | /> |
| 2026 | 2031 | } |
| 2027 | 2032 | placeholder={ |
apps/web/src/components/chat/ChatComposer.tsx+226-35| ... | ... | @@ -1,13 +1,15 @@ |
| 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; | |
| 1 | 2 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; |
| 2 | 3 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 3 | 4 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; |
| 4 | 5 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 5 | 6 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; |
| 6 | import { importPastedComposerText } from "../composerInlineTokenPaste"; | |
| 7 | import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; | |
| 7 | 8 | import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; |
| 8 | 9 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 9 | 10 | import { |
| 10 | 11 | questionAttachmentDraftId, |
| 12 | countQuestionAttachments, | |
| 11 | 13 | useQuestionAttachmentPreparation, |
| 12 | 14 | changeQuestionAttachmentPreparation, |
| 13 | 15 | } from "../../questionAttachments"; |
| ... | ... | @@ -34,8 +36,15 @@ import { |
| 34 | 36 | ProviderInstanceId, |
| 35 | 37 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 36 | 38 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 39 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 37 | 40 | } from "@t3tools/contracts"; |
| 38 | 41 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 42 | import { | |
| 43 | isPasteAsTextShortcut, | |
| 44 | nextPastedTextFileName, | |
| 45 | pastedTextDisposition, | |
| 46 | wouldTextPasteExceedLimit, | |
| 47 | } from "@t3tools/client-runtime/text-paste"; | |
| 39 | 48 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 40 | 49 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 41 | 50 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; |
| ... | ... | @@ -194,6 +203,7 @@ import { |
| 194 | 203 | ensureInlineContextReferences, |
| 195 | 204 | formatInlineContextReference, |
| 196 | 205 | insertInlineContextReference, |
| 206 | inlineContextReferenceReplacement, | |
| 197 | 207 | toKindScopedComposerContextId, |
| 198 | 208 | } from "~/lib/composerContextReferences"; |
| 199 | 209 | import { |
| ... | ... | @@ -284,7 +294,7 @@ import { |
| 284 | 294 | } from "../../lib/snapShotAnimation"; |
| 285 | 295 | import { resizeSnapShotSource } from "../../lib/snapShotSource"; |
| 286 | 296 | import { basenameOfPath } from "../../pierre-icons"; |
| 287 | import { cn, randomUUID } from "~/lib/utils"; | |
| 297 | import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; | |
| 288 | 298 | import { |
| 289 | 299 | getComposerPromptLengthValidationMessage, |
| 290 | 300 | getComposerSubmissionValidationMessage, |
| ... | ... | @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { |
| 1212 | 1222 | text: string, |
| 1213 | 1223 | options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, |
| 1214 | 1224 | ) => boolean; |
| 1225 | /** Apply large-paste folding for text redirected from a blurred composer. */ | |
| 1226 | pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; | |
| 1215 | 1227 | citeAssistantText: ( |
| 1216 | 1228 | citation: AssistantCitation, |
| 1217 | 1229 | sourceAnchor: AssistantCitationSourceAnchor, |
| ... | ... | @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2064 | 2076 | // Refs |
| 2065 | 2077 | // ------------------------------------------------------------------ |
| 2066 | 2078 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 2079 | const pasteAsTextShortcutUntilRef = useRef(0); | |
| 2080 | const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set<string> }>({ | |
| 2081 | targetKey: "", | |
| 2082 | names: new Set(), | |
| 2083 | }); | |
| 2067 | 2084 | const attachmentInputRef = useRef<HTMLInputElement>(null); |
| 2068 | 2085 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 2069 | 2086 | const composerFooterControlsRef = useRef<HTMLDivElement>(null); |
| ... | ... | @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2099 | 2116 | const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); |
| 2100 | 2117 | isRevertingCheckpointRef.current = isRevertingCheckpoint; |
| 2101 | 2118 | |
| 2119 | useEffect(() => { | |
| 2120 | const armPasteAsTextShortcut = () => { | |
| 2121 | // Electron can deliver its native menu action just before the paste | |
| 2122 | // event, while browsers normally deliver keydown first. A short deadline | |
| 2123 | // bridges both event paths without leaving later pastes in bypass mode. | |
| 2124 | pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; | |
| 2125 | }; | |
| 2126 | const onKeyDown = (event: KeyboardEvent) => { | |
| 2127 | if ( | |
| 2128 | event.target instanceof Node && | |
| 2129 | composerFormRef.current?.contains(event.target) && | |
| 2130 | isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) | |
| 2131 | ) { | |
| 2132 | armPasteAsTextShortcut(); | |
| 2133 | } | |
| 2134 | }; | |
| 2135 | const onBlur = () => { | |
| 2136 | pasteAsTextShortcutUntilRef.current = 0; | |
| 2137 | }; | |
| 2138 | const onDesktopPasteAsText = () => { | |
| 2139 | const activeElement = document.activeElement; | |
| 2140 | const blocksPasteToFocus = | |
| 2141 | activeElement instanceof Element && | |
| 2142 | activeElement.closest( | |
| 2143 | 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', | |
| 2144 | ) !== null; | |
| 2145 | if ( | |
| 2146 | (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || | |
| 2147 | !blocksPasteToFocus | |
| 2148 | ) { | |
| 2149 | armPasteAsTextShortcut(); | |
| 2150 | } | |
| 2151 | }; | |
| 2152 | window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); | |
| 2153 | window.addEventListener("keydown", onKeyDown, true); | |
| 2154 | window.addEventListener("blur", onBlur); | |
| 2155 | return () => { | |
| 2156 | window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); | |
| 2157 | window.removeEventListener("keydown", onKeyDown, true); | |
| 2158 | window.removeEventListener("blur", onBlur); | |
| 2159 | }; | |
| 2160 | }, []); | |
| 2161 | ||
| 2102 | 2162 | // ------------------------------------------------------------------ |
| 2103 | 2163 | // Derived: composer send state |
| 2104 | 2164 | // ------------------------------------------------------------------ |
| ... | ... | @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4041 | 4101 | mimeType: file.mimeType, |
| 4042 | 4102 | sizeBytes: file.sizeBytes, |
| 4043 | 4103 | file: null, |
| 4104 | ...(file.source ? { source: file.source } : {}), | |
| 4044 | 4105 | // An expired upload carries no ids, so it hydrates as a |
| 4045 | 4106 | // needs-reattach row and the "Attach again" flow takes over. |
| 4046 | 4107 | ...(expired |
| ... | ... | @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4319 | 4380 | sizeBytes: file.sizeBytes, |
| 4320 | 4381 | attachmentId: upload.attachmentId, |
| 4321 | 4382 | environmentId, |
| 4383 | ...(file.source ? { source: file.source } : {}), | |
| 4322 | 4384 | }); |
| 4323 | 4385 | } |
| 4324 | 4386 | // A repeat ⌘S on the *same* still-unencoded snapshot would stash it |
| ... | ... | @@ -5032,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5032 | 5094 | // ------------------------------------------------------------------ |
| 5033 | 5095 | // Callbacks: attachments |
| 5034 | 5096 | // ------------------------------------------------------------------ |
| 5097 | const countReservedAttachments = () => { | |
| 5098 | const questionRequest = pendingUserInputs[0]; | |
| 5099 | const otherQuestionKeys = | |
| 5100 | questionAttachmentTarget && questionRequest && activeThreadId | |
| 5101 | ? questionRequest.questions | |
| 5102 | .map((question) => | |
| 5103 | questionAttachmentDraftId( | |
| 5104 | environmentId, | |
| 5105 | activeThreadId, | |
| 5106 | questionRequest.requestId, | |
| 5107 | question.id, | |
| 5108 | ), | |
| 5109 | ) | |
| 5110 | .filter((key) => key !== questionAttachmentTarget) | |
| 5111 | : []; | |
| 5112 | return ( | |
| 5113 | composerImagesRef.current.length + | |
| 5114 | composerFilesRef.current.length + | |
| 5115 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) + | |
| 5116 | countQuestionAttachments(otherQuestionKeys) | |
| 5117 | ); | |
| 5118 | }; | |
| 5035 | 5119 | /** Resolves true when at least one chip was inserted for the accepted attachments. */ |
| 5036 | const addComposerAttachments = async (files: File[]): Promise<boolean> => { | |
| 5120 | const addComposerAttachments = async ( | |
| 5121 | files: File[], | |
| 5122 | options?: { | |
| 5123 | readonly source?: ChatFileAttachment["source"]; | |
| 5124 | readonly selection?: { start: number; end: number }; | |
| 5125 | }, | |
| 5126 | ): Promise<boolean> => { | |
| 5037 | 5127 | if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; |
| 5038 | 5128 | if ( |
| 5039 | 5129 | pendingUserInputs.length > 0 && |
| ... | ... | @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5056 | 5146 | // accepted files reserve their attachment slots (via the pending counter) |
| 5057 | 5147 | // before the first await, keeping the total under the limit. |
| 5058 | 5148 | const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; |
| 5059 | const otherQuestionAttachments = | |
| 5060 | questionAttachmentTarget && pendingUserInputs[0] | |
| 5061 | ? pendingUserInputs[0].questions.reduce((count, question) => { | |
| 5062 | const target = questionAttachmentDraftId( | |
| 5063 | environmentId, | |
| 5064 | threadId, | |
| 5065 | pendingUserInputs[0]!.requestId, | |
| 5066 | question.id, | |
| 5067 | ); | |
| 5068 | if (target === questionAttachmentTarget) return count; | |
| 5069 | const draft = getComposerDraft(target); | |
| 5070 | return ( | |
| 5071 | count + | |
| 5072 | (draft?.images.length ?? 0) + | |
| 5073 | (draft?.files.length ?? 0) + | |
| 5074 | (useQuestionAttachmentPreparation.getState().counts[target] ?? 0) | |
| 5075 | ); | |
| 5076 | }, 0) | |
| 5077 | : 0; | |
| 5078 | let reservedCount = | |
| 5079 | composerImagesRef.current.length + | |
| 5080 | composerFilesRef.current.length + | |
| 5081 | pendingCount + | |
| 5082 | otherQuestionAttachments; | |
| 5149 | let reservedCount = countReservedAttachments(); | |
| 5083 | 5150 | // A pick that matches a needs-reattach marker replaces it in the draft, so |
| 5084 | 5151 | // it must not consume a slot; a draft full of markers would otherwise hit |
| 5085 | 5152 | // the capacity error before the replacement path could run. |
| ... | ... | @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5146 | 5213 | mimeType: fileMimeType, |
| 5147 | 5214 | sizeBytes: attachmentFile.size, |
| 5148 | 5215 | file: attachmentFile, |
| 5216 | ...(options?.source ? { source: options.source } : {}), | |
| 5149 | 5217 | }); |
| 5150 | 5218 | } |
| 5151 | 5219 | if (!matchingReattachMarker) { |
| ... | ... | @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5160 | 5228 | const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); |
| 5161 | 5229 | const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); |
| 5162 | 5230 | if (storedFiles.length > 0) { |
| 5163 | insertedAny = insertAttachmentReferences(storedFiles.map(fileContextReference)); | |
| 5231 | insertedAny = insertAttachmentReferences( | |
| 5232 | storedFiles.map(fileContextReference), | |
| 5233 | options?.selection, | |
| 5234 | ); | |
| 5235 | } | |
| 5236 | if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) { | |
| 5237 | const attached = storedFiles[0]!; | |
| 5238 | toastManager.add({ | |
| 5239 | type: "info", | |
| 5240 | title: `Large paste attached as ${attached.name}`, | |
| 5241 | description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${ | |
| 5242 | isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V" | |
| 5243 | } to keep a large paste inline.`, | |
| 5244 | data: { hideCopyButton: true }, | |
| 5245 | }); | |
| 5164 | 5246 | } |
| 5165 | 5247 | } |
| 5166 | 5248 | if (acceptedImages.length === 0) return insertedAny; |
| ... | ... | @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5246 | 5328 | */ |
| 5247 | 5329 | const insertAttachmentReferences = ( |
| 5248 | 5330 | references: ReadonlyArray<ComposerContextReference>, |
| 5331 | selection?: { start: number; end: number }, | |
| 5249 | 5332 | ): boolean => { |
| 5250 | 5333 | if (references.length === 0) return false; |
| 5251 | 5334 | // Question answers carry attachments beside the answer, never as chips. Falling back to |
| 5252 | 5335 | // the thread prompt here would hide the file behind a reference the question never shows. |
| 5253 | 5336 | if (questionAttachmentTarget) return false; |
| 5337 | if (selection) { | |
| 5338 | const edit = inlineContextReferenceReplacement(promptRef.current, selection, references); | |
| 5339 | return applyPromptReplacement(edit.start, edit.end, edit.text); | |
| 5340 | } | |
| 5254 | 5341 | const text = references.map(formatInlineContextReference).join(" "); |
| 5255 | 5342 | const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); |
| 5256 | 5343 | if (!inserted) { |
| ... | ... | @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5284 | 5371 | // ------------------------------------------------------------------ |
| 5285 | 5372 | // Callbacks: paste / drag |
| 5286 | 5373 | // ------------------------------------------------------------------ |
| 5374 | const foldPastedText = ( | |
| 5375 | plainText: string, | |
| 5376 | bypassAutoAttachment: boolean, | |
| 5377 | selectionOverride?: { start: number; end: number }, | |
| 5378 | ): boolean => { | |
| 5379 | const questionCanAttach = | |
| 5380 | pendingUserInputs.length === 0 || | |
| 5381 | (supportsQuestionAttachments && | |
| 5382 | activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && | |
| 5383 | !activePendingIsResponding); | |
| 5384 | const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; | |
| 5385 | const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); | |
| 5386 | const wouldExceedInputLimit = wouldTextPasteExceedLimit({ | |
| 5387 | valueLength: promptRef.current.length, | |
| 5388 | selection: selection ?? { start: 0, end: 0 }, | |
| 5389 | textLength: plainText.length, | |
| 5390 | maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 5391 | }); | |
| 5392 | const shouldFold = | |
| 5393 | pastedTextDisposition({ | |
| 5394 | text: plainText, | |
| 5395 | bypassAutoAttachment, | |
| 5396 | wouldExceedInputLimit, | |
| 5397 | canAttach: true, | |
| 5398 | }) === "attachment"; | |
| 5399 | if (!shouldFold) { | |
| 5400 | return false; | |
| 5401 | } | |
| 5402 | ||
| 5403 | const canStageAttachment = | |
| 5404 | Boolean(activeThreadId) && | |
| 5405 | !isRevertingCheckpointRef.current && | |
| 5406 | questionCanAttach && | |
| 5407 | hasAttachmentSlot; | |
| 5408 | if (!canStageAttachment || fileStagingLimit === null) { | |
| 5409 | if (!wouldExceedInputLimit) { | |
| 5410 | return false; | |
| 5411 | } | |
| 5412 | toastManager.add({ | |
| 5413 | type: "error", | |
| 5414 | title: "Pasted text is too large for this message", | |
| 5415 | description: "Remove some text or an attachment, then paste again.", | |
| 5416 | data: { hideCopyButton: true }, | |
| 5417 | }); | |
| 5418 | return true; | |
| 5419 | } | |
| 5420 | ||
| 5421 | if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) { | |
| 5422 | pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; | |
| 5423 | } | |
| 5424 | const reservedNames = pastedTextFileNamesRef.current.names; | |
| 5425 | for (const file of composerFilesRef.current) reservedNames.add(file.name); | |
| 5426 | const foldedFileName = nextPastedTextFileName([...reservedNames]); | |
| 5427 | reservedNames.add(foldedFileName); | |
| 5428 | const foldedFile = new File([plainText], foldedFileName, { | |
| 5429 | type: "text/plain;charset=utf-8", | |
| 5430 | }); | |
| 5431 | if (foldedFile.size > fileStagingLimit) { | |
| 5432 | reservedNames.delete(foldedFileName); | |
| 5433 | if (!wouldExceedInputLimit) return false; | |
| 5434 | toastManager.add({ | |
| 5435 | type: "error", | |
| 5436 | title: "Pasted text is too large to attach", | |
| 5437 | description: "Reduce the clipboard contents or save a smaller excerpt as a file.", | |
| 5438 | data: { hideCopyButton: true }, | |
| 5439 | }); | |
| 5440 | return true; | |
| 5441 | } | |
| 5442 | ||
| 5443 | void addComposerAttachments([foldedFile], { | |
| 5444 | source: { _tag: "pasted-text" }, | |
| 5445 | ...(selection ? { selection } : {}), | |
| 5446 | }); | |
| 5447 | return true; | |
| 5448 | }; | |
| 5449 | ||
| 5287 | 5450 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 5288 | 5451 | const files = Array.from(event.clipboardData.files); |
| 5452 | const plainText = event.clipboardData.getData("text/plain"); | |
| 5453 | const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; | |
| 5454 | pasteAsTextShortcutUntilRef.current = 0; | |
| 5289 | 5455 | // Claimable pastes go through even when agent questions are pending or the |
| 5290 | 5456 | // composer is at its attachment limit: `addComposerAttachments` surfaces |
| 5291 | 5457 | // those as a toast and a thread error. An early return here would swallow |
| 5292 | 5458 | // the paste with no feedback. |
| 5293 | 5459 | if ( |
| 5294 | files.length === 0 || | |
| 5295 | !activeThreadId || | |
| 5296 | !shouldHandleComposerAttachmentPaste({ | |
| 5297 | files, | |
| 5298 | plainText: event.clipboardData.getData("text/plain"), | |
| 5299 | }) | |
| 5460 | files.length > 0 && | |
| 5461 | activeThreadId && | |
| 5462 | shouldHandleComposerAttachmentPaste({ files, plainText }) | |
| 5300 | 5463 | ) { |
| 5464 | event.preventDefault(); | |
| 5465 | event.stopPropagation(); | |
| 5466 | void addComposerAttachments(files); | |
| 5301 | 5467 | return; |
| 5302 | 5468 | } |
| 5469 | ||
| 5470 | // Copied T3 chips need the structured importer to bring their records and files along. | |
| 5471 | if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; | |
| 5472 | if (!foldPastedText(plainText, bypassAutoAttachment)) { | |
| 5473 | return; | |
| 5474 | } | |
| 5475 | ||
| 5303 | 5476 | event.preventDefault(); |
| 5304 | void addComposerAttachments(files); | |
| 5477 | event.stopPropagation(); | |
| 5305 | 5478 | }; |
| 5306 | 5479 | |
| 5307 | 5480 | const insertComposerText = useCallback( |
| ... | ... | @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5526 | 5699 | hasPendingAttachments: () => |
| 5527 | 5700 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, |
| 5528 | 5701 | insertTextAtEnd: insertComposerTextAtEnd, |
| 5702 | pasteTextAtEnd: (text: string, options) => { | |
| 5703 | const bypassAutoAttachment = | |
| 5704 | options?.bypassAutoAttachment === true || | |
| 5705 | Date.now() <= pasteAsTextShortcutUntilRef.current; | |
| 5706 | pasteAsTextShortcutUntilRef.current = 0; | |
| 5707 | const promptLength = promptRef.current.length; | |
| 5708 | if ( | |
| 5709 | !foldPastedText(text, bypassAutoAttachment, { | |
| 5710 | start: promptLength, | |
| 5711 | end: promptLength, | |
| 5712 | }) | |
| 5713 | ) { | |
| 5714 | return false; | |
| 5715 | } | |
| 5716 | focusComposer(); | |
| 5717 | return true; | |
| 5718 | }, | |
| 5529 | 5719 | citeAssistantText: (citation, sourceAnchor) => |
| 5530 | 5720 | insertComposerText( |
| 5531 | 5721 | formatAssistantCitationForComposer(citation, citation.comment), |
| ... | ... | @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5626 | 5816 | [ |
| 5627 | 5817 | activeThread, |
| 5628 | 5818 | addComposerAttachments, |
| 5819 | foldPastedText, | |
| 5629 | 5820 | composerDraftTarget, |
| 5630 | 5821 | composerCursor, |
| 5631 | 5822 | composerTerminalContexts, |
apps/web/src/components/composerInlineTokenPaste.ts+28-25| ... | ... | @@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste( |
| 144 | 144 | ); |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ | |
| 148 | export function importPastedComposerText( | |
| 147 | /** Clipboard records referenced by the copied text, including dependent screenshots. */ | |
| 148 | export function readPastedComposerContext( | |
| 149 | 149 | clipboardData: Pick<DataTransfer, "getData">, |
| 150 | importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], | |
| 151 | ): string { | |
| 150 | ): ComposerContextClipboardFragment | null { | |
| 152 | 151 | const pastedText = clipboardData.getData("text/plain"); |
| 153 | 152 | // Only records whose links are in the pasted text get imported; a fragment may carry |
| 154 | 153 | // more (it was built for a larger copy) and must not start transfers for those. |
| 155 | const decodedFragment = importContextFragment | |
| 156 | ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? | |
| 157 | decodeComposerContextClipboardHtml(clipboardData.getData("text/html"))) | |
| 158 | : null; | |
| 154 | const decodedFragment = | |
| 155 | decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? | |
| 156 | decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); | |
| 157 | if (decodedFragment === null) return null; | |
| 159 | 158 | const pastedIds = new Set<string>( |
| 160 | 159 | collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), |
| 161 | 160 | ); |
| 162 | if (decodedFragment) { | |
| 163 | for (const record of decodedFragment.records) { | |
| 164 | if ( | |
| 165 | record.kind === "preview-annotation" && | |
| 166 | !("payload" in record) && | |
| 167 | pastedIds.has(record.contextId) && | |
| 168 | record.screenshotContextId | |
| 169 | ) { | |
| 170 | pastedIds.add(record.screenshotContextId); | |
| 171 | } | |
| 161 | for (const record of decodedFragment.records) { | |
| 162 | if ( | |
| 163 | record.kind === "preview-annotation" && | |
| 164 | !("payload" in record) && | |
| 165 | pastedIds.has(record.contextId) && | |
| 166 | record.screenshotContextId | |
| 167 | ) { | |
| 168 | pastedIds.add(record.screenshotContextId); | |
| 172 | 169 | } |
| 173 | 170 | } |
| 174 | const fragment = | |
| 175 | decodedFragment === null | |
| 176 | ? null | |
| 177 | : { | |
| 178 | ...decodedFragment, | |
| 179 | records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), | |
| 180 | }; | |
| 171 | return { | |
| 172 | ...decodedFragment, | |
| 173 | records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), | |
| 174 | }; | |
| 175 | } | |
| 176 | ||
| 177 | /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ | |
| 178 | export function importPastedComposerText( | |
| 179 | clipboardData: Pick<DataTransfer, "getData">, | |
| 180 | importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], | |
| 181 | ): string { | |
| 182 | const pastedText = clipboardData.getData("text/plain"); | |
| 183 | const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; | |
| 181 | 184 | const rewrittenIds = |
| 182 | 185 | fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; |
| 183 | 186 | const text = |
apps/web/src/components/contextChipParts.tsx+23-22| ... | ... | @@ -119,29 +119,30 @@ export function PullRequestChip(props: { |
| 119 | 119 | onOpen: (event: MouseEvent<HTMLElement>, url: string) => void; |
| 120 | 120 | }) { |
| 121 | 121 | return ( |
| 122 | <ContextChipPopover | |
| 123 | accessibleLabel={`${props.kindLabel} ${props.label}: ${props.metadata.title}`} | |
| 124 | {...(props.copyMarkdown ? { copyMarkdown: props.copyMarkdown } : {})} | |
| 125 | triggerClassName={props.className} | |
| 126 | chip={ | |
| 127 | <> | |
| 128 | <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} /> | |
| 129 | <span className={props.labelClassName}>{props.label}</span> | |
| 130 | </> | |
| 131 | } | |
| 132 | > | |
| 133 | <div className="space-y-3 p-2"> | |
| 122 | <Tooltip> | |
| 123 | <TooltipTrigger | |
| 124 | render={ | |
| 125 | <Button | |
| 126 | variant="chip" | |
| 127 | className={cn( | |
| 128 | props.className, | |
| 129 | CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME, | |
| 130 | CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME, | |
| 131 | "cursor-pointer", | |
| 132 | )} | |
| 133 | aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`} | |
| 134 | data-markdown-copy={props.copyMarkdown} | |
| 135 | onClick={(event) => props.onOpen(event, props.metadata.url)} | |
| 136 | > | |
| 137 | <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} /> | |
| 138 | <span className={props.labelClassName}>{props.label}</span> | |
| 139 | </Button> | |
| 140 | } | |
| 141 | /> | |
| 142 | <TooltipPopup side="top"> | |
| 134 | 143 | <PullRequestContextDetails metadata={props.metadata} /> |
| 135 | <p className="text-xs text-muted-foreground">Captured pull request context</p> | |
| 136 | <Button | |
| 137 | variant="outline" | |
| 138 | size="sm" | |
| 139 | onClick={(event) => props.onOpen(event, props.metadata.url)} | |
| 140 | > | |
| 141 | Open pull request | |
| 142 | </Button> | |
| 143 | </div> | |
| 144 | </ContextChipPopover> | |
| 144 | </TooltipPopup> | |
| 145 | </Tooltip> | |
| 145 | 146 | ); |
| 146 | 147 | } |
| 147 | 148 |
apps/web/src/components/files/AttachmentFilePreview.tsx+21| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | Eye, |
| 13 | 13 | Table2, |
| 14 | 14 | Trash2Icon, |
| 15 | WrapTextIcon, | |
| 15 | 16 | XIcon, |
| 16 | 17 | } from "lucide-react"; |
| 17 | 18 | import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; |
| 21 | 22 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 22 | 23 | import { toastManager } from "~/components/ui/toast"; |
| 23 | 24 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 25 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | |
| 24 | 26 | import { cn } from "~/lib/utils"; |
| 25 | 27 | |
| 26 | 28 | import { AudioPreview } from "./AudioPreview"; |
| ... | ... | @@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: { |
| 180 | 182 | return () => controller.abort(); |
| 181 | 183 | }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); |
| 182 | 184 | const failure = error ?? (needsText ? contentError : null); |
| 185 | const wordWrap = useClientSettings((settings) => settings.wordWrap); | |
| 186 | const updateClientSettings = useUpdateClientSettings(); | |
| 187 | // Only the raw-text body honours word wrap. A rendered table or Markdown lays itself out, | |
| 188 | // so offering the toggle there would be a control that visibly does nothing. | |
| 189 | const showsRawText = | |
| 190 | failure === null && | |
| 191 | needsText && | |
| 192 | content !== null && | |
| 193 | !(delimiter && rendered) && | |
| 194 | !(kind === "markdown" && rendered); | |
| 183 | 195 | |
| 184 | 196 | const save = () => { |
| 185 | 197 | setSaving(true); |
| ... | ... | @@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: { |
| 302 | 314 | )} |
| 303 | 315 | </FileSurfaceAction> |
| 304 | 316 | ) : null} |
| 317 | {showsRawText ? ( | |
| 318 | <FileSurfaceAction | |
| 319 | label={wordWrap ? "Disable word wrap" : "Enable word wrap"} | |
| 320 | pressed={wordWrap} | |
| 321 | onPress={() => updateClientSettings({ wordWrap: !wordWrap })} | |
| 322 | > | |
| 323 | <WrapTextIcon className="size-3.5" /> | |
| 324 | </FileSurfaceAction> | |
| 325 | ) : null} | |
| 305 | 326 | {content ? ( |
| 306 | 327 | <FileSurfaceAction |
| 307 | 328 | label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"} |
apps/web/src/components/files/FilePreviewPanel.tsx+20-2| ... | ... | @@ -21,7 +21,7 @@ import { |
| 21 | 21 | squashAtomCommandFailure, |
| 22 | 22 | } from "@t3tools/client-runtime/state/runtime"; |
| 23 | 23 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 24 | import { Code2, Eye, FolderTree, Globe2, Table2 } from "lucide-react"; | |
| 24 | import { Code2, Eye, FolderTree, Globe2, Table2, WrapTextIcon } from "lucide-react"; | |
| 25 | 25 | import * as Schema from "effect/Schema"; |
| 26 | 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 27 | 27 | |
| ... | ... | @@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 31 | 31 | import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; |
| 32 | 32 | import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; |
| 33 | 33 | import { useRemoteOpenState } from "~/remoteOpen"; |
| 34 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 34 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | |
| 35 | 35 | import { useTheme } from "~/hooks/useTheme"; |
| 36 | 36 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| 37 | 37 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| ... | ... | @@ -995,6 +995,15 @@ export default function FilePreviewPanel({ |
| 995 | 995 | ? ("html" as const) |
| 996 | 996 | : null; |
| 997 | 997 | const canToggleRendered = attachment === undefined && renderedMode !== null; |
| 998 | const updateClientSettings = useUpdateClientSettings(); | |
| 999 | // Word wrap only reaches the text bodies. A rendered Markdown document, a table and the | |
| 1000 | // browser frame all lay themselves out, so the toggle stays hidden rather than inert. | |
| 1001 | const showsRawText = | |
| 1002 | relativePath !== null && | |
| 1003 | file.data !== null && | |
| 1004 | !(isMarkdown && renderMarkdown) && | |
| 1005 | !(tableDelimiter && renderTable) && | |
| 1006 | !renderBrowserFile; | |
| 998 | 1007 | const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile; |
| 999 | 1008 | const setRenderedPreferred = isMarkdown |
| 1000 | 1009 | ? setRenderMarkdownPreferred |
| ... | ... | @@ -1122,6 +1131,15 @@ export default function FilePreviewPanel({ |
| 1122 | 1131 | )} |
| 1123 | 1132 | </FileSurfaceAction> |
| 1124 | 1133 | ) : null} |
| 1134 | {showsRawText ? ( | |
| 1135 | <FileSurfaceAction | |
| 1136 | label={wordWrap ? "Disable word wrap" : "Enable word wrap"} | |
| 1137 | pressed={wordWrap} | |
| 1138 | onPress={() => updateClientSettings({ wordWrap: !wordWrap })} | |
| 1139 | > | |
| 1140 | <WrapTextIcon className="size-3.5" /> | |
| 1141 | </FileSurfaceAction> | |
| 1142 | ) : null} | |
| 1125 | 1143 | {canOpenInBrowser ? ( |
| 1126 | 1144 | <FileSurfaceAction label="Open file in preview browser" onPress={handleOpenInBrowser}> |
| 1127 | 1145 | <Globe2 className="size-3.5" /> |
apps/web/src/composerDraftStore.test.ts+5-1| ... | ... | @@ -436,7 +436,9 @@ describe("composerDraftStore file attachments", () => { |
| 436 | 436 | |
| 437 | 437 | it("persists uploaded file references without including file contents", () => { |
| 438 | 438 | const store = useComposerDraftStore.getState(); |
| 439 | store.addFiles(threadRef, [makeFile("file-1")]); | |
| 439 | store.addFiles(threadRef, [ | |
| 440 | { ...makeFile("file-1"), source: { _tag: "pasted-text" as const } }, | |
| 441 | ]); | |
| 440 | 442 | store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); |
| 441 | 443 | |
| 442 | 444 | const persistApi = useComposerDraftStore.persist as unknown as { |
| ... | ... | @@ -459,6 +461,7 @@ describe("composerDraftStore file attachments", () => { |
| 459 | 461 | sizeBytes: 6, |
| 460 | 462 | attachmentId: "pending-report-pdf", |
| 461 | 463 | environmentId: TEST_ENVIRONMENT_ID, |
| 464 | source: { _tag: "pasted-text" }, | |
| 462 | 465 | }, |
| 463 | 466 | ], |
| 464 | 467 | ); |
| ... | ... | @@ -474,6 +477,7 @@ describe("composerDraftStore file attachments", () => { |
| 474 | 477 | file: null, |
| 475 | 478 | uploadedAttachmentId: "pending-report-pdf", |
| 476 | 479 | uploadEnvironmentId: TEST_ENVIRONMENT_ID, |
| 480 | source: { _tag: "pasted-text" }, | |
| 477 | 481 | }, |
| 478 | 482 | ]); |
| 479 | 483 | }); |
apps/web/src/composerDraftStore.ts+5| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | ProviderOptionSelection, |
| 14 | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 15 | 15 | PreviewAnnotationPayloadSchema, |
| 16 | PastedTextAttachmentSource, | |
| 16 | 17 | type PreviewAnnotationPayload, |
| 17 | 18 | RuntimeMode, |
| 18 | 19 | type ServerProvider, |
| ... | ... | @@ -191,6 +192,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({ |
| 191 | 192 | sizeBytes: Schema.Number, |
| 192 | 193 | attachmentId: Schema.String, |
| 193 | 194 | environmentId: EnvironmentId, |
| 195 | source: Schema.optional(PastedTextAttachmentSource), | |
| 194 | 196 | }); |
| 195 | 197 | export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; |
| 196 | 198 | |
| ... | ... | @@ -207,6 +209,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({ |
| 207 | 209 | sizeBytes: Schema.Number, |
| 208 | 210 | attachmentId: Schema.optionalKey(Schema.String), |
| 209 | 211 | environmentId: Schema.optionalKey(EnvironmentId), |
| 212 | source: Schema.optional(PastedTextAttachmentSource), | |
| 210 | 213 | }); |
| 211 | 214 | export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; |
| 212 | 215 | const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); |
| ... | ... | @@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState( |
| 2144 | 2147 | name: file.name, |
| 2145 | 2148 | mimeType: file.mimeType, |
| 2146 | 2149 | sizeBytes: file.sizeBytes, |
| 2150 | ...(file.source ? { source: file.source } : {}), | |
| 2147 | 2151 | ...(file.uploadedAttachmentId && file.uploadEnvironmentId |
| 2148 | 2152 | ? { |
| 2149 | 2153 | attachmentId: file.uploadedAttachmentId, |
| ... | ... | @@ -2423,6 +2427,7 @@ function toHydratedThreadDraft( |
| 2423 | 2427 | mimeType: file.mimeType, |
| 2424 | 2428 | sizeBytes: file.sizeBytes, |
| 2425 | 2429 | file: null, |
| 2430 | ...(file.source ? { source: file.source } : {}), | |
| 2426 | 2431 | // A marker without an attachment id hydrates as needs-reattach: no |
| 2427 | 2432 | // bytes, no server-side upload, only the metadata to tell the user |
| 2428 | 2433 | // what to attach again. |
apps/web/src/lib/attachmentUploadQueue.test.ts+5-1| ... | ... | @@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => { |
| 238 | 238 | }); |
| 239 | 239 | |
| 240 | 240 | it("uploads generic files and sends file attachment references", async () => { |
| 241 | const file = makeFile("report"); | |
| 241 | const file = { | |
| 242 | ...makeFile("report"), | |
| 243 | source: { _tag: "pasted-text" as const }, | |
| 244 | }; | |
| 242 | 245 | startAttachmentUpload({ environmentId: firstEnvironment, image: file }); |
| 243 | 246 | await Promise.resolve(); |
| 244 | 247 | |
| ... | ... | @@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => { |
| 268 | 271 | name: "report.pdf", |
| 269 | 272 | mimeType: "application/pdf", |
| 270 | 273 | sizeBytes: 3, |
| 274 | source: { _tag: "pasted-text" }, | |
| 271 | 275 | }, |
| 272 | 276 | ]); |
| 273 | 277 | }); |
apps/web/src/lib/attachmentUploadQueue.ts+1-1| ... | ... | @@ -563,7 +563,7 @@ export function getUploadedAttachments(input: { |
| 563 | 563 | name: image.name, |
| 564 | 564 | mimeType: image.mimeType, |
| 565 | 565 | sizeBytes: image.sizeBytes, |
| 566 | ...(image.type === "image" && image.source ? { source: image.source } : {}), | |
| 566 | ...(image.source ? { source: image.source } : {}), | |
| 567 | 567 | }); |
| 568 | 568 | } |
| 569 | 569 | return attachments; |
apps/web/src/lib/composerContextReferences.test.ts+12| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ensureInlineContextReferences, |
| 7 | 7 | formatInlineContextReference, |
| 8 | 8 | insertInlineContextReference, |
| 9 | inlineContextReferenceReplacement, | |
| 9 | 10 | removeInlineContextReference, |
| 10 | 11 | stripInlineContextReferences, |
| 11 | 12 | toComposerContextId, |
| ... | ... | @@ -18,6 +19,17 @@ const reviewLink = "[a.ts L4](t3-context://v1/review-comment/rc-1)"; |
| 18 | 19 | const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)"; |
| 19 | 20 | |
| 20 | 21 | describe("composerContextReferences", () => { |
| 22 | it.each([ | |
| 23 | { prompt: "before selected after", start: 7, end: 15, expected: `before ${reviewLink} after` }, | |
| 24 | { prompt: "selected", start: 0, end: 8, expected: `${reviewLink} ` }, | |
| 25 | { prompt: "aSELECTb", start: 1, end: 7, expected: `a ${reviewLink} b` }, | |
| 26 | ])( | |
| 27 | "replaces selected text in '$prompt' with the attachment chip", | |
| 28 | ({ prompt, start, end, expected }) => { | |
| 29 | const edit = inlineContextReferenceReplacement(prompt, { start, end }, [review]); | |
| 30 | expect(`${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`).toBe(expected); | |
| 31 | }, | |
| 32 | ); | |
| 21 | 33 | it("formats, collects and strips references of any kind", () => { |
| 22 | 34 | expect(formatInlineContextReference(review)).toBe(reviewLink); |
| 23 | 35 | const prompt = `x ${reviewLink} y ${previewLink} ${reviewLink}`; |
apps/web/src/lib/composerContextReferences.ts+21-6| ... | ... | @@ -93,19 +93,34 @@ function isBoundaryWhitespace(char: string | undefined): boolean { |
| 93 | 93 | return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | /** Replaces a selected range with chips, keeping word boundaries and the trailing caret space. */ | |
| 97 | export function inlineContextReferenceReplacement( | |
| 98 | prompt: string, | |
| 99 | selection: { start: number; end: number }, | |
| 100 | references: ReadonlyArray<ComposerContextReference>, | |
| 101 | ): { start: number; end: number; text: string } { | |
| 102 | const start = Math.max(0, Math.min(prompt.length, Math.floor(selection.start))); | |
| 103 | const end = Math.max(start, Math.min(prompt.length, Math.floor(selection.end))); | |
| 104 | const needsLeadingSpace = !isBoundaryWhitespace(prompt[start - 1]); | |
| 105 | return { | |
| 106 | start, | |
| 107 | end: prompt[end] === " " ? end + 1 : end, | |
| 108 | text: `${needsLeadingSpace ? " " : ""}${references.map(formatInlineContextReference).join(" ")} `, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 96 | 112 | /** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */ |
| 97 | 113 | export function insertInlineContextReference( |
| 98 | 114 | prompt: string, |
| 99 | 115 | cursorInput: number, |
| 100 | 116 | reference: ComposerContextReference, |
| 101 | 117 | ): { prompt: string; cursor: number } { |
| 102 | const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput))); | |
| 103 | const needsLeadingSpace = !isBoundaryWhitespace(prompt[cursor - 1]); | |
| 104 | const replacement = `${needsLeadingSpace ? " " : ""}${formatInlineContextReference(reference)} `; | |
| 105 | const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor; | |
| 118 | const edit = inlineContextReferenceReplacement(prompt, { start: cursorInput, end: cursorInput }, [ | |
| 119 | reference, | |
| 120 | ]); | |
| 106 | 121 | return { |
| 107 | prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`, | |
| 108 | cursor: cursor + replacement.length, | |
| 122 | prompt: `${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`, | |
| 123 | cursor: edit.start + edit.text.length, | |
| 109 | 124 | }; |
| 110 | 125 | } |
| 111 | 126 |
apps/web/src/lib/desktopPasteAsText.test.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { DESKTOP_PASTE_AS_TEXT_EVENT, installDesktopPasteAsText } from "./desktopPasteAsText"; | |
| 4 | ||
| 5 | describe("desktop paste as text", () => { | |
| 6 | it.each([false, true])("pastes with a mounted composer: %s", (hasComposer) => { | |
| 7 | const target = new EventTarget(); | |
| 8 | let menuAction: ((action: string) => void) | undefined; | |
| 9 | const order: string[] = []; | |
| 10 | const bridge = { | |
| 11 | onMenuAction: (listener) => { | |
| 12 | menuAction = listener; | |
| 13 | return () => { | |
| 14 | menuAction = undefined; | |
| 15 | }; | |
| 16 | }, | |
| 17 | pasteAsText: vi.fn(async () => { | |
| 18 | order.push("paste"); | |
| 19 | }), | |
| 20 | } satisfies Pick<DesktopBridge, "onMenuAction" | "pasteAsText">; | |
| 21 | if (hasComposer) | |
| 22 | target.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, () => order.push("armed")); | |
| 23 | const uninstall = installDesktopPasteAsText(bridge, target); | |
| 24 | menuAction?.("open-settings"); | |
| 25 | expect(order).toEqual([]); | |
| 26 | menuAction?.("paste-as-text"); | |
| 27 | expect(order).toEqual(hasComposer ? ["armed", "paste"] : ["paste"]); | |
| 28 | uninstall?.(); | |
| 29 | menuAction?.("paste-as-text"); | |
| 30 | expect(bridge.pasteAsText).toHaveBeenCalledOnce(); | |
| 31 | }); | |
| 32 | }); |
apps/web/src/lib/desktopPasteAsText.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export const DESKTOP_PASTE_AS_TEXT_EVENT = "t3:paste-as-text"; | |
| 4 | ||
| 5 | /** Arm composer paste handling before Electron delivers the native clipboard event. */ | |
| 6 | export function installDesktopPasteAsText( | |
| 7 | bridge: Pick<DesktopBridge, "onMenuAction" | "pasteAsText"> | undefined, | |
| 8 | target: EventTarget, | |
| 9 | ): (() => void) | undefined { | |
| 10 | return bridge?.onMenuAction((action) => { | |
| 11 | if (action !== "paste-as-text") return; | |
| 12 | target.dispatchEvent(new Event(DESKTOP_PASTE_AS_TEXT_EVENT)); | |
| 13 | void bridge.pasteAsText?.(); | |
| 14 | }); | |
| 15 | } |
apps/web/src/questionAttachments.test.ts+41| ... | ... | @@ -3,6 +3,7 @@ import { beforeEach, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import { useComposerDraftStore } from "./composerDraftStore"; |
| 4 | 4 | import { |
| 5 | 5 | questionAttachmentDraftId, |
| 6 | countQuestionAttachments, | |
| 6 | 7 | questionAttachmentDraftPrefix, |
| 7 | 8 | changeQuestionAttachmentPreparation, |
| 8 | 9 | clearQuestionAttachmentDraft, |
| ... | ... | @@ -98,3 +99,43 @@ it("does not clear another environment whose id contains a question prefix", () |
| 98 | 99 | expect(store.getComposerDraft(ownKey)).toBeNull(); |
| 99 | 100 | expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer"); |
| 100 | 101 | }); |
| 102 | ||
| 103 | it("counts files, images, and pending preparation across the shared question budget", () => { | |
| 104 | const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first"); | |
| 105 | const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second"); | |
| 106 | const other = questionAttachmentDraftId( | |
| 107 | EnvironmentId.make("other"), | |
| 108 | threadId, | |
| 109 | requestId, | |
| 110 | "first", | |
| 111 | ); | |
| 112 | const store = useComposerDraftStore.getState(); | |
| 113 | store.addFiles( | |
| 114 | second, | |
| 115 | Array.from({ length: 6 }, (_, index) => ({ | |
| 116 | type: "file" as const, | |
| 117 | id: `file-${index}`, | |
| 118 | name: `spec-${index}.txt`, | |
| 119 | mimeType: "text/plain", | |
| 120 | sizeBytes: 4, | |
| 121 | file: new File(["spec"], `spec-${index}.txt`), | |
| 122 | })), | |
| 123 | ); | |
| 124 | store.addImages(first, [ | |
| 125 | { | |
| 126 | type: "image", | |
| 127 | id: "image", | |
| 128 | name: "image.png", | |
| 129 | mimeType: "image/png", | |
| 130 | sizeBytes: 5, | |
| 131 | previewUrl: "blob:count-test", | |
| 132 | file: new File(["image"], "image.png"), | |
| 133 | }, | |
| 134 | ]); | |
| 135 | changeQuestionAttachmentPreparation(second, 1); | |
| 136 | changeQuestionAttachmentPreparation(other, 8); | |
| 137 | expect(countQuestionAttachments([first, second])).toBe(8); | |
| 138 | expect(countQuestionAttachments([second])).toBe(7); | |
| 139 | changeQuestionAttachmentPreparation(second, -1); | |
| 140 | expect(countQuestionAttachments([first, second])).toBe(7); | |
| 141 | }); |
apps/web/src/questionAttachments.ts+10| ... | ... | @@ -25,6 +25,16 @@ export const useQuestionAttachmentPreparation = create<{ counts: Record<string, |
| 25 | 25 | counts: {}, |
| 26 | 26 | })); |
| 27 | 27 | |
| 28 | /** Count both staged files and in-flight preparation against the shared question limit. */ | |
| 29 | export function countQuestionAttachments(keys: ReadonlyArray<DraftId>): number { | |
| 30 | const store = useComposerDraftStore.getState(); | |
| 31 | const { counts } = useQuestionAttachmentPreparation.getState(); | |
| 32 | return keys.reduce((total, key) => { | |
| 33 | const draft = store.getComposerDraft(key); | |
| 34 | return total + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + (counts[key] ?? 0); | |
| 35 | }, 0); | |
| 36 | } | |
| 37 | ||
| 28 | 38 | export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void { |
| 29 | 39 | useQuestionAttachmentPreparation.setState((state) => |
| 30 | 40 | delta < 0 && !(key in state.counts) |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | } from "../components/KeybindingsUpdateToast.logic"; |
| 68 | 68 | |
| 69 | 69 | import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; |
| 70 | import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; | |
| 70 | 71 | import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; |
| 71 | 72 | |
| 72 | 73 | export const Route = createRootRoute({ |
| ... | ... | @@ -108,6 +109,7 @@ export const Route = createRootRoute({ |
| 108 | 109 | }); |
| 109 | 110 | |
| 110 | 111 | function RootRouteView() { |
| 112 | useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); | |
| 111 | 113 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 112 | 114 | const { authGateState } = Route.useRouteContext(); |
| 113 | 115 | const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; |
docs/user/composer.md+6| ... | ... | @@ -6,6 +6,12 @@ include a skill when the task needs more context. |
| 6 | 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer |
| 7 | 7 | so you can shorten them or split them into several messages. |
| 8 | 8 | |
| 9 | Pasting 32 KiB or more of text adds that fragment as a text-file attachment so | |
| 10 | the agent can inspect it without filling the model context. A smaller paste also | |
| 11 | becomes an attachment when inserting it would exceed the message limit. On a | |
| 12 | hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere | |
| 13 | to keep a large paste editable in the composer instead. | |
| 14 | ||
| 9 | 15 | ## Attach files |
| 10 | 16 | |
| 11 | 17 | Attach up to eight files per message. Images can be up to 10 MB; other files can |
packages/client-runtime/package.json+4| ... | ... | @@ -103,6 +103,10 @@ |
| 103 | 103 | "types": "./src/state/attachments.ts", |
| 104 | 104 | "default": "./src/state/attachments.ts" |
| 105 | 105 | }, |
| 106 | "./text-paste": { | |
| 107 | "types": "./src/textPaste.ts", | |
| 108 | "default": "./src/textPaste.ts" | |
| 109 | }, | |
| 106 | 110 | "./state/connections": { |
| 107 | 111 | "types": "./src/state/connections.ts", |
| 108 | 112 | "default": "./src/state/connections.ts" |
packages/client-runtime/src/textPaste.test.ts created+156| ... | ... | @@ -0,0 +1,156 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | nextPastedTextFileName, | |
| 5 | PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES, | |
| 6 | isPasteAsTextShortcut, | |
| 7 | pastedTextDisposition, | |
| 8 | replaceTextSelection, | |
| 9 | wouldTextPasteExceedLimit, | |
| 10 | } from "./textPaste.ts"; | |
| 11 | ||
| 12 | describe("pasted text disposition", () => { | |
| 13 | it("keeps ordinary text inline and folds at the 32 KiB boundary", () => { | |
| 14 | expect( | |
| 15 | pastedTextDisposition({ | |
| 16 | text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES - 1), | |
| 17 | canAttach: true, | |
| 18 | }), | |
| 19 | ).toBe("inline"); | |
| 20 | expect( | |
| 21 | pastedTextDisposition({ | |
| 22 | text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES), | |
| 23 | canAttach: true, | |
| 24 | }), | |
| 25 | ).toBe("attachment"); | |
| 26 | }); | |
| 27 | ||
| 28 | it("measures UTF-8 bytes instead of UTF-16 characters", () => { | |
| 29 | const text = "🙂".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES / 4); | |
| 30 | expect(text.length).toBeLessThan(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); | |
| 31 | expect(pastedTextDisposition({ text, canAttach: true })).toBe("attachment"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("keeps text inline for the explicit bypass or without attachment support", () => { | |
| 35 | const text = "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); | |
| 36 | expect(pastedTextDisposition({ text, canAttach: true, bypassAutoAttachment: true })).toBe( | |
| 37 | "inline", | |
| 38 | ); | |
| 39 | expect(pastedTextDisposition({ text, canAttach: false })).toBe("inline"); | |
| 40 | }); | |
| 41 | ||
| 42 | it("folds a smaller paste when the resulting prompt would exceed the input limit", () => { | |
| 43 | expect( | |
| 44 | pastedTextDisposition({ | |
| 45 | text: "small paste", | |
| 46 | canAttach: true, | |
| 47 | wouldExceedInputLimit: true, | |
| 48 | }), | |
| 49 | ).toBe("attachment"); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("pasted text attachment names", () => { | |
| 54 | it("uses the first available readable sequence", () => { | |
| 55 | expect(nextPastedTextFileName([])).toBe("pasted-text.txt"); | |
| 56 | expect(nextPastedTextFileName(["PASTED-TEXT.TXT", "pasted-text-2.txt"])).toBe( | |
| 57 | "pasted-text-3.txt", | |
| 58 | ); | |
| 59 | }); | |
| 60 | }); | |
| 61 | ||
| 62 | describe("paste-as-text shortcut", () => { | |
| 63 | it.each([ | |
| 64 | { metaKey: true, ctrlKey: false, macPlatform: true }, | |
| 65 | { metaKey: false, ctrlKey: true, macPlatform: false }, | |
| 66 | ])("accepts the platform modifier with Shift", ({ metaKey, ctrlKey, macPlatform }) => { | |
| 67 | expect( | |
| 68 | isPasteAsTextShortcut( | |
| 69 | { | |
| 70 | key: "V", | |
| 71 | metaKey, | |
| 72 | ctrlKey, | |
| 73 | shiftKey: true, | |
| 74 | altKey: false, | |
| 75 | }, | |
| 76 | macPlatform, | |
| 77 | ), | |
| 78 | ).toBe(true); | |
| 79 | }); | |
| 80 | ||
| 81 | it("does not claim ordinary or alternate paste chords", () => { | |
| 82 | expect( | |
| 83 | isPasteAsTextShortcut( | |
| 84 | { | |
| 85 | key: "v", | |
| 86 | metaKey: true, | |
| 87 | ctrlKey: false, | |
| 88 | shiftKey: false, | |
| 89 | altKey: false, | |
| 90 | }, | |
| 91 | true, | |
| 92 | ), | |
| 93 | ).toBe(false); | |
| 94 | expect( | |
| 95 | isPasteAsTextShortcut( | |
| 96 | { | |
| 97 | key: "v", | |
| 98 | metaKey: true, | |
| 99 | ctrlKey: false, | |
| 100 | shiftKey: true, | |
| 101 | altKey: true, | |
| 102 | }, | |
| 103 | true, | |
| 104 | ), | |
| 105 | ).toBe(false); | |
| 106 | }); | |
| 107 | ||
| 108 | it("rejects the other platform's modifier", () => { | |
| 109 | const event = { | |
| 110 | key: "v", | |
| 111 | metaKey: false, | |
| 112 | ctrlKey: true, | |
| 113 | shiftKey: true, | |
| 114 | altKey: false, | |
| 115 | }; | |
| 116 | expect(isPasteAsTextShortcut(event, true)).toBe(false); | |
| 117 | expect(isPasteAsTextShortcut({ ...event, metaKey: true, ctrlKey: false }, false)).toBe(false); | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | describe("text paste insertion", () => { | |
| 122 | it("replaces the selected range and returns the collapsed cursor", () => { | |
| 123 | expect( | |
| 124 | replaceTextSelection({ | |
| 125 | value: "before old after", | |
| 126 | selection: { start: 7, end: 10 }, | |
| 127 | text: "new", | |
| 128 | }), | |
| 129 | ).toEqual({ value: "before new after", cursor: 10 }); | |
| 130 | }); | |
| 131 | ||
| 132 | it("clamps stale native selections", () => { | |
| 133 | expect( | |
| 134 | replaceTextSelection({ value: "abc", selection: { start: 20, end: 30 }, text: "!" }), | |
| 135 | ).toEqual({ value: "abc!", cursor: 4 }); | |
| 136 | }); | |
| 137 | ||
| 138 | it("measures the replacement value after removing the selected range", () => { | |
| 139 | expect( | |
| 140 | wouldTextPasteExceedLimit({ | |
| 141 | valueLength: 100, | |
| 142 | selection: { start: 40, end: 80 }, | |
| 143 | textLength: 30, | |
| 144 | maxLength: 100, | |
| 145 | }), | |
| 146 | ).toBe(false); | |
| 147 | expect( | |
| 148 | wouldTextPasteExceedLimit({ | |
| 149 | valueLength: 100, | |
| 150 | selection: { start: 40, end: 80 }, | |
| 151 | textLength: 41, | |
| 152 | maxLength: 100, | |
| 153 | }), | |
| 154 | ).toBe(true); | |
| 155 | }); | |
| 156 | }); |
packages/client-runtime/src/textPaste.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | export const PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES = 32 * 1024; | |
| 2 | ||
| 3 | const textEncoder = new TextEncoder(); | |
| 4 | ||
| 5 | export type PastedTextDisposition = "attachment" | "inline"; | |
| 6 | ||
| 7 | export function isPasteAsTextShortcut( | |
| 8 | event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, | |
| 9 | macPlatform: boolean, | |
| 10 | ): boolean { | |
| 11 | return ( | |
| 12 | event.key.toLowerCase() === "v" && | |
| 13 | event.shiftKey && | |
| 14 | !event.altKey && | |
| 15 | (macPlatform ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) | |
| 16 | ); | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * Large clipboard text becomes a file so an agent can inspect it selectively. | |
| 21 | * The threshold is byte-based: character counts substantially understate the | |
| 22 | * context cost of some Unicode-heavy clipboard contents. | |
| 23 | */ | |
| 24 | export function pastedTextDisposition(input: { | |
| 25 | readonly text: string; | |
| 26 | readonly canAttach: boolean; | |
| 27 | readonly bypassAutoAttachment?: boolean; | |
| 28 | readonly wouldExceedInputLimit?: boolean; | |
| 29 | }): PastedTextDisposition { | |
| 30 | if (input.bypassAutoAttachment || !input.canAttach || input.text.length === 0) { | |
| 31 | return "inline"; | |
| 32 | } | |
| 33 | return input.wouldExceedInputLimit || | |
| 34 | input.text.length >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES || | |
| 35 | textEncoder.encode(input.text).byteLength >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES | |
| 36 | ? "attachment" | |
| 37 | : "inline"; | |
| 38 | } | |
| 39 | ||
| 40 | /** Stable, human-readable names when a draft contains several folded pastes. */ | |
| 41 | export function nextPastedTextFileName(existingNames: ReadonlyArray<string>): string { | |
| 42 | const names = new Set(existingNames.map((name) => name.toLowerCase())); | |
| 43 | if (!names.has("pasted-text.txt")) { | |
| 44 | return "pasted-text.txt"; | |
| 45 | } | |
| 46 | for (let sequence = 2; ; sequence += 1) { | |
| 47 | const candidate = `pasted-text-${sequence}.txt`; | |
| 48 | if (!names.has(candidate)) { | |
| 49 | return candidate; | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | export function replaceTextSelection(input: { | |
| 55 | readonly value: string; | |
| 56 | readonly selection: { readonly start: number; readonly end: number }; | |
| 57 | readonly text: string; | |
| 58 | }): { readonly value: string; readonly cursor: number } { | |
| 59 | const start = Math.max(0, Math.min(input.value.length, input.selection.start)); | |
| 60 | const end = Math.max(start, Math.min(input.value.length, input.selection.end)); | |
| 61 | return { | |
| 62 | value: `${input.value.slice(0, start)}${input.text}${input.value.slice(end)}`, | |
| 63 | cursor: start + input.text.length, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | export function wouldTextPasteExceedLimit(input: { | |
| 68 | readonly valueLength: number; | |
| 69 | readonly selection: { readonly start: number; readonly end: number }; | |
| 70 | readonly textLength: number; | |
| 71 | readonly maxLength: number; | |
| 72 | }): boolean { | |
| 73 | const start = Math.max(0, Math.min(input.valueLength, input.selection.start)); | |
| 74 | const end = Math.max(start, Math.min(input.valueLength, input.selection.end)); | |
| 75 | return input.valueLength - (end - start) + input.textLength > input.maxLength; | |
| 76 | } |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -1308,6 +1308,8 @@ export interface DesktopBridge { |
| 1308 | 1308 | * builds lack it; callers fall back to VS Code only. |
| 1309 | 1309 | */ |
| 1310 | 1310 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1311 | /** Present when the desktop shell can perform an ordered plain-text paste. */ | |
| 1312 | pasteAsText?: () => Promise<void>; | |
| 1311 | 1313 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1312 | 1314 | onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; |
| 1313 | 1315 | /** |
packages/contracts/src/orchestration.ts+7| ... | ... | @@ -310,6 +310,9 @@ export const ChatImageAttachment = Schema.Struct({ |
| 310 | 310 | }); |
| 311 | 311 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; |
| 312 | 312 | |
| 313 | export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {}); | |
| 314 | export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type; | |
| 315 | ||
| 313 | 316 | export const ChatFileAttachment = Schema.Struct({ |
| 314 | 317 | type: Schema.Literal("file"), |
| 315 | 318 | id: ChatAttachmentId, |
| ... | ... | @@ -319,6 +322,10 @@ export const ChatFileAttachment = Schema.Struct({ |
| 319 | 322 | Schema.isGreaterThanOrEqualTo(1), |
| 320 | 323 | Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), |
| 321 | 324 | ), |
| 325 | /** Clipboard text folded by a client. Providers keep these path-only so the | |
| 326 | agent can inspect the file selectively instead of eagerly spending the | |
| 327 | same context the fold is intended to preserve. */ | |
| 328 | source: Schema.optional(PastedTextAttachmentSource), | |
| 322 | 329 | }); |
| 323 | 330 | export type ChatFileAttachment = typeof ChatFileAttachment.Type; |
| 324 | 331 |
packages/shared/src/composerContextLegacy.test.ts+16| ... | ... | @@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => { |
| 582 | 582 | ]); |
| 583 | 583 | }); |
| 584 | 584 | |
| 585 | it("keeps adjacent trailing reviews in place, including their original spacing", () => { | |
| 586 | const upgraded = upgradeLegacyContextMessage(`Compare ${review} ${review}`); | |
| 587 | expect(upgraded.text).toBe( | |
| 588 | "Compare [f.ts line](t3-context://v1/review-comment/legacy_review-comment_1) [f.ts line](t3-context://v1/review-comment/legacy_review-comment_2)", | |
| 589 | ); | |
| 590 | }); | |
| 591 | ||
| 592 | it("keeps a trailing review beside a terminal chip whose payload follows it", () => { | |
| 593 | const upgraded = upgradeLegacyContextMessage( | |
| 594 | `@build:7 ${review}\n\n<terminal_context>\n- Build line 7:\n output\n</terminal_context>`, | |
| 595 | ); | |
| 596 | expect(upgraded.text).toBe( | |
| 597 | "[Build line 7](t3-context://v1/terminal/legacy_terminal_1) [f.ts line](t3-context://v1/review-comment/legacy_review-comment_1)", | |
| 598 | ); | |
| 599 | }); | |
| 600 | ||
| 585 | 601 | it("handles the combined legacy send order: terminal, element, preview, review", () => { |
| 586 | 602 | const text = [ |
| 587 | 603 | "See ", |
packages/shared/src/composerContextLegacy.ts+13-6| ... | ... | @@ -339,15 +339,22 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext |
| 339 | 339 | |
| 340 | 340 | // Blocks were appended in send order (terminal, element, preview, review), so they peel |
| 341 | 341 | // off the end in reverse. Each peel exposes the next block as trailing. |
| 342 | // Only reviews that trailed the original text were appended by the old send path; a | |
| 343 | // review that sat before other blocks keeps its place. | |
| 342 | // Peel reviews only when they hide another trailing context block. A review at the end | |
| 343 | // of ordinary prose can still be inline; keep its original spacing and line breaks. | |
| 344 | 344 | const trailingReviewTokens: number[] = []; |
| 345 | 345 | const tokens = trailingReviewTokenPattern.exec(rest); |
| 346 | 346 | if (tokens && tokens[0].length > 0) { |
| 347 | trailingReviewTokens.push( | |
| 348 | ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])), | |
| 349 | ); | |
| 350 | rest = rest.slice(0, tokens.index).replace(/\n+$/, ""); | |
| 347 | const preceding = rest.slice(0, tokens.index); | |
| 348 | if ( | |
| 349 | TRAILING_PREVIEW.test(preceding) || | |
| 350 | TRAILING_ELEMENT.test(preceding) || | |
| 351 | TRAILING_TERMINAL.test(preceding) | |
| 352 | ) { | |
| 353 | trailingReviewTokens.push( | |
| 354 | ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])), | |
| 355 | ); | |
| 356 | rest = preceding; | |
| 357 | } | |
| 351 | 358 | } |
| 352 | 359 | for (;;) { |
| 353 | 360 | const preview = stripTrailing(rest, TRAILING_PREVIEW); |
packages/shared/src/composerContextLegacySend.test.ts+25| ... | ... | @@ -99,6 +99,31 @@ describe("serializeLegacyContextMessage", () => { |
| 99 | 99 | }); |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | it.each([" ", "\n", "\n\n"])( | |
| 103 | "preserves the separator between a skill and a trailing PR through an older server: %j", | |
| 104 | (separator) => { | |
| 105 | const pullRequest = { | |
| 106 | ...review, | |
| 107 | label: "#11440", | |
| 108 | sectionId: "pull-request:11440", | |
| 109 | sectionTitle: "Pull request", | |
| 110 | filePath: "PR #11440", | |
| 111 | rangeLabel: "summary", | |
| 112 | text: "Audit Mobile Photo Import", | |
| 113 | diff: "", | |
| 114 | }; | |
| 115 | const text = `$pr-audit${separator}${formatComposerContextReference(pullRequest)}`; | |
| 116 | const upgraded = upgradeLegacyContextMessage( | |
| 117 | serializeLegacyContextMessage({ text, records: [pullRequest] }), | |
| 118 | ); | |
| 119 | ||
| 120 | expect(upgraded.records).toHaveLength(1); | |
| 121 | expect(upgraded.text).toBe( | |
| 122 | `$pr-audit${separator}${formatComposerContextReference(upgraded.records[0]!)}`, | |
| 123 | ); | |
| 124 | }, | |
| 125 | ); | |
| 126 | ||
| 102 | 127 | it("retains picked-element details for preview annotations sent through an older server", () => { |
| 103 | 128 | const text = `Update ${formatComposerContextReference(annotation)}`; |
| 104 | 129 | const upgraded = upgradeLegacyContextMessage( |