| 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,6 +40,7 @@ import { |
| 40 | openExternal, | 40 | openExternal, |
| 41 | openSystemSettings, | 41 | openSystemSettings, |
| 42 | checkSystemPermission, | 42 | checkSystemPermission, |
| 43 | pasteAsText, | ||
| 43 | probeRemoteEditors, | 44 | probeRemoteEditors, |
| 44 | pickFolder, | 45 | pickFolder, |
| 45 | pickProjectFavicon, | 46 | pickProjectFavicon, |
| ... | @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 124 | yield* ipc.handle(openExternal); | 125 | yield* ipc.handle(openExternal); |
| 125 | yield* ipc.handle(openSystemSettings); | 126 | yield* ipc.handle(openSystemSettings); |
| 126 | yield* ipc.handle(checkSystemPermission); | 127 | yield* ipc.handle(checkSystemPermission); |
| 128 | yield* ipc.handle(pasteAsText); | ||
| 127 | yield* ipc.handle(probeRemoteEditors); | 129 | yield* ipc.handle(probeRemoteEditors); |
| 128 | yield* ipc.handle(getUpdateState); | 130 | yield* ipc.handle(getUpdateState); |
| 129 | yield* ipc.handle(setUpdateChannel); | 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,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; | 7 | export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; |
| 8 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; | 8 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; |
| 9 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 9 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; | ||
| 10 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; | 11 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; |
| 11 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; | 12 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; |
| 12 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; | 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 +6,15 @@ import { vi } from "vite-plus/test"; |
| 6 | 6 | ||
| 7 | import type * as Electron from "electron"; | 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 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | 18 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 10 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 19 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 11 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| ... | @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ... | @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 13 | import { | 22 | import { |
| 14 | getLocalEnvironmentBootstraps, | 23 | getLocalEnvironmentBootstraps, |
| 15 | getWindowFullscreenState, | 24 | getWindowFullscreenState, |
| 25 | pasteAsText, | ||
| 16 | pickProjectFavicon, | 26 | pickProjectFavicon, |
| 17 | } from "./window.ts"; | 27 | } from "./window.ts"; |
| 18 | 28 | ||
| ... | @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => { | ... | @@ -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 | describe("pickProjectFavicon", () => { | 210 | describe("pickProjectFavicon", () => { |
| 157 | it.effect("opens a single-image picker from the project directory", () => | 211 | it.effect("opens a single-image picker from the project directory", () => |
| 158 | Effect.gen(function* () { | 212 | Effect.gen(function* () { |
apps/desktop/src/ipc/methods/window.ts+26| ... | @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ | ... | @@ -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 | /** Theme files are a few KB; anything larger returns empty text and lets the | 374 | /** Theme files are a few KB; anything larger returns empty text and lets the |
| 349 | * renderer reject it by size without the contents ever crossing the bridge. */ | 375 | * renderer reject it by size without the contents ever crossing the bridge. */ |
| 350 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; | 376 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; |
apps/desktop/src/preload.ts+1| ... | @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 157 | openSystemSettings: (pane: string) => | 157 | openSystemSettings: (pane: string) => |
| 158 | ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), | 158 | ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), |
| 159 | probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), | 159 | probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), |
| 160 | pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), | ||
| 160 | onMenuAction: (listener) => { | 161 | onMenuAction: (listener) => { |
| 161 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | 162 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 162 | if (typeof action !== "string") return; | 163 | if (typeof action !== "string") return; |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+30| ... | @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { | ... | @@ -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 | // Zoom must route through DesktopWindow.zoomMain instead of the Electron | 186 | // Zoom must route through DesktopWindow.zoomMain instead of the Electron |
| 157 | // zoom roles: the roles zoom whichever webContents has focus, which breaks | 187 | // zoom roles: the roles zoom whichever webContents has focus, which breaks |
| 158 | // app zoom while an embedded preview WebContentsView holds focus. | 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,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function |
| 46 | action: string, | 46 | action: string, |
| 47 | ): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> { | 47 | ): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> { |
| 48 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | 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 | const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( | 54 | const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( |
| ... | @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { | ... | @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { |
| 135 | const settingsClick = () => { | 137 | const settingsClick = () => { |
| 136 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); | 138 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); |
| 137 | }; | 139 | }; |
| 140 | const pasteAsTextClick = () => { | ||
| 141 | runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); | ||
| 142 | }; | ||
| 138 | const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { | 143 | const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { |
| 139 | runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); | 144 | runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); |
| 140 | }; | 145 | }; |
| ... | @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { | ... | @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { |
| 184 | { role: environment.platform === "darwin" ? "close" : "quit" }, | 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 | label: "View", | 221 | label: "View", |
| 190 | submenu: [ | 222 | submenu: [ |
apps/mobile/modules/t3-composer-editor/android/build.gradle+12| ... | @@ -8,6 +8,10 @@ android { | ... | @@ -8,6 +8,10 @@ android { |
| 8 | namespace 'expo.modules.t3composereditor' | 8 | namespace 'expo.modules.t3composereditor' |
| 9 | compileSdk rootProject.ext.compileSdkVersion | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | ||
| 11 | testOptions { | ||
| 12 | unitTests.includeAndroidResources = true | ||
| 13 | } | ||
| 14 | |||
| 11 | defaultConfig { | 15 | defaultConfig { |
| 12 | minSdkVersion rootProject.ext.minSdkVersion | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | @@ -17,4 +21,12 @@ android { | ... | @@ -17,4 +21,12 @@ android { |
| 17 | dependencies { | 21 | dependencies { |
| 18 | implementation project(':expo-modules-core') | 22 | implementation project(':expo-modules-core') |
| 19 | implementation project(':t3tools-mobile-markdown-text') | 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,6 +116,12 @@ class T3ComposerEditorModule : Module() { |
| 116 | Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> | 116 | Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> |
| 117 | view.setSpellCheck(spellCheck) | 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 | Events( | 126 | Events( |
| 121 | "onComposerChange", | 127 | "onComposerChange", |
| ... | @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() { | ... | @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() { |
| 125 | "onComposerPasteImages", | 131 | "onComposerPasteImages", |
| 126 | "onComposerContextPress", | 132 | "onComposerContextPress", |
| 127 | "onComposerPasteContext", | 133 | "onComposerPasteContext", |
| 134 | "onComposerPasteText", | ||
| 128 | "onComposerContentSizeChange", | 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,7 +1,8 @@ |
| 1 | package expo.modules.t3composereditor | 1 | package expo.modules.t3composereditor |
| 2 | 2 | ||
| 3 | import android.content.Context | 3 | import android.content.ClipData |
| 4 | import android.content.ClipboardManager | 4 | import android.content.ClipboardManager |
| 5 | import android.content.Context | ||
| 5 | import android.graphics.Color | 6 | import android.graphics.Color |
| 6 | import android.graphics.Canvas | 7 | import android.graphics.Canvas |
| 7 | import android.graphics.Paint | 8 | import android.graphics.Paint |
| ... | @@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 48 | private val onComposerPasteImages by EventDispatcher() | 49 | private val onComposerPasteImages by EventDispatcher() |
| 49 | private val onComposerContextPress by EventDispatcher() | 50 | private val onComposerContextPress by EventDispatcher() |
| 50 | private val onComposerPasteContext by EventDispatcher() | 51 | private val onComposerPasteContext by EventDispatcher() |
| 52 | private val onComposerPasteText by EventDispatcher() | ||
| 51 | private val onComposerContentSizeChange by EventDispatcher() | 53 | private val onComposerContentSizeChange by EventDispatcher() |
| 52 | private var applyingNativeValue = false | 54 | private var applyingNativeValue = false |
| 53 | private var desiredLineHeightPx = 0 | 55 | private var desiredLineHeightPx = 0 |
| ... | @@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 86 | editor.pasteImagesListener = { uris -> | 88 | editor.pasteImagesListener = { uris -> |
| 87 | onComposerPasteImages(mapOf("uris" to uris)) | 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 | val contextGestures = | 101 | val contextGestures = |
| 91 | GestureDetector( | 102 | GestureDetector( |
| 92 | context, | 103 | context, |
| ... | @@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 120 | contextGestures.onTouchEvent(event) | 131 | contextGestures.onTouchEvent(event) |
| 121 | false | 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 | editor.setOnFocusChangeListener { _, hasFocus -> | 145 | editor.setOnFocusChangeListener { _, hasFocus -> |
| 124 | if (hasFocus) { | 146 | if (hasFocus) { |
| 125 | onComposerFocus(emptyMap<String, Any>()) | 147 | onComposerFocus(emptyMap<String, Any>()) |
| ... | @@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 305 | updateInputFlags() | 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 | fun focusEditor() { | 338 | fun focusEditor() { |
| 309 | editor.requestFocus() | 339 | editor.requestFocus() |
| 310 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager | 340 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager |
| ... | @@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 365 | editor.highlightColor = defaultHighlightColor | 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 | mapOf( | 402 | mapOf( |
| 370 | "start" to editor.selectionStart.coerceAtLeast(0), | 403 | "start" to minOf(start, end).coerceAtLeast(0), |
| 371 | "end" to editor.selectionEnd.coerceAtLeast(0), | 404 | "end" to maxOf(start, end).coerceAtLeast(0), |
| 372 | ) | 405 | ) |
| 373 | 406 | ||
| 374 | private fun emitSelectionChange(start: Int, end: Int) { | 407 | private fun emitSelectionChange(start: Int, end: Int) { |
| ... | @@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( | ... | @@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 379 | onComposerSelectionChange( | 412 | onComposerSelectionChange( |
| 380 | mapOf( | 413 | mapOf( |
| 381 | "value" to editor.text.toString(), | 414 | "value" to editor.text.toString(), |
| 382 | "selection" to mapOf("start" to start, "end" to end), | 415 | "selection" to currentSelectionPayload(start, end), |
| 383 | "eventCount" to nativeEventCount, | 416 | "eventCount" to nativeEventCount, |
| 384 | ), | 417 | ), |
| 385 | ) | 418 | ) |
| ... | @@ -544,11 +577,14 @@ private fun parseTokens(value: String): List<ComposerToken> = try { | ... | @@ -544,11 +577,14 @@ private fun parseTokens(value: String): List<ComposerToken> = try { |
| 544 | emptyList() | 577 | emptyList() |
| 545 | } | 578 | } |
| 546 | 579 | ||
| 547 | private class SelectionAwareEditText(context: Context) : EditText(context) { | 580 | internal class SelectionAwareEditText(context: Context) : EditText(context) { |
| 548 | var readOnly = false | 581 | var readOnly = false |
| 549 | var selectionListener: ((Int, Int) -> Unit)? = null | 582 | var selectionListener: ((Int, Int) -> Unit)? = null |
| 550 | var pasteImagesListener: ((List<String>) -> Unit)? = null | 583 | var pasteImagesListener: ((List<String>) -> Unit)? = null |
| 551 | var pasteContextListener: ((Map<String, String>) -> Unit)? = null | 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 | var clipboardFragment = "" | 588 | var clipboardFragment = "" |
| 553 | 589 | ||
| 554 | private fun deleteChip(backwards: Boolean): Boolean { | 590 | private fun deleteChip(backwards: Boolean): Boolean { |
| ... | @@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { | ... | @@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 602 | super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) | 638 | super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) |
| 603 | } | 639 | } |
| 604 | } | 640 | } |
| 605 | |||
| 606 | override fun onSelectionChanged(selStart: Int, selEnd: Int) { | 641 | override fun onSelectionChanged(selStart: Int, selEnd: Int) { |
| 607 | super.onSelectionChanged(selStart, selEnd) | 642 | super.onSelectionChanged(selStart, selEnd) |
| 608 | selectionListener?.invoke(selStart, selEnd) | 643 | selectionListener?.invoke(selStart, selEnd) |
| ... | @@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { | ... | @@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 615 | } | 650 | } |
| 616 | val handled = when { | 651 | val handled = when { |
| 617 | id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut) | 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 | pasting -> pasteContextOrImages() | 657 | pasting -> pasteContextOrImages() |
| 619 | else -> false | 658 | else -> false |
| 620 | } | 659 | } |
| ... | @@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { | ... | @@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 636 | pasteContextListener?.invoke(payload) | 675 | pasteContextListener?.invoke(payload) |
| 637 | return true | 676 | return true |
| 638 | } | 677 | } |
| 678 | return pasteImagesOrInterceptedText() | ||
| 679 | } | ||
| 680 | |||
| 681 | private fun pasteImagesOrInterceptedText(): Boolean { | ||
| 639 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager | 682 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager |
| 640 | val clip = clipboard?.primaryClip | 683 | val clip = clipboard?.primaryClip |
| 641 | val imageUris = buildList { | 684 | val imageUris = buildList { |
| ... | @@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { | ... | @@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { |
| 648 | } | 691 | } |
| 649 | } | 692 | } |
| 650 | } | 693 | } |
| 651 | if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris) | 694 | return when { |
| 652 | return imageUris.isNotEmpty() | 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,6 +87,12 @@ public class T3ComposerEditorModule: Module { |
| 87 | Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in | 87 | Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in |
| 88 | view.setSpellCheck(spellCheck) | 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 | Events( | 97 | Events( |
| 92 | "onComposerChange", | 98 | "onComposerChange", |
| ... | @@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module { | ... | @@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module { |
| 97 | "onComposerPasteImages", | 103 | "onComposerPasteImages", |
| 98 | "onComposerContextPress", | 104 | "onComposerContextPress", |
| 99 | "onComposerPasteContext", | 105 | "onComposerPasteContext", |
| 106 | "onComposerPasteText", | ||
| 100 | "onComposerContentSizeChange" | 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,10 +86,14 @@ private final class ComposerTextView: UITextView { |
| 86 | 86 | ||
| 87 | var onPasteImages: (([String]) -> Void)? | 87 | var onPasteImages: (([String]) -> Void)? |
| 88 | var onPasteContext: (([String: String]) -> Void)? | 88 | var onPasteContext: (([String: String]) -> Void)? |
| 89 | var onPasteText: ((String, NSRange) -> Void)? | ||
| 89 | var clipboardFragment = "" | 90 | var clipboardFragment = "" |
| 90 | var onAttributedMutation: (() -> Void)? | 91 | var onAttributedMutation: (() -> Void)? |
| 91 | var onSubmit: (() -> Void)? | 92 | var onSubmit: (() -> Void)? |
| 92 | var isReadOnly = false | 93 | var isReadOnly = false |
| 94 | var textPasteThresholdBytes = 0 | ||
| 95 | var maxInputChars = Int.max | ||
| 96 | private var bypassTextPasteInterception = false | ||
| 93 | 97 | ||
| 94 | override var keyCommands: [UIKeyCommand]? { | 98 | override var keyCommands: [UIKeyCommand]? { |
| 95 | var commands = super.keyCommands ?? [] | 99 | var commands = super.keyCommands ?? [] |
| ... | @@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView { | ... | @@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView { |
| 101 | submit.discoverabilityTitle = "Send Message" | 105 | submit.discoverabilityTitle = "Send Message" |
| 102 | submit.wantsPriorityOverSystemBehavior = true | 106 | submit.wantsPriorityOverSystemBehavior = true |
| 103 | commands.append(submit) | 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 | return commands | 118 | return commands |
| 105 | } | 119 | } |
| 106 | 120 | ||
| ... | @@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView { | ... | @@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView { |
| 108 | onSubmit?() | 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 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { | 134 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 112 | if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { | 135 | if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { |
| 113 | return false | 136 | return false |
| ... | @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { | ... | @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { |
| 150 | return | 173 | return |
| 151 | } | 174 | } |
| 152 | } | 175 | } |
| 176 | if !bypassTextPasteInterception, | ||
| 177 | let text = pasteboard.string, shouldInterceptTextPaste(text) { | ||
| 178 | onPasteText?(text, selectedRange) | ||
| 179 | return | ||
| 180 | } | ||
| 153 | super.paste(sender) | 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 | override func deleteBackward() { | 198 | override func deleteBackward() { |
| 157 | guard !isReadOnly else { | 199 | guard !isReadOnly else { |
| 158 | return | 200 | return |
| ... | @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro | ... | @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 368 | let onComposerPasteImages = EventDispatcher() | 410 | let onComposerPasteImages = EventDispatcher() |
| 369 | let onComposerContextPress = EventDispatcher() | 411 | let onComposerContextPress = EventDispatcher() |
| 370 | let onComposerPasteContext = EventDispatcher() | 412 | let onComposerPasteContext = EventDispatcher() |
| 413 | let onComposerPasteText = EventDispatcher() | ||
| 371 | let onComposerContentSizeChange = EventDispatcher() | 414 | let onComposerContentSizeChange = EventDispatcher() |
| 372 | 415 | ||
| 373 | public required init(appContext: AppContext? = nil) { | 416 | public required init(appContext: AppContext? = nil) { |
| ... | @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro | ... | @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 387 | self?.onComposerPasteImages(["uris": urls]) | 430 | self?.onComposerPasteImages(["uris": urls]) |
| 388 | } | 431 | } |
| 389 | textView.onPasteContext = { [weak self] context in | 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 | textView.onAttributedMutation = { [weak self] in | 453 | textView.onAttributedMutation = { [weak self] in |
| 393 | self?.emitTextChange() | 454 | self?.emitTextChange() |
| ... | @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro | ... | @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro |
| 596 | textView.spellCheckingType = spellCheck ? .yes : .no | 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 | func focusEditor() { | 668 | func focusEditor() { |
| 600 | textView.becomeFirstResponder() | 669 | textView.becomeFirstResponder() |
| 601 | } | 670 | } |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+9| ... | @@ -33,6 +33,15 @@ using namespace facebook::react; | ... | @@ -33,6 +33,15 @@ using namespace facebook::react; |
| 33 | @end | 33 | @end |
| 34 | 34 | ||
| 35 | @implementation T3ContextCopyTextView | 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 | - (void)copy:(id)sender | 45 | - (void)copy:(id)sender |
| 37 | { | 46 | { |
| 38 | NSRange selected = self.selectedRange; | 47 | NSRange selected = self.selectedRange; |
apps/mobile/src/components/ComposerAttachmentStrip.tsx+3-1| ... | @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { | ... | @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { |
| 226 | // The document picker types every pick as a plain file, so a picture arrives here as one. | 226 | // The document picker types every pick as a plain file, so a picture arrives here as one. |
| 227 | // What it *is* decides how it presents, the same way videos are already recognised below. | 227 | // What it *is* decides how it presents, the same way videos are already recognised below. |
| 228 | if (attachment.type === "image" || imageMimeType(attachment) !== null) { | 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 | return ( | 231 | return ( |
| 230 | <ComposerImageAttachment | 232 | <ComposerImageAttachment |
| 231 | {...props} | 233 | {...props} |
| 232 | attachment={ | 234 | attachment={ |
| 233 | attachment.type === "image" | 235 | attachment.type === "image" |
| 234 | ? attachment | 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,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native |
| 9 | import { | 9 | import { |
| 10 | appendComposerDraftAttachments, | 10 | appendComposerDraftAttachments, |
| 11 | createComposerDraftContextHistory, | 11 | createComposerDraftContextHistory, |
| 12 | getComposerDraftAfterSelection, | ||
| 12 | getComposerDraftSnapshot, | 13 | getComposerDraftSnapshot, |
| 13 | insertComposerDraftContext, | 14 | insertComposerDraftContext, |
| 15 | insertComposerDraftText, | ||
| 14 | rememberComposerDraftSelection, | 16 | rememberComposerDraftSelection, |
| 15 | setComposerDraftContext, | 17 | setComposerDraftContext, |
| 16 | setComposerContextImporting, | 18 | setComposerContextImporting, |
| 17 | useComposerDraft, | 19 | useComposerDraft, |
| 18 | } from "../state/use-composer-drafts"; | 20 | } from "../state/use-composer-drafts"; |
| 19 | import { | 21 | import { importComposerContextClipboard } from "../lib/composerContextClipboard"; |
| 20 | importComposerContextClipboard, | ||
| 21 | type NativeContextClipboard, | ||
| 22 | } from "../lib/composerContextClipboard"; | ||
| 23 | import { ComposerContextSheet } from "./ComposerContextSheet"; | 22 | import { ComposerContextSheet } from "./ComposerContextSheet"; |
| 24 | import { AppText as Text } from "./AppText"; | 23 | import { AppText as Text } from "./AppText"; |
| 25 | import { | 24 | import { |
| ... | @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { | ... | @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { |
| 34 | readonly onOpenMention?: (path: string) => void; | 33 | readonly onOpenMention?: (path: string) => void; |
| 35 | /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ | 34 | /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ |
| 36 | readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; | 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 | export function ComposerEditor({ | 46 | export function ComposerEditor({ |
| ... | @@ -41,6 +48,8 @@ export function ComposerEditor({ | ... | @@ -41,6 +48,8 @@ export function ComposerEditor({ |
| 41 | environmentId, | 48 | environmentId, |
| 42 | onOpenMention, | 49 | onOpenMention, |
| 43 | onOpenAttachment, | 50 | onOpenAttachment, |
| 51 | chipsInert, | ||
| 52 | onInertChipPress, | ||
| 44 | ...props | 53 | ...props |
| 45 | }: ComposerEditorProps) { | 54 | }: ComposerEditorProps) { |
| 46 | const draft = useComposerDraft(draftKey ?? null); | 55 | const draft = useComposerDraft(draftKey ?? null); |
| ... | @@ -68,40 +77,35 @@ export function ComposerEditor({ | ... | @@ -68,40 +77,35 @@ export function ComposerEditor({ |
| 68 | }, | 77 | }, |
| 69 | [draftKey], | 78 | [draftKey], |
| 70 | ); | 79 | ); |
| 71 | const pasteContext = async (clipboard: NativeContextClipboard) => { | 80 | const pasteContext = async ( |
| 81 | clipboard: Parameters<NonNullable<NativeComposerEditorProps["onPasteContext"]>>[0], | ||
| 82 | ) => { | ||
| 72 | if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; | 83 | if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; |
| 84 | const insertion = { text: clipboard.value, ...clipboard.selection }; | ||
| 73 | const controller = new AbortController(); | 85 | const controller = new AbortController(); |
| 74 | importRef.current = controller; | 86 | importRef.current = controller; |
| 75 | setImporting(true); | 87 | setImporting(true); |
| 76 | setComposerContextImporting(draftKey, true); | 88 | setComposerContextImporting(draftKey, true); |
| 77 | try { | 89 | try { |
| 90 | const retained = getComposerDraftAfterSelection(draftKey, insertion); | ||
| 78 | const result = await importComposerContextClipboard( | 91 | const result = await importComposerContextClipboard( |
| 79 | clipboard, | 92 | clipboard, |
| 80 | getComposerDraftSnapshot(draftKey).attachments.length, | 93 | retained.attachments.length, |
| 81 | controller.signal, | 94 | controller.signal, |
| 82 | getComposerDraftSnapshot(draftKey).context?.records.length ?? 0, | 95 | retained.context?.records.length ?? 0, |
| 83 | ); | 96 | ); |
| 84 | if (!result) { | 97 | if (!result) { |
| 85 | insertComposerDraftContext(draftKey, { | 98 | insertComposerDraftText(draftKey, clipboard.text, insertion); |
| 86 | text: clipboard.text, | ||
| 87 | context: { version: 1, records: [] }, | ||
| 88 | }); | ||
| 89 | return; | 99 | return; |
| 90 | } | 100 | } |
| 91 | const rejected = appendComposerDraftAttachments(draftKey, result.attachments); | 101 | if (!insertComposerDraftContext(draftKey, result, insertion)) { |
| 92 | const ids = new Set( | 102 | Alert.alert( |
| 93 | getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), | 103 | "Could not paste context", |
| 94 | ); | 104 | "Remove some attachments or context items from the draft, then paste again.", |
| 95 | insertComposerDraftContext(draftKey, { | 105 | ); |
| 96 | text: result.text, | 106 | return; |
| 97 | context: { | 107 | } |
| 98 | version: 1, | 108 | if (result.failures.length > 0) |
| 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) | ||
| 105 | Alert.alert( | 109 | Alert.alert( |
| 106 | "Some attachments could not be copied", | 110 | "Some attachments could not be copied", |
| 107 | "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", | 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,6 +165,10 @@ export function ComposerEditor({ |
| 161 | onPasteContext={(clipboard) => void pasteContext(clipboard)} | 165 | onPasteContext={(clipboard) => void pasteContext(clipboard)} |
| 162 | context={draft.context} | 166 | context={draft.context} |
| 163 | onContextPress={(selection) => { | 167 | onContextPress={(selection) => { |
| 168 | if (chipsInert) { | ||
| 169 | onInertChipPress?.(); | ||
| 170 | return; | ||
| 171 | } | ||
| 164 | const path = composerMentionPath(selection.source, draft.context); | 172 | const path = composerMentionPath(selection.source, draft.context); |
| 165 | if (path && onOpenMention) { | 173 | if (path && onOpenMention) { |
| 166 | onOpenMention(path); | 174 | onOpenMention(path); |
| ... | @@ -174,7 +182,12 @@ export function ComposerEditor({ | ... | @@ -174,7 +182,12 @@ export function ComposerEditor({ |
| 174 | setSelected(selection); | 182 | setSelected(selection); |
| 175 | }} | 183 | }} |
| 176 | onSelectionChange={(selection) => { | 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 | props.onSelectionChange?.(selection); | 191 | props.onSelectionChange?.(selection); |
| 179 | }} | 192 | }} |
| 180 | /> | 193 | /> |
| ... | @@ -229,4 +242,8 @@ export function ComposerEditor({ | ... | @@ -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,6 +20,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 20 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 20 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 21 | import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; | 21 | import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; |
| 22 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 22 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 23 | import { SourceFileSurface } from "./SourceFileSurface"; | 24 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 24 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; | 25 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 25 | 26 | ||
| ... | @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { | ... | @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { |
| 99 | </Text> | 100 | </Text> |
| 100 | </View> | 101 | </View> |
| 101 | ) : null} | 102 | ) : null} |
| 102 | {table && document.rendered ? ( | 103 | {table && document.activeMode === "table" ? ( |
| 103 | <ScrollView className="flex-1"> | 104 | <ScrollView className="flex-1"> |
| 104 | {table.truncated ? ( | 105 | {table.truncated ? ( |
| 105 | <View className="border-b border-warning-border bg-warning px-4 py-2"> | 106 | <View className="border-b border-warning-border bg-warning px-4 py-2"> |
| ... | @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { | ... | @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { |
| 137 | </View> | 138 | </View> |
| 138 | </ScrollView> | 139 | </ScrollView> |
| 139 | </ScrollView> | 140 | </ScrollView> |
| 140 | ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( | 141 | ) : document.activeMode === "markdown" && props.environmentId ? ( |
| 141 | <FileMarkdownPreview | 142 | <FileMarkdownPreview |
| 142 | cwd="" | 143 | cwd="" |
| 143 | relativePath="" | 144 | relativePath="" |
| ... | @@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: { | ... | @@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: { |
| 147 | captured | 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 | </View> | 153 | </View> |
| 153 | ); | 154 | ); |
| ... | @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { | ... | @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { |
| 175 | 176 | ||
| 176 | export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | 177 | export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 177 | const navigation = useNavigation(); | 178 | const navigation = useNavigation(); |
| 179 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | ||
| 178 | const iconColor = useUniwindTheme()["--color-icon"]; | 180 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 179 | const isAndroid = Platform.OS === "android"; | 181 | const isAndroid = Platform.OS === "android"; |
| 180 | const params = props.route.params; | 182 | const params = props.route.params; |
| ... | @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | ... | @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 249 | handleBack(); | 251 | handleBack(); |
| 250 | }, [draftKey, handleBack, params.attachmentId]); | 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 | const menuActions = useMemo( | 255 | const menuActions = useMemo( |
| 254 | () => | 256 | () => |
| 255 | [ | 257 | [ |
| ... | @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | ... | @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 271 | onPress: () => setRendered(false), | 273 | onPress: () => setRendered(false), |
| 272 | } as const) | 274 | } as const) |
| 273 | : null, | 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 | content | 285 | content |
| 275 | ? ({ | 286 | ? ({ |
| 276 | id: "copy", | 287 | id: "copy", |
| ... | @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | ... | @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 312 | } as const) | 323 | } as const) |
| 313 | : null, | 324 | : null, |
| 314 | ].filter((action) => action !== null), | 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 | const androidMenuActions = useMemo<MenuAction[]>( | 341 | const androidMenuActions = useMemo<MenuAction[]>( |
| 319 | () => | 342 | () => |
| 320 | menuActions.map((action) => ({ | 343 | menuActions.map((action) => ({ |
| 321 | id: action.id, | 344 | id: action.id, |
| 322 | title: action.title, | 345 | title: action.title, |
| 323 | image: action.icon, | 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 | ...("destructive" in action ? { attributes: { destructive: true } } : {}), | 348 | ...("destructive" in action ? { attributes: { destructive: true } } : {}), |
| 326 | })), | 349 | })), |
| 327 | [activeMode, menuActions], | 350 | [selectedAction, menuActions], |
| 328 | ); | 351 | ); |
| 329 | const handleAndroidMenuAction = useCallback( | 352 | const handleAndroidMenuAction = useCallback( |
| 330 | (event: { nativeEvent: { event: string } }) => { | 353 | (event: { nativeEvent: { event: string } }) => { |
| ... | @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | ... | @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 372 | <NativeHeaderToolbar.MenuAction | 395 | <NativeHeaderToolbar.MenuAction |
| 373 | key={action.id} | 396 | key={action.id} |
| 374 | icon={action.icon} | 397 | icon={action.icon} |
| 375 | isOn={action.id === activeMode} | 398 | isOn={action.id === selectedAction} |
| 376 | onPress={action.onPress} | 399 | onPress={action.onPress} |
| 377 | > | 400 | > |
| 378 | {action.title} | 401 | {action.title} |
apps/mobile/src/features/files/SourceFileSurface.tsx+124-19| ... | @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | import type { ComponentType } from "react"; | 3 | import type { ComponentType } from "react"; |
| 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 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 | import { AppText as Text } from "../../components/AppText"; | 14 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { LoadingStrip } from "../../components/LoadingStrip"; | 15 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| ... | @@ -23,13 +30,17 @@ import { | ... | @@ -23,13 +30,17 @@ import { |
| 23 | NATIVE_SOURCE_CONTENT_WIDTH, | 30 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 24 | nativeSourceRowId, | 31 | nativeSourceRowId, |
| 25 | } from "./nativeSourceFileAdapter"; | 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 | import { sourceHighlightAtom } from "./sourceHighlightingState"; | 36 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 28 | 37 | ||
| 29 | interface SourceFileSurfaceProps { | 38 | interface SourceFileSurfaceProps { |
| 30 | readonly contents: string; | 39 | readonly contents: string; |
| 31 | readonly path: string; | 40 | readonly path: string; |
| 32 | readonly initialLine?: number | null; | 41 | readonly initialLine?: number | null; |
| 42 | /** Keep the entire document in one native text-selection scope. */ | ||
| 43 | readonly selectable?: boolean; | ||
| 33 | /** Enables native pull-to-refresh on the source surface. */ | 44 | /** Enables native pull-to-refresh on the source surface. */ |
| 34 | readonly onRefresh?: () => Promise<void> | void; | 45 | readonly onRefresh?: () => Promise<void> | void; |
| 35 | } | 46 | } |
| ... | @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { | ... | @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 129 | ? "ready" | 140 | ? "ready" |
| 130 | : "highlighting"; | 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 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { | 146 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { |
| ... | @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat | ... | @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat |
| 146 | return null; | 157 | return null; |
| 147 | } | 158 | } |
| 148 | 159 | ||
| 149 | function NativeSourceFileSurface( | 160 | function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { |
| 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 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | 161 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 161 | const handlePullToRefresh = useCallback(async () => { | 162 | const handlePullToRefresh = useCallback(async () => { |
| 162 | if (!onRefresh) { | 163 | if (!onRefresh) { |
| ... | @@ -169,6 +170,21 @@ function NativeSourceFileSurface( | ... | @@ -169,6 +170,21 @@ function NativeSourceFileSurface( |
| 169 | setIsPullRefreshing(false); | 170 | setIsPullRefreshing(false); |
| 170 | } | 171 | } |
| 171 | }, [onRefresh]); | 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 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); | 188 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); |
| 173 | const selectedRowIdsJson = useMemo( | 189 | const selectedRowIdsJson = useMemo( |
| 174 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), | 190 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), |
| ... | @@ -212,9 +228,18 @@ function NativeSourceFileSurface( | ... | @@ -212,9 +228,18 @@ function NativeSourceFileSurface( |
| 212 | } | 228 | } |
| 213 | 229 | ||
| 214 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | 230 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 231 | const foreground = useUniwindTheme()["--color-foreground"]; | ||
| 215 | const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); | 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 | const listRef = useRef<FlatList<string>>(null); | 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 | useEffect(() => { | 244 | useEffect(() => { |
| 220 | if (targetIndex === null) { | 245 | if (targetIndex === null) { |
| ... | @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 240 | [codeSurface, codeWordBreak, targetIndex, tokens], | 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 | const list = ( | 317 | const list = ( |
| 244 | <FlatList | 318 | <FlatList |
| 245 | ref={listRef} | 319 | ref={listRef} |
| 320 | refreshControl={refreshControl} | ||
| 246 | data={lines} | 321 | data={lines} |
| 247 | keyExtractor={(_line, index) => String(index)} | 322 | keyExtractor={(_line, index) => String(index)} |
| 248 | initialNumToRender={80} | 323 | initialNumToRender={80} |
| ... | @@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -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 | return ( | 361 | return ( |
| 270 | <View className="relative flex-1 bg-sheet"> | 362 | <View className="relative flex-1 bg-sheet"> |
| 271 | <SourceHighlightStatusView status={status} /> | 363 | <SourceHighlightStatusView status={status} /> |
| 272 | {codeWordBreak ? ( | 364 | {usesLineList ? ( |
| 273 | list | 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 | <ScrollView horizontal bounces={false} className="flex-1"> | 376 | <ScrollView horizontal bounces={false} className="flex-1"> |
| 276 | {list} | 377 | {padded} |
| 277 | </ScrollView> | 378 | </ScrollView> |
| 278 | )} | 379 | )} |
| 279 | </View> | 380 | </View> |
| ... | @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 282 | 383 | ||
| 283 | export function SourceFileSurface(props: SourceFileSurfaceProps) { | 384 | export function SourceFileSurface(props: SourceFileSurfaceProps) { |
| 284 | const NativeView = resolveNativeReviewDiffView(); | 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 | <NativeSourceFileSurface {...props} NativeView={NativeView} /> | 391 | <NativeSourceFileSurface {...props} NativeView={NativeView} /> |
| 287 | ) : ( | 392 | ) : ( |
| 288 | <JavaScriptSourceFileSurface {...props} /> | 393 | <JavaScriptSourceFileSurface {...props} /> |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+26| ... | @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 583 | useAdaptiveWorkspacePaneRole("inspector"); | 583 | useAdaptiveWorkspacePaneRole("inspector"); |
| 584 | const navigation = useNavigation(); | 584 | const navigation = useNavigation(); |
| 585 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | 585 | const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 586 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | ||
| 586 | const iconColor = useUniwindTheme()["--color-icon"]; | 587 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 587 | const isAndroid = Platform.OS === "android"; | 588 | const isAndroid = Platform.OS === "android"; |
| 588 | const params = props.route.params; | 589 | const params = props.route.params; |
| ... | @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 763 | onPress: () => setModeOverride({ path: relativePath, mode: "source" }), | 764 | onPress: () => setModeOverride({ path: relativePath, mode: "source" }), |
| 764 | } as const) | 765 | } as const) |
| 765 | : null, | 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 | ...(mediaSource | 777 | ...(mediaSource |
| 767 | ? mediaActions.actions | 778 | ? mediaActions.actions |
| 768 | .filter(({ id }) => id !== "open-file") | 779 | .filter(({ id }) => id !== "open-file") |
| ... | @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 783 | onPress: () => copyTextWithHaptic(relativePath), | 794 | onPress: () => copyTextWithHaptic(relativePath), |
| 784 | } as const, | 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 | isPdfFile({ name: relativePath }) && previewUri !== null | 808 | isPdfFile({ name: relativePath }) && previewUri !== null |
| 787 | ? ({ | 809 | ? ({ |
| 788 | id: "open-pdf", | 810 | id: "open-pdf", |
| ... | @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 821 | : null, | 843 | : null, |
| 822 | ].filter((action) => action !== null); | 844 | ].filter((action) => action !== null); |
| 823 | }, [ | 845 | }, [ |
| 846 | appearance.codeWordBreak, | ||
| 847 | setCodeWordBreak, | ||
| 824 | assetPreviewUri, | 848 | assetPreviewUri, |
| 825 | assetPreview.refresh, | 849 | assetPreview.refresh, |
| 826 | previewUri, | 850 | previewUri, |
| ... | @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 833 | resolvedActiveMode, | 857 | resolvedActiveMode, |
| 834 | mediaSource, | 858 | mediaSource, |
| 835 | mediaActions.actions, | 859 | mediaActions.actions, |
| 860 | fileData?.contents, | ||
| 861 | fileData?.truncated, | ||
| 836 | ]); | 862 | ]); |
| 837 | 863 | ||
| 838 | const androidFileMenuActions = useMemo<MenuAction[]>( | 864 | const androidFileMenuActions = useMemo<MenuAction[]>( |
apps/mobile/src/features/files/source-file-document.test.ts+12-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("prepareSourceFileDocument", () => { | 5 | describe("prepareSourceFileDocument", () => { |
| 6 | it("normalizes and serializes source rows once for repeated consumers", () => { | 6 | it("normalizes and serializes source rows once for repeated consumers", () => { |
| ... | @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { | ... | @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { |
| 14 | expect(second).toBe(first); | 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,3 +1,4 @@ |
| 1 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; | ||
| 1 | import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; | 2 | import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; |
| 2 | 3 | ||
| 3 | const MAX_CACHED_DOCUMENTS = 8; | 4 | const MAX_CACHED_DOCUMENTS = 8; |
| ... | @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument | ... | @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument |
| 52 | 53 | ||
| 53 | return document; | 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,4 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 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 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 8 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | import { | 9 | import { |
| 4 | CommonActions, | 10 | CommonActions, |
| ... | @@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; | ... | @@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily"; |
| 22 | 28 | ||
| 23 | import { | 29 | import { |
| 24 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 30 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 31 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 25 | resolveEnvironmentMachineKind, | 32 | resolveEnvironmentMachineKind, |
| 26 | type EnvironmentId, | 33 | type EnvironmentId, |
| 27 | } from "@t3tools/contracts"; | 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 | import { composerContextImportsAtom } from "../../state/use-composer-drafts"; | 41 | import { composerContextImportsAtom } from "../../state/use-composer-drafts"; |
| 31 | import { | 42 | import { |
| 32 | composerContextSendBlockReason, | 43 | composerContextSendBlockReason, |
| ... | @@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ... | @@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 41 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 52 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 42 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 53 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 43 | import { composerStripAttachments } from "../../lib/composerImages"; | 54 | import { composerStripAttachments } from "../../lib/composerImages"; |
| 44 | import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; | ||
| 45 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 55 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 46 | import { | 56 | import { |
| 47 | composerAttachmentUploadBlockReason, | 57 | composerAttachmentUploadBlockReason, |
| ... | @@ -73,13 +83,17 @@ import { | ... | @@ -73,13 +83,17 @@ import { |
| 73 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 83 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 74 | import { | 84 | import { |
| 75 | convertPastedImagesToAttachments, | 85 | convertPastedImagesToAttachments, |
| 86 | createPastedTextComposerAttachment, | ||
| 76 | pickComposerFiles, | 87 | pickComposerFiles, |
| 77 | pickComposerMedia, | 88 | pickComposerMedia, |
| 89 | removePersistedComposerAttachmentFile, | ||
| 78 | type DraftComposerFileAttachment, | 90 | type DraftComposerFileAttachment, |
| 79 | } from "../../lib/composerImages"; | 91 | } from "../../lib/composerImages"; |
| 80 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 92 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 81 | import { | 93 | import { |
| 82 | clearComposerDraftContent, | 94 | clearComposerDraftContent, |
| 95 | captureComposerDraftInsertion, | ||
| 96 | countComposerDraftAttachmentsAfterSelection, | ||
| 83 | getComposerDraftSnapshot, | 97 | getComposerDraftSnapshot, |
| 84 | mergeComposerDraftContent, | 98 | mergeComposerDraftContent, |
| 85 | restoreComposerDraftSnapshot, | 99 | restoreComposerDraftSnapshot, |
| ... | @@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: { | ... | @@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: { |
| 297 | const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>()); | 311 | const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>()); |
| 298 | const activeShareImportTokenRef = useRef<symbol | null>(null); | 312 | const activeShareImportTokenRef = useRef<symbol | null>(null); |
| 299 | const shareImportMountedRef = useRef(true); | 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 | const latestDraftKeyRef = useRef(flow.draftKey); | 320 | const latestDraftKeyRef = useRef(flow.draftKey); |
| 301 | const latestIncomingShareIdRef = useRef(props.incomingShareId); | 321 | const latestIncomingShareIdRef = useRef(props.incomingShareId); |
| 302 | latestDraftKeyRef.current = flow.draftKey; | 322 | latestDraftKeyRef.current = flow.draftKey; |
| ... | @@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: { |
| 344 | : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; | 364 | : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; |
| 345 | // Media needs its thumbnail; every other file already reads as its inline chip. | 365 | // Media needs its thumbnail; every other file already reads as its inline chip. |
| 346 | const stripAttachments = useMemo( | 366 | const stripAttachments = useMemo( |
| 347 | () => | 367 | () => composerStripAttachments(flow.attachments), |
| 348 | composerStripAttachments( | 368 | [flow.attachments], |
| 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], | ||
| 357 | ); | 369 | ); |
| 358 | const composerMenu = useComposerCommandMenu({ | 370 | const composerMenu = useComposerCommandMenu({ |
| 359 | draftMessage: flow.prompt, | 371 | draftMessage: flow.prompt, |
| ... | @@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: { | ... | @@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: { |
| 903 | return; | 915 | return; |
| 904 | } | 916 | } |
| 905 | const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; | 917 | const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; |
| 918 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | ||
| 906 | const result = await pickComposerMedia({ | 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 | maxVideoBytes: | 924 | maxVideoBytes: |
| 909 | capabilities?.attachmentUploads === true | 925 | capabilities?.attachmentUploads === true |
| 910 | ? capabilities.fileAttachments?.maxUploadBytes | 926 | ? capabilities.fileAttachments?.maxUploadBytes |
| 911 | : undefined, | 927 | : undefined, |
| 912 | }); | 928 | }); |
| 913 | const rejectedCount = | 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 | const problems = [ | 931 | const problems = [ |
| 916 | ...(result.error ? [result.error] : []), | 932 | ...(result.error ? [result.error] : []), |
| 917 | ...(rejectedCount > 0 | 933 | ...(rejectedCount > 0 |
| ... | @@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: { | ... | @@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: { |
| 933 | Alert.alert("File attachments are not available on this server."); | 949 | Alert.alert("File attachments are not available on this server."); |
| 934 | return; | 950 | return; |
| 935 | } | 951 | } |
| 952 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | ||
| 936 | const result = await pickComposerFiles({ | 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 | maxBytes, | 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 | // The picker error and the live-cap rejection can both happen in one | 962 | // The picker error and the live-cap rejection can both happen in one |
| 942 | // pick; report both in a single alert. | 963 | // pick; report both in a single alert. |
| 943 | const problems = [ | 964 | const problems = [ |
| ... | @@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: { | ... | @@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: { |
| 954 | const handleNativePasteImages = useCallback( | 975 | const handleNativePasteImages = useCallback( |
| 955 | async (uris: ReadonlyArray<string>) => { | 976 | async (uris: ReadonlyArray<string>) => { |
| 956 | try { | 977 | try { |
| 978 | const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; | ||
| 957 | const images = await convertPastedImagesToAttachments({ | 979 | const images = await convertPastedImagesToAttachments({ |
| 958 | uris, | 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 | if (images.length > 0) { | 986 | if (images.length > 0) { |
| 962 | flow.appendAttachments(images); | 987 | flow.appendAttachments(images, insertion); |
| 963 | } | 988 | } |
| 964 | } catch (error) { | 989 | } catch (error) { |
| 965 | console.error("[native paste] error converting images", error); | 990 | console.error("[native paste] error converting images", error); |
| ... | @@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: { | ... | @@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: { |
| 968 | [flow], | 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 | async function handleStart(): Promise<void> { | 1094 | async function handleStart(): Promise<void> { |
| 972 | if (voiceInput.blocksSubmission) return; | 1095 | if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; |
| 973 | const selectedProject = flow.selectedProject; | 1096 | const selectedProject = flow.selectedProject; |
| 974 | const draftKey = flow.draftKey; | 1097 | const draftKey = flow.draftKey; |
| 975 | if (!selectedProject || !draftKey) { | 1098 | if (!selectedProject || !draftKey) { |
| ... | @@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: { |
| 1139 | isIncomingShareReady && | 1262 | isIncomingShareReady && |
| 1140 | !isImportingShare && | 1263 | !isImportingShare && |
| 1141 | !flow.submitting && | 1264 | !flow.submitting && |
| 1265 | pendingPastedTextAttachmentCount === 0 && | ||
| 1142 | !voiceInput.blocksSubmission && | 1266 | !voiceInput.blocksSubmission && |
| 1143 | !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); | 1267 | !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); |
| 1144 | const openDraftDocument = (attachment: ComposerDocumentAttachment) => { | 1268 | const openDraftDocument = (attachment: ComposerDocumentAttachment) => { |
| ... | @@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: { |
| 1195 | onFocus={() => setIsComposerFocused(true)} | 1319 | onFocus={() => setIsComposerFocused(true)} |
| 1196 | onBlur={() => setIsComposerFocused(false)} | 1320 | onBlur={() => setIsComposerFocused(false)} |
| 1197 | onPasteImages={(uris) => void handleNativePasteImages(uris)} | 1321 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 1322 | onPasteText={(paste) => void handleNativePasteText(paste)} | ||
| 1198 | placeholder="Ask anything…" | 1323 | placeholder="Ask anything…" |
| 1199 | singleLineCentered={false} | 1324 | singleLineCentered={false} |
| 1200 | contentInsetVertical={0} | 1325 | contentInsetVertical={0} |
| ... | @@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: { |
| 1486 | <ComposerActionButton | 1611 | <ComposerActionButton |
| 1487 | accessibilityLabel={ | 1612 | accessibilityLabel={ |
| 1488 | attachmentBlockReason ?? | 1613 | attachmentBlockReason ?? |
| 1489 | (flow.submitting | 1614 | (pendingPastedTextAttachmentCount > 0 |
| 1490 | ? "Starting task" | 1615 | ? "Attaching pasted text" |
| 1491 | : attachmentsUploading | 1616 | : flow.submitting |
| 1492 | ? "Queue task, sends when uploads finish" | 1617 | ? "Starting task" |
| 1493 | : environmentConnected | 1618 | : attachmentsUploading |
| 1494 | ? "Start task" | 1619 | ? "Queue task, sends when uploads finish" |
| 1495 | : "Queue task") | 1620 | : environmentConnected |
| 1621 | ? "Start task" | ||
| 1622 | : "Queue task") | ||
| 1496 | } | 1623 | } |
| 1497 | disabled={!canStart} | 1624 | disabled={!canStart} |
| 1498 | icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} | 1625 | icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} |
apps/mobile/src/features/threads/ThreadComposer.tsx+103-25| ... | @@ -1,14 +1,19 @@ | ... | @@ -1,14 +1,19 @@ |
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | ||
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import type { | 4 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; |
| 4 | EnvironmentId, | 5 | import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; |
| 5 | MessageId, | 6 | import { |
| 6 | ModelSelection, | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 7 | OrchestrationThreadShell, | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 8 | ProviderInteractionMode, | 9 | type EnvironmentId, |
| 9 | RuntimeMode, | 10 | type MessageId, |
| 10 | ServerConfig as T3ServerConfig, | 11 | type ModelSelection, |
| 11 | UsageLimitsReport, | 12 | type OrchestrationThreadShell, |
| 13 | type ProviderInteractionMode, | ||
| 14 | type RuntimeMode, | ||
| 15 | type ServerConfig as T3ServerConfig, | ||
| 16 | type UsageLimitsReport, | ||
| 12 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 13 | import { | 18 | import { |
| 14 | collectProviderUsageLimits, | 19 | collectProviderUsageLimits, |
| ... | @@ -46,7 +51,10 @@ import Animated, { | ... | @@ -46,7 +51,10 @@ import Animated, { |
| 46 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 51 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 47 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 52 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 48 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 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 | import type { ComposerDocumentAttachment } from "../../lib/composerContext"; | 58 | import type { ComposerDocumentAttachment } from "../../lib/composerContext"; |
| 51 | import { useProject } from "../../state/entities"; | 59 | import { useProject } from "../../state/entities"; |
| 52 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | 60 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| ... | @@ -72,7 +80,6 @@ import { | ... | @@ -72,7 +80,6 @@ import { |
| 72 | type DraftComposerAttachment, | 80 | type DraftComposerAttachment, |
| 73 | type DraftComposerFileAttachment, | 81 | type DraftComposerFileAttachment, |
| 74 | } from "../../lib/composerImages"; | 82 | } from "../../lib/composerImages"; |
| 75 | import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; | ||
| 76 | import { | 83 | import { |
| 77 | buildModelOptions, | 84 | buildModelOptions, |
| 78 | groupByProvider, | 85 | groupByProvider, |
| ... | @@ -135,6 +142,7 @@ export interface ThreadComposerProps { | ... | @@ -135,6 +142,7 @@ export interface ThreadComposerProps { |
| 135 | readonly onPickDraftMedia: () => Promise<void>; | 142 | readonly onPickDraftMedia: () => Promise<void>; |
| 136 | readonly onPickDraftFiles: () => Promise<void>; | 143 | readonly onPickDraftFiles: () => Promise<void>; |
| 137 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 144 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 145 | readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>; | ||
| 138 | readonly onRemoveDraftImage: (imageId: string) => void; | 146 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 139 | readonly onStopThread: () => void; | 147 | readonly onStopThread: () => void; |
| 140 | readonly onSendMessage: () => Promise<MessageId | null>; | 148 | readonly onSendMessage: () => Promise<MessageId | null>; |
| ... | @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 278 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); | 286 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 279 | const inputRef = props.editorRef ?? fallbackInputRef; | 287 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 280 | const [isFocused, setIsFocused] = useState(false); | 288 | const [isFocused, setIsFocused] = useState(false); |
| 289 | const pendingPastedTextAttachmentCountRef = useRef(0); | ||
| 290 | const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); | ||
| 281 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | 291 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 282 | editorRef: inputRef, | 292 | editorRef: inputRef, |
| 283 | isEditorFocused: isFocused, | 293 | isEditorFocused: isFocused, |
| ... | @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 291 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); | 301 | const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); |
| 292 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); | 302 | const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); |
| 293 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 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 | 304 | // Only media belongs above the composer; every other file reads as its inline chip. |
| 295 | // already read as an inline chip and need no strip tile. | ||
| 296 | const stripAttachments = useMemo( | 305 | const stripAttachments = useMemo( |
| 297 | () => | 306 | () => composerStripAttachments(props.draftAttachments), |
| 298 | composerStripAttachments( | 307 | [props.draftAttachments], |
| 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], | ||
| 307 | ); | 308 | ); |
| 308 | const showStopAction = | 309 | const showStopAction = |
| 309 | !hasContent && | 310 | !hasContent && |
| ... | @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 422 | states: uploadStates, | 423 | states: uploadStates, |
| 423 | }); | 424 | }); |
| 424 | const contextImports = useAtomValue(composerContextImportsAtom); | 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 | const canSend = | 430 | const canSend = |
| 427 | hasContent && | 431 | hasContent && |
| 428 | !contextImports[composerOwnerKey] && | 432 | !contextImports[composerOwnerKey] && |
| ... | @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 478 | onEditorFocusChange?.(false); | 482 | onEditorFocusChange?.(false); |
| 479 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); | 483 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); |
| 480 | const handleSend = useCallback(async () => { | 484 | const handleSend = useCallback(async () => { |
| 485 | if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; | ||
| 481 | // Typed out in full rather than picked from the menu. Attachments mean the | 486 | // Typed out in full rather than picked from the menu. Attachments mean the |
| 482 | // user is sending a prompt, so those go through as usual. | 487 | // user is sending a prompt, so those go through as usual. |
| 483 | if ( | 488 | if ( |
| ... | @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 488 | if (openUsageLimits()) onChangeDraftMessage(""); | 493 | if (openUsageLimits()) onChangeDraftMessage(""); |
| 489 | return; | 494 | return; |
| 490 | } | 495 | } |
| 491 | if (voiceInput.blocksSubmission) return; | ||
| 492 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 496 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 493 | if (inFlightThreadIdsRef.current.has(threadKey)) return; | 497 | if (inFlightThreadIdsRef.current.has(threadKey)) return; |
| 494 | inFlightThreadIdsRef.current.add(threadKey); | 498 | inFlightThreadIdsRef.current.add(threadKey); |
| ... | @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 736 | }); | 740 | }); |
| 737 | }} | 741 | }} |
| 738 | onOpenAttachment={openDraftDocument} | 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 | ref={inputRef} | 747 | ref={inputRef} |
| 740 | multiline | 748 | multiline |
| 741 | value={props.draftMessage} | 749 | value={props.draftMessage} |
| ... | @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 745 | onChangeText={props.onChangeDraftMessage} | 753 | onChangeText={props.onChangeDraftMessage} |
| 746 | onSelectionChange={composerMenu.onSelectionChange} | 754 | onSelectionChange={composerMenu.onSelectionChange} |
| 747 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} | 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 | placeholder={props.placeholder} | 826 | placeholder={props.placeholder} |
| 749 | onFocus={handleFocus} | 827 | onFocus={handleFocus} |
| 750 | onBlur={handleBlur} | 828 | onBlur={handleBlur} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | ||
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 2 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import { | 3 | import { |
| 3 | appendCodexArtifactTemplateUsePrompt, | 4 | appendCodexArtifactTemplateUsePrompt, |
| ... | @@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps { |
| 155 | readonly onPickDraftMedia: () => Promise<void>; | 156 | readonly onPickDraftMedia: () => Promise<void>; |
| 156 | readonly onPickDraftFiles: () => Promise<void>; | 157 | readonly onPickDraftFiles: () => Promise<void>; |
| 157 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 158 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 159 | readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>; | ||
| 158 | readonly onRemoveDraftImage: (imageId: string) => void; | 160 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 159 | readonly onStopThread: () => void; | 161 | readonly onStopThread: () => void; |
| 160 | readonly onSendMessage: () => Promise<MessageId | null>; | 162 | readonly onSendMessage: () => Promise<MessageId | null>; |
| ... | @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1049 | onPickDraftMedia={props.onPickDraftMedia} | 1051 | onPickDraftMedia={props.onPickDraftMedia} |
| 1050 | onPickDraftFiles={props.onPickDraftFiles} | 1052 | onPickDraftFiles={props.onPickDraftFiles} |
| 1051 | onNativePasteImages={props.onNativePasteImages} | 1053 | onNativePasteImages={props.onNativePasteImages} |
| 1054 | onNativePasteText={props.onNativePasteText} | ||
| 1052 | onRemoveDraftImage={props.onRemoveDraftImage} | 1055 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 1053 | onStopThread={props.onStopThread} | 1056 | onStopThread={props.onStopThread} |
| 1054 | onSendMessage={handleSendMessage} | 1057 | onSendMessage={handleSendMessage} |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1| ... | @@ -891,6 +891,7 @@ function ThreadRouteContent( | ... | @@ -891,6 +891,7 @@ function ThreadRouteContent( |
| 891 | onPickDraftMedia={composer.onPickDraftMedia} | 891 | onPickDraftMedia={composer.onPickDraftMedia} |
| 892 | onPickDraftFiles={composer.onPickDraftFiles} | 892 | onPickDraftFiles={composer.onPickDraftFiles} |
| 893 | onNativePasteImages={composer.onNativePasteImages} | 893 | onNativePasteImages={composer.onNativePasteImages} |
| 894 | onNativePasteText={composer.onNativePasteText} | ||
| 894 | onRemoveDraftImage={composer.onRemoveDraftImage} | 895 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 895 | serverConfig={serverConfig} | 896 | serverConfig={serverConfig} |
| 896 | onStopThread={handleStopThread} | 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,6 +44,7 @@ import { projectEnvironment } from "../../state/projects"; |
| 44 | import { useEnvironmentQuery } from "../../state/query"; | 44 | import { useEnvironmentQuery } from "../../state/query"; |
| 45 | import { | 45 | import { |
| 46 | appendComposerDraftAttachments, | 46 | appendComposerDraftAttachments, |
| 47 | type ComposerDraftInsertion, | ||
| 47 | clearComposerDraft, | 48 | clearComposerDraft, |
| 48 | composerDraftsAtom, | 49 | composerDraftsAtom, |
| 49 | createNewTaskDraft, | 50 | createNewTaskDraft, |
| ... | @@ -203,7 +204,10 @@ type NewTaskFlowContextValue = { | ... | @@ -203,7 +204,10 @@ type NewTaskFlowContextValue = { |
| 203 | readonly setPrompt: (value: string) => void; | 204 | readonly setPrompt: (value: string) => void; |
| 204 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void; | 205 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void; |
| 205 | /** Appends draft attachments; returns how many the live cap rejected. */ | 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 | readonly removeAttachment: (imageId: string) => void; | 211 | readonly removeAttachment: (imageId: string) => void; |
| 208 | readonly clearAttachments: () => void; | 212 | readonly clearAttachments: () => void; |
| 209 | readonly setSubmitting: (value: boolean) => void; | 213 | readonly setSubmitting: (value: boolean) => void; |
| ... | @@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 600 | // Returns how many attachments the live cap rejected so the caller can | 604 | // Returns how many attachments the live cap rejected so the caller can |
| 601 | // tell the user (a concurrent add can fill the draft mid-pick). | 605 | // tell the user (a concurrent add can fill the draft mid-pick). |
| 602 | const appendAttachments = useCallback( | 606 | const appendAttachments = useCallback( |
| 603 | (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => { | 607 | ( |
| 608 | nextAttachments: ReadonlyArray<DraftComposerAttachment>, | ||
| 609 | insertion?: ComposerDraftInsertion, | ||
| 610 | ): number => { | ||
| 604 | if (!selectedProjectDraftKey) { | 611 | if (!selectedProjectDraftKey) { |
| 605 | return 0; | 612 | return 0; |
| 606 | } | 613 | } |
| 607 | return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { | 614 | return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { |
| 608 | appendReference: true, | 615 | appendReference: true, |
| 616 | insertion, | ||
| 609 | }); | 617 | }); |
| 610 | }, | 618 | }, |
| 611 | [selectedProjectDraftKey], | 619 | [selectedProjectDraftKey], |
apps/mobile/src/lib/appearancePreferences.test.ts+7| ... | @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { | ... | @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { |
| 90 | expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); | 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 | it("returns the authored text scale at the 16pt default", () => { | 100 | it("returns the authored text scale at the 16pt default", () => { |
| 94 | expect(DEFAULT_BASE_FONT_SIZE).toBe(16); | 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,6 +11,7 @@ import type { FileBackedComposerAttachment } from "./composerImages"; |
| 11 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; | 11 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; |
| 12 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; | 12 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; |
| 13 | import { useRefreshAssetUrl } from "../state/assets"; | 13 | import { useRefreshAssetUrl } from "../state/assets"; |
| 14 | import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; | ||
| 14 | 15 | ||
| 15 | const isLocalUri = (uri: string) => /^(file|content):/.test(uri); | 16 | const isLocalUri = (uri: string) => /^(file|content):/.test(uri); |
| 16 | 17 | ||
| ... | @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { | ... | @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { |
| 32 | }) { | 33 | }) { |
| 33 | const kind = filePreviewKind(input); | 34 | const kind = filePreviewKind(input); |
| 34 | const delimiter = filePreviewDelimiter(input); | 35 | const delimiter = filePreviewDelimiter(input); |
| 35 | const renderedMode = | ||
| 36 | kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; | ||
| 37 | const shareController = useRef<AbortController | null>(null); | 36 | const shareController = useRef<AbortController | null>(null); |
| 38 | useEffect(() => () => shareController.current?.abort(), []); | 37 | useEffect(() => () => shareController.current?.abort(), []); |
| 39 | const resource = useMemo( | 38 | const resource = useMemo( |
| ... | @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { | ... | @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { |
| 61 | const [contentError, setContentError] = useState<string | null>(null); | 60 | const [contentError, setContentError] = useState<string | null>(null); |
| 62 | const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); | 61 | const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); |
| 63 | const [rendered, setRendered] = useState(true); | 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 | const [revision, setRevision] = useState(0); | 69 | const [revision, setRevision] = useState(0); |
| 65 | const [sharing, setSharing] = useState(false); | 70 | const [sharing, setSharing] = useState(false); |
| 66 | const uri = input.attachment ? localUri : remoteUri; | 71 | const uri = input.attachment ? localUri : remoteUri; |
| ... | @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { | ... | @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { |
| 185 | }; | 190 | }; |
| 186 | return { | 191 | return { |
| 187 | kind, | 192 | kind, |
| 188 | renderedMode, | 193 | ...presentation, |
| 189 | uri, | 194 | uri, |
| 190 | /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ | 195 | /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ |
| 191 | resource, | 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,7 +360,14 @@ describe("prepareTurnAttachments", () => { |
| 360 | }); | 360 | }); |
| 361 | 361 | ||
| 362 | it("uploads generic file bytes directly and keeps mixed attachment order", async () => { | 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 | expect(mocks.upload).toHaveBeenCalledWith( | 372 | expect(mocks.upload).toHaveBeenCalledWith( |
| 366 | "file:///documents/report.pdf", | 373 | "file:///documents/report.pdf", |
| ... | @@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => { | ... | @@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => { |
| 379 | name: "report.pdf", | 386 | name: "report.pdf", |
| 380 | mimeType: "application/pdf", | 387 | mimeType: "application/pdf", |
| 381 | sizeBytes: 42, | 388 | sizeBytes: 42, |
| 389 | source: { _tag: "pasted-text" }, | ||
| 382 | }); | 390 | }); |
| 383 | expect(prepared.attachments[1]?.type).toBe("image"); | 391 | expect(prepared.attachments[1]?.type).toBe("image"); |
| 384 | expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); | 392 | expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); |
| 385 | expect(prepared.draftAttachments[0]).toEqual({ | 393 | expect(prepared.draftAttachments[0]).toEqual({ |
| 386 | ...file, | 394 | ...pastedFile, |
| 387 | uploadedAttachmentId: MINTED_ID, | 395 | uploadedAttachmentId: MINTED_ID, |
| 388 | uploadEnvironmentId: environmentId, | 396 | uploadEnvironmentId: environmentId, |
| 389 | }); | 397 | }); |
apps/mobile/src/lib/attachmentUpload.ts+5-1| ... | @@ -198,7 +198,11 @@ function uploadedReference( | ... | @@ -198,7 +198,11 @@ function uploadedReference( |
| 198 | // chat view with nothing to show a thumbnail from, on every client. | 198 | // chat view with nothing to show a thumbnail from, on every client. |
| 199 | return isComposerImageAttachment(attachment) | 199 | return isComposerImageAttachment(attachment) |
| 200 | ? { type: "image", ...fields } | 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 | function attachmentUploadInput(attachment: DraftComposerAttachment) { | 208 | function attachmentUploadInput(attachment: DraftComposerAttachment) { |
apps/mobile/src/lib/composer-image-schema.ts+2-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | export const DraftComposerImageAttachmentSchema = Schema.Struct({ | 4 | export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 5 | id: Schema.String, | 5 | id: Schema.String, |
| ... | @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ | ... | @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ |
| 29 | mimeType: Schema.String, | 29 | mimeType: Schema.String, |
| 30 | sizeBytes: Schema.Number, | 30 | sizeBytes: Schema.Number, |
| 31 | fileUri: Schema.String, | 31 | fileUri: Schema.String, |
| 32 | source: Schema.optional(PastedTextAttachmentSource), | ||
| 32 | uploadedAttachmentId: Schema.optional(Schema.String), | 33 | uploadedAttachmentId: Schema.optional(Schema.String), |
| 33 | uploadEnvironmentId: Schema.optional(EnvironmentId), | 34 | uploadEnvironmentId: Schema.optional(EnvironmentId), |
| 34 | }); | 35 | }); |
apps/mobile/src/lib/composerImages.test.ts+139-11| ... | @@ -1,14 +1,28 @@ | ... | @@ -1,14 +1,28 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; | 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 | vi.mock("expo-file-system", () => ({ | 15 | vi.mock("expo-file-system", () => ({ |
| 7 | File: class { | 16 | File: class { |
| 8 | readonly uri: string; | 17 | readonly uri: string; |
| 18 | readonly name: string; | ||
| 19 | readonly parentDirectory: { readonly uri: string }; | ||
| 9 | 20 | ||
| 10 | constructor(uri: string) { | 21 | constructor(parent: string | { readonly uri: string }, name?: string) { |
| 11 | this.uri = uri; | 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 | get exists(): boolean { | 28 | get exists(): boolean { |
| ... | @@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({ | ... | @@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({ |
| 29 | entry.deleted = true; | 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 | vi.mock("./uuid", () => ({ | 74 | vi.mock("./uuid", () => ({ |
| 36 | uuidv4: () => "attachment-id", | 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 | describe("native pasted image cleanup", () => { | 131 | describe("native pasted image cleanup", () => { |
| 42 | beforeEach(() => { | 132 | beforeEach(() => { |
| ... | @@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => { | ... | @@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => { |
| 91 | expect(files.get(overflow)?.deleted).toBe(true); | 181 | expect(files.get(overflow)?.deleted).toBe(true); |
| 92 | expect(files.get(userOwned)?.deleted).toBe(false); | 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 | describe("composerStripAttachments", () => { | 206 | describe("composerStripAttachments", () => { |
| ... | @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { | ... | @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { |
| 119 | fileUri: "file:///notes.txt", | 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 | const { composerStripAttachments } = await import("./composerImages"); | 233 | const { composerStripAttachments } = await import("./composerImages"); |
| 124 | const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"])); | 234 | const kept = composerStripAttachments([image, video] as never); |
| 125 | // A thumbnail is the only way to see media, so it stays regardless of the chip. | ||
| 126 | expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); | 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 | const { composerStripAttachments } = await import("./composerImages"); | 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 | const { composerStripAttachments } = await import("./composerImages"); | 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,6 +8,7 @@ import { |
| 8 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | 8 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 10 | type EnvironmentId, | 10 | type EnvironmentId, |
| 11 | type PastedTextAttachmentSource, | ||
| 11 | type UploadChatImageAttachment, | 12 | type UploadChatImageAttachment, |
| 12 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 13 | import type { DocumentPickerResult } from "expo-document-picker"; | 14 | import type { DocumentPickerResult } from "expo-document-picker"; |
| ... | @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; | ... | @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; |
| 21 | import { videoMimeType } from "@t3tools/shared/video"; | 22 | import { videoMimeType } from "@t3tools/shared/video"; |
| 22 | import { beginForegroundHandoff } from "./foreground-handoff"; | 23 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 23 | import { uuidv4 } from "./uuid"; | 24 | import { uuidv4 } from "./uuid"; |
| 25 | import { writeFileAtomically } from "./atomic-file"; | ||
| 24 | 26 | ||
| 25 | export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> { | 27 | export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> { |
| 26 | readonly id: string; | 28 | readonly id: string; |
| ... | @@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment { | ... | @@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment { |
| 40 | readonly mimeType: string; | 42 | readonly mimeType: string; |
| 41 | readonly sizeBytes: number; | 43 | readonly sizeBytes: number; |
| 42 | readonly fileUri: string; | 44 | readonly fileUri: string; |
| 45 | readonly source?: PastedTextAttachmentSource; | ||
| 43 | readonly uploadedAttachmentId?: string; | 46 | readonly uploadedAttachmentId?: string; |
| 44 | readonly uploadEnvironmentId?: EnvironmentId; | 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 | export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; | 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 | 82 | * What the strip above the composer shows: media, and nothing else. A thumbnail is the only |
| 51 | * only way to see it; every other file is already legible as its inline chip, so it only | 83 | * way to see a picture or a video, so those always preview there. Everything else reads as |
| 52 | * falls back to the strip when the prompt carries no reference to it. Mirrors web's | 84 | * its inline chip, which carries the name, the type and the size in the line of prose the |
| 53 | * `composerOtherFilesForPresentation`. | 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 | export function composerStripAttachments( | 93 | export function composerStripAttachments( |
| 56 | attachments: ReadonlyArray<DraftComposerAttachment>, | 94 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 57 | inlineAttachmentIds: ReadonlySet<string>, | ||
| 58 | ): ReadonlyArray<DraftComposerAttachment> { | 95 | ): ReadonlyArray<DraftComposerAttachment> { |
| 59 | return attachments.filter( | 96 | return attachments.filter( |
| 60 | (attachment) => | 97 | (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null, |
| 61 | isComposerImageAttachment(attachment) || | ||
| 62 | videoMimeType(attachment) !== null || | ||
| 63 | !inlineAttachmentIds.has(attachment.id), | ||
| 64 | ); | 98 | ); |
| 65 | } | 99 | } |
| 66 | 100 | ||
| ... | @@ -539,11 +573,19 @@ export async function pickComposerMedia(input: { | ... | @@ -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<{ | 576 | /** Clipboard images take priority over their alternate text representation. */ |
| 543 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; | 577 | export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise< |
| 544 | readonly text: string | null; | 578 | | { |
| 545 | readonly error: string | null; | 579 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; |
| 546 | }> { | 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 | let clipboard: Awaited<ReturnType<typeof loadClipboard>>; | 589 | let clipboard: Awaited<ReturnType<typeof loadClipboard>>; |
| 548 | try { | 590 | try { |
| 549 | clipboard = await loadClipboard(); | 591 | clipboard = await loadClipboard(); |
| ... | @@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu | ... | @@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu |
| 605 | const text = await clipboard.getStringAsync(); | 647 | const text = await clipboard.getStringAsync(); |
| 606 | return { | 648 | return { |
| 607 | images: [], | 649 | images: [], |
| 608 | text: text.length > 0 ? text : null, | 650 | ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }), |
| 609 | error: text.length > 0 ? null : "Clipboard is empty.", | ||
| 610 | }; | 651 | }; |
| 611 | } | 652 | } |
| 612 | 653 |
apps/mobile/src/native/T3ComposerEditor.ios.tsx+47-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -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 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 2 | import { composerContextEditorTokens } from "../lib/composerContext"; | 4 | import { composerContextEditorTokens } from "../lib/composerContext"; |
| 3 | import { requireNativeView } from "expo"; | 5 | import { requireNativeView } from "expo"; |
| ... | @@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ | ... | @@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ |
| 52 | readonly uris: ReadonlyArray<string>; | 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 | interface NativeComposerEditorRef { | 64 | interface NativeComposerEditorRef { |
| 56 | focus: () => Promise<void>; | 65 | focus: () => Promise<void>; |
| 57 | blur: () => Promise<void>; | 66 | blur: () => Promise<void>; |
| ... | @@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { | ... | @@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { |
| 81 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, | 90 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, |
| 82 | ) => void; | 91 | ) => void; |
| 83 | readonly onComposerPasteContext?: ( | 92 | readonly onComposerPasteContext?: ( |
| 84 | event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, | 93 | event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, |
| 85 | ) => void; | 94 | ) => void; |
| 95 | readonly textPasteThresholdBytes: number; | ||
| 96 | readonly maxInputChars: number; | ||
| 97 | readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; | ||
| 86 | readonly onComposerFocus?: () => void; | 98 | readonly onComposerFocus?: () => void; |
| 87 | readonly onComposerBlur?: () => void; | 99 | readonly onComposerBlur?: () => void; |
| 88 | readonly onComposerSubmit?: () => void; | 100 | readonly onComposerSubmit?: () => void; |
| ... | @@ -108,6 +120,7 @@ export function ComposerEditor({ | ... | @@ -108,6 +120,7 @@ export function ComposerEditor({ |
| 108 | onChangeText, | 120 | onChangeText, |
| 109 | onSelectionChange, | 121 | onSelectionChange, |
| 110 | onPasteImages, | 122 | onPasteImages, |
| 123 | onPasteText, | ||
| 111 | onFocus, | 124 | onFocus, |
| 112 | onBlur, | 125 | onBlur, |
| 113 | onSubmit, | 126 | onSubmit, |
| ... | @@ -282,6 +295,8 @@ export function ComposerEditor({ | ... | @@ -282,6 +295,8 @@ export function ComposerEditor({ |
| 282 | autoFocus={props.autoFocus ?? false} | 295 | autoFocus={props.autoFocus ?? false} |
| 283 | autoCorrect={props.autoCorrect ?? true} | 296 | autoCorrect={props.autoCorrect ?? true} |
| 284 | spellCheck={props.spellCheck ?? true} | 297 | spellCheck={props.spellCheck ?? true} |
| 298 | textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} | ||
| 299 | maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} | ||
| 285 | style={style as StyleProp<ViewStyle>} | 300 | style={style as StyleProp<ViewStyle>} |
| 286 | onComposerChange={(event) => { | 301 | onComposerChange={(event) => { |
| 287 | const acknowledgedEventCount = acceptNativeEvent( | 302 | const acknowledgedEventCount = acceptNativeEvent( |
| ... | @@ -315,7 +330,36 @@ export function ComposerEditor({ | ... | @@ -315,7 +330,36 @@ export function ComposerEditor({ |
| 315 | }} | 330 | }} |
| 316 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} | 331 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} |
| 317 | onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} | 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 | onComposerFocus={onFocus} | 363 | onComposerFocus={onFocus} |
| 320 | onComposerBlur={onBlur} | 364 | onComposerBlur={onBlur} |
| 321 | onComposerSubmit={onSubmit} | 365 | onComposerSubmit={onSubmit} |
| ... | @@ -327,4 +371,5 @@ export type { | ... | @@ -327,4 +371,5 @@ export type { |
| 327 | ComposerEditorHandle, | 371 | ComposerEditorHandle, |
| 328 | ComposerEditorProps, | 372 | ComposerEditorProps, |
| 329 | ComposerEditorSelection, | 373 | ComposerEditorSelection, |
| 374 | ComposerTextPaste, | ||
| 330 | } from "./T3ComposerEditor.types"; | 375 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.native.tsx+47-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -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 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 2 | import { composerContextEditorTokens } from "../lib/composerContext"; | 4 | import { composerContextEditorTokens } from "../lib/composerContext"; |
| 3 | import { requireNativeView } from "expo"; | 5 | import { requireNativeView } from "expo"; |
| ... | @@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ | ... | @@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ |
| 55 | readonly uris: ReadonlyArray<string>; | 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 | interface NativeComposerEditorRef { | 67 | interface NativeComposerEditorRef { |
| 59 | focus: () => Promise<void>; | 68 | focus: () => Promise<void>; |
| 60 | blur: () => Promise<void>; | 69 | blur: () => Promise<void>; |
| ... | @@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { | ... | @@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { |
| 85 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, | 94 | event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, |
| 86 | ) => void; | 95 | ) => void; |
| 87 | readonly onComposerPasteContext?: ( | 96 | readonly onComposerPasteContext?: ( |
| 88 | event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, | 97 | event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, |
| 89 | ) => void; | 98 | ) => void; |
| 99 | readonly textPasteThresholdBytes: number; | ||
| 100 | readonly maxInputChars: number; | ||
| 101 | readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; | ||
| 90 | readonly onComposerFocus?: () => void; | 102 | readonly onComposerFocus?: () => void; |
| 91 | readonly onComposerBlur?: () => void; | 103 | readonly onComposerBlur?: () => void; |
| 92 | } | 104 | } |
| ... | @@ -111,6 +123,7 @@ export function ComposerEditor({ | ... | @@ -111,6 +123,7 @@ export function ComposerEditor({ |
| 111 | onChangeText, | 123 | onChangeText, |
| 112 | onSelectionChange, | 124 | onSelectionChange, |
| 113 | onPasteImages, | 125 | onPasteImages, |
| 126 | onPasteText, | ||
| 114 | onFocus, | 127 | onFocus, |
| 115 | onBlur, | 128 | onBlur, |
| 116 | contentInsetVertical = 0, | 129 | contentInsetVertical = 0, |
| ... | @@ -291,6 +304,8 @@ export function ComposerEditor({ | ... | @@ -291,6 +304,8 @@ export function ComposerEditor({ |
| 291 | autoFocus={props.autoFocus ?? false} | 304 | autoFocus={props.autoFocus ?? false} |
| 292 | autoCorrect={props.autoCorrect ?? true} | 305 | autoCorrect={props.autoCorrect ?? true} |
| 293 | spellCheck={props.spellCheck ?? true} | 306 | spellCheck={props.spellCheck ?? true} |
| 307 | textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} | ||
| 308 | maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} | ||
| 294 | style={{ flex: 1, minHeight: 0 }} | 309 | style={{ flex: 1, minHeight: 0 }} |
| 295 | onComposerChange={(event) => { | 310 | onComposerChange={(event) => { |
| 296 | const acknowledgedEventCount = acceptNativeEvent( | 311 | const acknowledgedEventCount = acceptNativeEvent( |
| ... | @@ -325,7 +340,36 @@ export function ComposerEditor({ | ... | @@ -325,7 +340,36 @@ export function ComposerEditor({ |
| 325 | }} | 340 | }} |
| 326 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} | 341 | onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} |
| 327 | onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} | 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 | onComposerFocus={onFocus} | 373 | onComposerFocus={onFocus} |
| 330 | onComposerBlur={onBlur} | 374 | onComposerBlur={onBlur} |
| 331 | /> | 375 | /> |
| ... | @@ -337,4 +381,5 @@ export type { | ... | @@ -337,4 +381,5 @@ export type { |
| 337 | ComposerEditorHandle, | 381 | ComposerEditorHandle, |
| 338 | ComposerEditorProps, | 382 | ComposerEditorProps, |
| 339 | ComposerEditorSelection, | 383 | ComposerEditorSelection, |
| 384 | ComposerTextPaste, | ||
| 340 | } from "./T3ComposerEditor.types"; | 385 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.tsx+2| ... | @@ -12,6 +12,7 @@ export function ComposerEditor({ | ... | @@ -12,6 +12,7 @@ export function ComposerEditor({ |
| 12 | skills: _skills, | 12 | skills: _skills, |
| 13 | selection, | 13 | selection, |
| 14 | onPasteImages, | 14 | onPasteImages, |
| 15 | onPasteText: _onPasteText, | ||
| 15 | style, | 16 | style, |
| 16 | textStyle, | 17 | textStyle, |
| 17 | contentInsetVertical = 0, | 18 | contentInsetVertical = 0, |
| ... | @@ -65,4 +66,5 @@ export type { | ... | @@ -65,4 +66,5 @@ export type { |
| 65 | ComposerEditorHandle, | 66 | ComposerEditorHandle, |
| 66 | ComposerEditorProps, | 67 | ComposerEditorProps, |
| 67 | ComposerEditorSelection, | 68 | ComposerEditorSelection, |
| 69 | ComposerTextPaste, | ||
| 68 | } from "./T3ComposerEditor.types"; | 70 | } from "./T3ComposerEditor.types"; |
apps/mobile/src/native/T3ComposerEditor.types.ts+14-5| ... | @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { | ... | @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { |
| 7 | readonly end: number; | 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 | export interface ComposerEditorHandle { | 17 | export interface ComposerEditorHandle { |
| 11 | focus: () => void; | 18 | focus: () => void; |
| 12 | blur: () => void; | 19 | blur: () => void; |
| ... | @@ -18,11 +25,12 @@ export interface ComposerEditorProps { | ... | @@ -18,11 +25,12 @@ export interface ComposerEditorProps { |
| 18 | readonly value: string; | 25 | readonly value: string; |
| 19 | readonly context?: OrchestrationMessageContext; | 26 | readonly context?: OrchestrationMessageContext; |
| 20 | readonly clipboardFragment?: string; | 27 | readonly clipboardFragment?: string; |
| 21 | readonly onPasteContext?: (clipboard: { | 28 | readonly onPasteContext?: ( |
| 22 | readonly text: string; | 29 | clipboard: ComposerTextPaste & { |
| 23 | readonly fragment: string; | 30 | readonly fragment: string; |
| 24 | readonly html: string; | 31 | readonly html: string; |
| 25 | }) => void; | 32 | }, |
| 33 | ) => void; | ||
| 26 | readonly skills?: ReadonlyArray< | 34 | readonly skills?: ReadonlyArray< |
| 27 | Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> & | 35 | Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> & |
| 28 | Partial<Pick<ServerProviderSkill, "path">> | 36 | Partial<Pick<ServerProviderSkill, "path">> |
| ... | @@ -50,6 +58,7 @@ export interface ComposerEditorProps { | ... | @@ -50,6 +58,7 @@ export interface ComposerEditorProps { |
| 50 | readonly start: number; | 58 | readonly start: number; |
| 51 | readonly end: number; | 59 | readonly end: number; |
| 52 | }) => void; | 60 | }) => void; |
| 61 | readonly onPasteText?: (paste: ComposerTextPaste) => void; | ||
| 53 | readonly onFocus?: () => void; | 62 | readonly onFocus?: () => void; |
| 54 | readonly onBlur?: () => void; | 63 | readonly onBlur?: () => void; |
| 55 | /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ | 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,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,10 +148,14 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ |
| 148 | })); | 148 | })); |
| 149 | 149 | ||
| 150 | import type { DraftComposerAttachment } from "../lib/composerImages"; | 150 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 151 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | ||
| 151 | import { appAtomRegistry } from "./atom-registry"; | 152 | import { appAtomRegistry } from "./atom-registry"; |
| 152 | import { threadOutboxManager } from "./thread-outbox"; | 153 | import { threadOutboxManager } from "./thread-outbox"; |
| 153 | import { | 154 | import { |
| 154 | appendComposerDraftAttachments, | 155 | appendComposerDraftAttachments, |
| 156 | captureComposerDraftInsertion, | ||
| 157 | countComposerDraftAttachmentsAfterSelection, | ||
| 158 | getComposerDraftAfterSelection, | ||
| 155 | archiveCloudComposerDrafts, | 159 | archiveCloudComposerDrafts, |
| 156 | clearComposerDraftContent, | 160 | clearComposerDraftContent, |
| 157 | clearComposerDraftContentState, | 161 | clearComposerDraftContentState, |
| ... | @@ -181,6 +185,7 @@ import { | ... | @@ -181,6 +185,7 @@ import { |
| 181 | retargetNewTaskDraft, | 185 | retargetNewTaskDraft, |
| 182 | setComposerDraftText, | 186 | setComposerDraftText, |
| 183 | insertComposerDraftContext, | 187 | insertComposerDraftContext, |
| 188 | insertComposerDraftText, | ||
| 184 | rememberComposerDraftSelection, | 189 | rememberComposerDraftSelection, |
| 185 | setComposerDraftAttachmentUpload, | 190 | setComposerDraftAttachmentUpload, |
| 186 | waitForComposerDraftsLoaded, | 191 | waitForComposerDraftsLoaded, |
| ... | @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { | ... | @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { |
| 233 | } | 238 | } |
| 234 | 239 | ||
| 235 | describe("mobile composer drafts", () => { | 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 | it.each([false, true])( | 316 | it.each([false, true])( |
| 237 | "restores deleted file chips and releases undo history (uploaded: %s)", | 317 | "restores deleted file chips and releases undo history (uploaded: %s)", |
| 238 | async (uploaded) => { | 318 | async (uploaded) => { |
| ... | @@ -303,6 +383,30 @@ describe("mobile composer drafts", () => { | ... | @@ -303,6 +383,30 @@ describe("mobile composer drafts", () => { |
| 303 | expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); | 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 | it("drops chips and records for attachments a replace no longer keeps", () => { | 410 | it("drops chips and records for attachments a replace no longer keeps", () => { |
| 307 | const key = "new-task:draft-1"; | 411 | const key = "new-task:draft-1"; |
| 308 | const kept = { | 412 | const kept = { |
| ... | @@ -426,41 +530,57 @@ describe("mobile composer drafts", () => { | ... | @@ -426,41 +530,57 @@ describe("mobile composer drafts", () => { |
| 426 | expect(reloaded.cloudDrafts.signedOut).toEqual({}); | 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 () => { | 533 | it.each([ |
| 430 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 534 | { name: "notes.txt", mimeType: "text/plain" }, |
| 431 | onTestFinished(() => outboxLoad.mockRestore()); | 535 | { name: "document-photo.png", mimeType: "image/png" }, |
| 432 | const cleanup = Promise.withResolvers<void>(); | 536 | { name: "recording.mp4", mimeType: "video/mp4" }, |
| 433 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | 537 | ])( |
| 434 | cleanup.resolve(); | 538 | "removes $name after its last reference is deleted, while retaining native images", |
| 435 | }); | 539 | async ({ name, mimeType }) => { |
| 436 | const key = "environment-1:remove-context-files"; | 540 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 437 | const file = { | 541 | onTestFinished(() => outboxLoad.mockRestore()); |
| 438 | id: "file-1", | 542 | const cleanup = Promise.withResolvers<void>(); |
| 439 | type: "file" as const, | 543 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { |
| 440 | name: "notes.txt", | 544 | cleanup.resolve(); |
| 441 | mimeType: "text/plain", | 545 | }); |
| 442 | sizeBytes: 4, | 546 | const key = "environment-1:remove-context-files"; |
| 443 | fileUri: "file:///notes.txt", | 547 | const file = { |
| 444 | }; | 548 | id: "file-1", |
| 445 | const image = { | 549 | type: "file" as const, |
| 446 | ...file, | 550 | name, |
| 447 | id: "image-1", | 551 | mimeType, |
| 448 | type: "image" as const, | 552 | sizeBytes: 4, |
| 449 | name: "image.png", | 553 | fileUri: `file:///${name}`, |
| 450 | mimeType: "image/png", | 554 | }; |
| 451 | fileUri: "file:///image.png", | 555 | const image = { |
| 452 | previewUri: "file:///image.png", | 556 | ...file, |
| 453 | }; | 557 | id: "image-1", |
| 454 | appendComposerDraftAttachments(key, [file, image], { appendReference: true }); | 558 | type: "image" as const, |
| 455 | const fileLink = "[notes.txt](t3-context://v1/file/file-1)"; | 559 | name: "image.png", |
| 456 | setComposerDraftText(key, `${fileLink} ${fileLink}`); | 560 | mimeType: "image/png", |
| 457 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | 561 | fileUri: "file:///image.png", |
| 458 | setComposerDraftText(key, fileLink); | 562 | previewUri: "file:///image.png", |
| 459 | expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); | 563 | }; |
| 460 | setComposerDraftText(key, "plain text"); | 564 | appendComposerDraftAttachments(key, [file, image], { appendReference: true }); |
| 461 | expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); | 565 | const fileLink = formatComposerContextReference( |
| 462 | await cleanup.promise; | 566 | getComposerDraftSnapshot(key).context!.records[0]!, |
| 463 | }); | 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 | it("rejects attachments atomically when no context slots remain", async () => { | 585 | it("rejects attachments atomically when no context slots remain", async () => { |
| 466 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 586 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { | ... | @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { |
| 506 | expect(getComposerDraftSnapshot(key)).toEqual(before); | 626 | expect(getComposerDraftSnapshot(key)).toEqual(before); |
| 507 | await cleanup.promise; | 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 | it("inserts context at the saved caret and retains its payload through persistence and restore", () => { | 813 | it("inserts context at the saved caret and retains its payload through persistence and restore", () => { |
| 510 | const draftKey = "context-environment:context-thread"; | 814 | const draftKey = "context-environment:context-thread"; |
| 511 | const record = { | 815 | const record = { |
| ... | @@ -564,7 +868,25 @@ describe("mobile composer drafts", () => { | ... | @@ -564,7 +868,25 @@ describe("mobile composer drafts", () => { |
| 564 | }, | 868 | }, |
| 565 | }).drafts, | 869 | }).drafts, |
| 566 | ).toEqual({ | 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,8 +1204,34 @@ describe("mobile composer drafts", () => { |
| 882 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); | 1204 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); |
| 883 | onTestFinished(() => enqueue.mockRestore()); | 1205 | onTestFinished(() => enqueue.mockRestore()); |
| 884 | await restoreCloudComposerDrafts("account-a"); | 1206 | await restoreCloudComposerDrafts("account-a"); |
| 885 | expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); | 1207 | expect(getComposerDraftSnapshot(key)).toEqual( |
| 886 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); | 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 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); | 1235 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); |
| 888 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); | 1236 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); |
| 889 | const persisted = decodePersistedComposerState( | 1237 | const persisted = decodePersistedComposerState( |
| ... | @@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => { | ... | @@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => { |
| 2314 | await fresh.releaseUnusedComposerAttachmentFiles([file]); | 2662 | await fresh.releaseUnusedComposerAttachmentFiles([file]); |
| 2315 | 2663 | ||
| 2316 | expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ | 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 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | 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,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity"; |
| 24 | import { writeFileAtomically } from "../lib/atomic-file"; | 24 | import { writeFileAtomically } from "../lib/atomic-file"; |
| 25 | import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; | 25 | import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; |
| 26 | import { | 26 | import { |
| 27 | collectComposerContextReferences, | ||
| 27 | formatComposerContextReference, | 28 | formatComposerContextReference, |
| 28 | sanitizeComposerContextLabel, | 29 | sanitizeComposerContextLabel, |
| 29 | replaceComposerContextReferences, | 30 | replaceComposerContextReferences, |
| 30 | } from "@t3tools/shared/composerContextReferences"; | 31 | } from "@t3tools/shared/composerContextReferences"; |
| 31 | import { imageMimeType } from "@t3tools/shared/image"; | 32 | import { imageMimeType } from "@t3tools/shared/image"; |
| 33 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 32 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; | 34 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 33 | import { | 35 | import { |
| 34 | composerAttachmentFileReferenceKey, | 36 | composerAttachmentFileReferenceKey, |
| ... | @@ -95,6 +97,89 @@ export function readComposerDraftSelection( | ... | @@ -95,6 +97,89 @@ export function readComposerDraftSelection( |
| 95 | return { start: lastComposerSelection.start, end: lastComposerSelection.end }; | 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 | /** Retains file bytes while native text undo can restore their references. */ | 183 | /** Retains file bytes while native text undo can restore their references. */ |
| 99 | export function createComposerDraftContextHistory() { | 184 | export function createComposerDraftContextHistory() { |
| 100 | const restoreContext = createComposerContextHistory(); | 185 | const restoreContext = createComposerContextHistory(); |
| ... | @@ -162,16 +247,36 @@ export function setComposerDraftContext( | ... | @@ -162,16 +247,36 @@ export function setComposerDraftContext( |
| 162 | 247 | ||
| 163 | export function insertComposerDraftContext( | 248 | export function insertComposerDraftContext( |
| 164 | draftKey: string, | 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 | ): boolean { | 256 | ): boolean { |
| 167 | let inserted = false; | 257 | let inserted = false; |
| 258 | let removed: ReadonlyArray<DraftComposerAttachment> = []; | ||
| 168 | updateComposerDrafts((current) => { | 259 | updateComposerDrafts((current) => { |
| 169 | const draft = normalizeDraft(current[draftKey]); | 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 | if (!nextDraft) return current; | 274 | if (!nextDraft) return current; |
| 172 | inserted = true; | 275 | inserted = true; |
| 276 | removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); | ||
| 173 | return { ...current, [draftKey]: nextDraft }; | 277 | return { ...current, [draftKey]: nextDraft }; |
| 174 | }); | 278 | }); |
| 279 | scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); | ||
| 175 | return inserted; | 280 | return inserted; |
| 176 | } | 281 | } |
| 177 | 282 | ||
| ... | @@ -179,13 +284,9 @@ function draftWithInsertedContext( | ... | @@ -179,13 +284,9 @@ function draftWithInsertedContext( |
| 179 | draftKey: string, | 284 | draftKey: string, |
| 180 | draft: ComposerDraft, | 285 | draft: ComposerDraft, |
| 181 | content: { text: string; context: OrchestrationMessageContext }, | 286 | content: { text: string; context: OrchestrationMessageContext }, |
| 287 | target?: ComposerDraftInsertion, | ||
| 182 | ): ComposerDraft | null { | 288 | ): ComposerDraft | null { |
| 183 | const selection = | 289 | const { start, end } = contextInsertionRange(draftKey, draft, target); |
| 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)); | ||
| 189 | const before = draft.text.slice(0, start); | 290 | const before = draft.text.slice(0, start); |
| 190 | const after = draft.text.slice(end); | 291 | const after = draft.text.slice(end); |
| 191 | const insertion = `${before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content.text) ? " " : ""}${content.text}${!/\s$/.test(content.text) && (after.length === 0 || !/^\s/.test(after)) ? " " : ""}`; | 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,7 +301,7 @@ function draftWithInsertedContext( |
| 200 | start: start + insertion.length, | 301 | start: start + insertion.length, |
| 201 | end: start + insertion.length, | 302 | end: start + insertion.length, |
| 202 | }; | 303 | }; |
| 203 | return { ...draft, text, context }; | 304 | return withReferencedContextFiles(draft, text, context); |
| 204 | } | 305 | } |
| 205 | 306 | ||
| 206 | export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()( | 307 | export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()( |
| ... | @@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void { | ... | @@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void { |
| 352 | persistRetryNeeded = false; | 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 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { | 518 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| 356 | if (!draft) { | 519 | if (!draft) { |
| 357 | return EMPTY_DRAFT; | 520 | return EMPTY_DRAFT; |
| ... | @@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft( | ... | @@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft( |
| 421 | draft: ComposerDraft, | 584 | draft: ComposerDraft, |
| 422 | now: string, | 585 | now: string, |
| 423 | ): readonly [key: string, draft: ComposerDraft] { | 586 | ): readonly [key: string, draft: ComposerDraft] { |
| 587 | const restored = restoreMissingComposerFileReferences(draft); | ||
| 424 | const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; | 588 | const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; |
| 425 | if (legacy === null) { | 589 | if (legacy === null) { |
| 426 | return [key, draft]; | 590 | return [key, restored]; |
| 427 | } | 591 | } |
| 428 | return [ | 592 | return [ |
| 429 | newTaskDraftKey(newDraftId()), | 593 | newTaskDraftKey(newDraftId()), |
| 430 | { | 594 | { |
| 431 | ...draft, | 595 | ...restored, |
| 432 | project: { | 596 | project: { |
| 433 | environmentId: EnvironmentIdSchema.make(legacy.environmentId), | 597 | environmentId: EnvironmentIdSchema.make(legacy.environmentId), |
| 434 | projectId: ProjectIdSchema.make(legacy.projectId), | 598 | projectId: ProjectIdSchema.make(legacy.projectId), |
| ... | @@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void { | ... | @@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void { |
| 1089 | updateComposerDrafts((current) => { | 1253 | updateComposerDrafts((current) => { |
| 1090 | const existing = normalizeDraft(current[draftKey]); | 1254 | const existing = normalizeDraft(current[draftKey]); |
| 1091 | const context = referencedComposerContext(value, existing.context); | 1255 | const context = referencedComposerContext(value, existing.context); |
| 1092 | const retainedIds = new Set(context?.records.map((record) => record.contextId)); | 1256 | const draft = withReferencedContextFiles(existing, value, context); |
| 1093 | const removedFileIds = new Set( | 1257 | removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment)); |
| 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 | }; | ||
| 1109 | return withComposerDraft(current, draftKey, draft); | 1258 | return withComposerDraft(current, draftKey, draft); |
| 1110 | }); | 1259 | }); |
| 1111 | scheduleUnusedComposerAttachmentCleanup(removed); | 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 | export function appendComposerDraftText(draftKey: string, value: string): void { | 1278 | export function appendComposerDraftText(draftKey: string, value: string): void { |
| 1115 | updateComposerDrafts((current) => { | 1279 | updateComposerDrafts((current) => { |
| 1116 | const existing = normalizeDraft(current[draftKey]); | 1280 | const existing = normalizeDraft(current[draftKey]); |
| ... | @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( | ... | @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( |
| 1137 | options?: { | 1301 | options?: { |
| 1138 | readonly allowOverflow?: boolean; | 1302 | readonly allowOverflow?: boolean; |
| 1139 | readonly appendReference?: boolean; | 1303 | readonly appendReference?: boolean; |
| 1304 | readonly insertion?: ComposerDraftInsertion; | ||
| 1140 | readonly maxAttachments?: number; | 1305 | readonly maxAttachments?: number; |
| 1141 | }, | 1306 | }, |
| 1142 | ): number { | 1307 | ): number { |
| ... | @@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments( | ... | @@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments( |
| 1144 | return 0; | 1309 | return 0; |
| 1145 | } | 1310 | } |
| 1146 | let rejected: ReadonlyArray<DraftComposerAttachment> = []; | 1311 | let rejected: ReadonlyArray<DraftComposerAttachment> = []; |
| 1312 | let removed: ReadonlyArray<DraftComposerAttachment> = []; | ||
| 1147 | updateComposerDrafts((current) => { | 1313 | updateComposerDrafts((current) => { |
| 1148 | const existing = normalizeDraft(current[draftKey]); | 1314 | const existing = normalizeDraft(current[draftKey]); |
| 1315 | const retained = options?.appendReference | ||
| 1316 | ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) | ||
| 1317 | : existing; | ||
| 1149 | const remaining = options?.allowOverflow | 1318 | const remaining = options?.allowOverflow |
| 1150 | ? attachments.length | 1319 | ? attachments.length |
| 1151 | : Math.max( | 1320 | : Math.max( |
| ... | @@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments( | ... | @@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments( |
| 1153 | Math.min( | 1322 | Math.min( |
| 1154 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 1323 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 1155 | options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 1324 | options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 1156 | ) - existing.attachments.length, | 1325 | ) - retained.attachments.length, |
| 1157 | ); | 1326 | ); |
| 1158 | const contextCapacity = options?.appendReference | 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 | : attachments.length; | 1329 | : attachments.length; |
| 1161 | const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); | 1330 | const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); |
| 1162 | rejected = attachments.slice(accepted.length); | 1331 | rejected = attachments.slice(accepted.length); |
| ... | @@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments( | ... | @@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments( |
| 1165 | } | 1334 | } |
| 1166 | let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; | 1335 | let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; |
| 1167 | if (options?.appendReference) { | 1336 | if (options?.appendReference) { |
| 1168 | const records = accepted.map((attachment) => { | 1337 | const records = accepted.map((attachment) => attachmentContextRecord(attachment)); |
| 1169 | const common = { | 1338 | const inserted = draftWithInsertedContext( |
| 1170 | version: 1 as const, | 1339 | draftKey, |
| 1171 | contextId: ComposerContextId.make(attachment.id), | 1340 | draft, |
| 1172 | label: sanitizeComposerContextLabel(attachment.name, attachment.type), | 1341 | { |
| 1173 | attachmentId: attachment.id, | 1342 | text: records.map(formatComposerContextReference).join(" "), |
| 1174 | name: attachment.name, | 1343 | context: { version: 1, records }, |
| 1175 | mimeType: attachment.mimeType, | 1344 | }, |
| 1176 | sizeBytes: attachment.sizeBytes, | 1345 | options?.insertion, |
| 1177 | }; | 1346 | ); |
| 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 | }); | ||
| 1188 | if (!inserted) { | 1347 | if (!inserted) { |
| 1189 | rejected = attachments; | 1348 | rejected = attachments; |
| 1190 | return current; | 1349 | return current; |
| 1191 | } | 1350 | } |
| 1192 | draft = { ...inserted, attachments: [...inserted.attachments] }; | 1351 | draft = { ...inserted, attachments: [...inserted.attachments] }; |
| 1352 | removed = existing.attachments.filter( | ||
| 1353 | (attachment) => !draft.attachments.includes(attachment), | ||
| 1354 | ); | ||
| 1193 | } | 1355 | } |
| 1194 | return { | 1356 | return { |
| 1195 | ...current, | 1357 | ...current, |
| 1196 | [draftKey]: draft, | 1358 | [draftKey]: draft, |
| 1197 | }; | 1359 | }; |
| 1198 | }); | 1360 | }); |
| 1199 | scheduleUnusedComposerAttachmentCleanup(rejected); | 1361 | scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); |
| 1200 | return rejected.length; | 1362 | return rejected.length; |
| 1201 | } | 1363 | } |
| 1202 | 1364 |
apps/mobile/src/state/use-thread-composer-state.ts+161-9| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 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 | import { Alert } from "react-native"; | 4 | import { Alert } from "react-native"; |
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| ... | @@ -7,6 +8,7 @@ import { | ... | @@ -7,6 +8,7 @@ import { |
| 7 | DEFAULT_PROVIDER_INTERACTION_MODE, | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 8 | MessageId, | 9 | MessageId, |
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 10 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 11 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 10 | type EnvironmentId, | 12 | type EnvironmentId, |
| 11 | type ModelSelection, | 13 | type ModelSelection, |
| 12 | type ProviderInteractionMode, | 14 | type ProviderInteractionMode, |
| ... | @@ -14,6 +16,8 @@ import { | ... | @@ -14,6 +16,8 @@ import { |
| 14 | type ThreadId, | 16 | type ThreadId, |
| 15 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 16 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 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 | import { | 21 | import { |
| 18 | parseCodexFeedbackCommand, | 22 | parseCodexFeedbackCommand, |
| 19 | submitCodexFeedback, | 23 | submitCodexFeedback, |
| ... | @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; | ... | @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; |
| 29 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | 33 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; |
| 30 | import { | 34 | import { |
| 31 | convertPastedImagesToAttachments, | 35 | convertPastedImagesToAttachments, |
| 36 | createPastedTextComposerAttachment, | ||
| 32 | pasteComposerClipboard, | 37 | pasteComposerClipboard, |
| 33 | pickComposerFiles, | 38 | pickComposerFiles, |
| 34 | pickComposerMedia, | 39 | pickComposerMedia, |
| 40 | removePersistedComposerAttachmentFile, | ||
| 35 | } from "../lib/composerImages"; | 41 | } from "../lib/composerImages"; |
| 36 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 42 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 37 | import { scopedThreadKey } from "../lib/scopedEntities"; | 43 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| ... | @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; | ... | @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 42 | import { pendingThreadCreationMessage } from "./pending-thread-creation"; | 48 | import { pendingThreadCreationMessage } from "./pending-thread-creation"; |
| 43 | import { | 49 | import { |
| 44 | appendComposerDraftAttachments, | 50 | appendComposerDraftAttachments, |
| 45 | appendComposerDraftText, | 51 | captureComposerDraftInsertion, |
| 52 | countComposerDraftAttachmentsAfterSelection, | ||
| 53 | insertComposerDraftText, | ||
| 46 | insertComposerDraftContext, | 54 | insertComposerDraftContext, |
| 47 | clearComposerDraftContent, | 55 | clearComposerDraftContent, |
| 48 | composerDraftsAtom, | 56 | composerDraftsAtom, |
| ... | @@ -132,6 +140,23 @@ export function useThreadComposerState() { | ... | @@ -132,6 +140,23 @@ export function useThreadComposerState() { |
| 132 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { | 140 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { |
| 133 | reportFailure: false, | 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 | useEffect(() => { | 161 | useEffect(() => { |
| 137 | ensureComposerDraftsLoaded(); | 162 | ensureComposerDraftsLoaded(); |
| ... | @@ -479,9 +504,10 @@ export function useThreadComposerState() { | ... | @@ -479,9 +504,10 @@ export function useThreadComposerState() { |
| 479 | } | 504 | } |
| 480 | 505 | ||
| 481 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 506 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 507 | const insertion = captureComposerDraftInsertion(threadKey); | ||
| 482 | const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; | 508 | const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; |
| 483 | const result = await pickComposerMedia({ | 509 | const result = await pickComposerMedia({ |
| 484 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 510 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), |
| 485 | maxVideoBytes: | 511 | maxVideoBytes: |
| 486 | capabilities?.attachmentUploads === true | 512 | capabilities?.attachmentUploads === true |
| 487 | ? capabilities.fileAttachments?.maxUploadBytes | 513 | ? capabilities.fileAttachments?.maxUploadBytes |
| ... | @@ -489,6 +515,7 @@ export function useThreadComposerState() { | ... | @@ -489,6 +515,7 @@ export function useThreadComposerState() { |
| 489 | }); | 515 | }); |
| 490 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { | 516 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { |
| 491 | appendReference: true, | 517 | appendReference: true, |
| 518 | insertion, | ||
| 492 | }); | 519 | }); |
| 493 | const problems = [ | 520 | const problems = [ |
| 494 | ...(result.error ? [result.error] : []), | 521 | ...(result.error ? [result.error] : []), |
| ... | @@ -514,13 +541,15 @@ export function useThreadComposerState() { | ... | @@ -514,13 +541,15 @@ export function useThreadComposerState() { |
| 514 | } | 541 | } |
| 515 | 542 | ||
| 516 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 543 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 544 | const insertion = captureComposerDraftInsertion(threadKey); | ||
| 517 | // pickComposerFiles clamps the advertised limit to the contract maximum. | 545 | // pickComposerFiles clamps the advertised limit to the contract maximum. |
| 518 | const result = await pickComposerFiles({ | 546 | const result = await pickComposerFiles({ |
| 519 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 547 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), |
| 520 | maxBytes, | 548 | maxBytes, |
| 521 | }); | 549 | }); |
| 522 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { | 550 | const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { |
| 523 | appendReference: true, | 551 | appendReference: true, |
| 552 | insertion, | ||
| 524 | }); | 553 | }); |
| 525 | // The picker error and the live-cap rejection can both happen in one | 554 | // The picker error and the live-cap rejection can both happen in one |
| 526 | // pick; report both in a single alert. | 555 | // pick; report both in a single alert. |
| ... | @@ -541,14 +570,81 @@ export function useThreadComposerState() { | ... | @@ -541,14 +570,81 @@ export function useThreadComposerState() { |
| 541 | } | 570 | } |
| 542 | 571 | ||
| 543 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 572 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 573 | const insertion = captureComposerDraftInsertion(threadKey); | ||
| 544 | const result = await pasteComposerClipboard({ | 574 | const result = await pasteComposerClipboard({ |
| 545 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 575 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), |
| 546 | }); | 576 | }); |
| 547 | const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { | 577 | const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { |
| 548 | appendReference: true, | 578 | appendReference: true, |
| 579 | insertion, | ||
| 549 | }); | 580 | }); |
| 550 | if (result.text) { | 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 | if (result.error) { | 649 | if (result.error) { |
| 554 | setPendingConnectionError(result.error); | 650 | setPendingConnectionError(result.error); |
| ... | @@ -557,7 +653,12 @@ export function useThreadComposerState() { | ... | @@ -557,7 +653,12 @@ export function useThreadComposerState() { |
| 557 | `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, | 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 | const onNativePasteImages = useCallback( | 663 | const onNativePasteImages = useCallback( |
| 563 | async (uris: ReadonlyArray<string>) => { | 664 | async (uris: ReadonlyArray<string>) => { |
| ... | @@ -566,13 +667,14 @@ export function useThreadComposerState() { | ... | @@ -566,13 +667,14 @@ export function useThreadComposerState() { |
| 566 | } | 667 | } |
| 567 | 668 | ||
| 568 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 669 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 670 | const insertion = captureComposerDraftInsertion(threadKey); | ||
| 569 | try { | 671 | try { |
| 570 | const images = await convertPastedImagesToAttachments({ | 672 | const images = await convertPastedImagesToAttachments({ |
| 571 | uris, | 673 | uris, |
| 572 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, | 674 | existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), |
| 573 | }); | 675 | }); |
| 574 | if (images.length > 0) { | 676 | if (images.length > 0) { |
| 575 | appendComposerDraftAttachments(threadKey, images, { appendReference: true }); | 677 | appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion }); |
| 576 | } | 678 | } |
| 577 | } catch (error) { | 679 | } catch (error) { |
| 578 | console.error("[native paste] error converting images", { | 680 | console.error("[native paste] error converting images", { |
| ... | @@ -586,6 +688,55 @@ export function useThreadComposerState() { | ... | @@ -586,6 +688,55 @@ export function useThreadComposerState() { |
| 586 | [composerDrafts, selectedThreadShell], | 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 | const onRemoveDraftImage = useCallback( | 740 | const onRemoveDraftImage = useCallback( |
| 590 | (imageId: string) => { | 741 | (imageId: string) => { |
| 591 | if (!selectedThreadShell) { | 742 | if (!selectedThreadShell) { |
| ... | @@ -663,6 +814,7 @@ export function useThreadComposerState() { | ... | @@ -663,6 +814,7 @@ export function useThreadComposerState() { |
| 663 | onPickDraftFiles, | 814 | onPickDraftFiles, |
| 664 | onPasteIntoDraft, | 815 | onPasteIntoDraft, |
| 665 | onNativePasteImages, | 816 | onNativePasteImages, |
| 817 | onNativePasteText, | ||
| 666 | onRemoveDraftImage, | 818 | onRemoveDraftImage, |
| 667 | onSendMessage, | 819 | onSendMessage, |
| 668 | onUpdateModelSelection, | 820 | onUpdateModelSelection, |
apps/server/src/provider/Layers/ProviderService.test.ts+46| ... | @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 2228 | assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); | 2228 | assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); |
| 2229 | assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); | 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 | yield* provider.stopSession({ threadId: session.threadId }); | 2250 | yield* provider.stopSession({ threadId: session.threadId }); |
| 2232 | }), | 2251 | }), |
| 2233 | ); | 2252 | ); |
| ... | @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { | ... | @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { |
| 4683 | 4702 | ||
| 4684 | const validation = makeProviderServiceLayer(); | 4703 | const validation = makeProviderServiceLayer(); |
| 4685 | validation.layer("ProviderServiceLive validation", (it) => { | 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 | it.effect("rejects citation-expanded input over the provider character limit", () => | 4732 | it.effect("rejects citation-expanded input over the provider character limit", () => |
| 4687 | Effect.gen(function* () { | 4733 | Effect.gen(function* () { |
| 4688 | const provider = yield* ProviderService.ProviderService; | 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,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1593 | 1593 | ||
| 1594 | // Every attachment gets an on-disk path in the prompt so the model's tools | 1594 | // Every attachment gets an on-disk path in the prompt so the model's tools |
| 1595 | // can dereference the actual file. All attachments then go to the adapter, | 1595 | // can dereference the actual file. All attachments then go to the adapter, |
| 1596 | // and each adapter decides what its provider ingests natively: OpenCode | 1596 | // and each adapter decides what its provider ingests natively. Folded |
| 1597 | // sends generic files as file parts, the others send images only and rely | 1597 | // clipboard text remains path-only everywhere: eagerly embedding it would |
| 1598 | // on the path line for everything else. Unresolvable ids are skipped here | 1598 | // spend the same context the client deliberately preserved by folding it. |
| 1599 | // and surface as adapter errors when the file is read. | 1599 | // Unresolvable ids are skipped here and surface as adapter errors when the |
| 1600 | // file is read. | ||
| 1600 | let inputTextWithAttachmentContext = inputTextWithCitations; | 1601 | let inputTextWithAttachmentContext = inputTextWithCitations; |
| 1601 | const appendAttachmentContext = (context: string | undefined) => { | 1602 | const appendAttachmentContext = (context: string | undefined) => { |
| 1602 | if (context === undefined) return; | 1603 | if (context === undefined) return true; |
| 1603 | const candidate = inputTextWithAttachmentContext | 1604 | const candidate = inputTextWithAttachmentContext |
| 1604 | ? `${inputTextWithAttachmentContext}\n\n${context}` | 1605 | ? `${inputTextWithAttachmentContext}\n\n${context}` |
| 1605 | : context; | 1606 | : context; |
| 1606 | if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { | 1607 | if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { |
| 1607 | inputTextWithAttachmentContext = candidate; | 1608 | inputTextWithAttachmentContext = candidate; |
| 1609 | return true; | ||
| 1608 | } | 1610 | } |
| 1611 | return false; | ||
| 1609 | }; | 1612 | }; |
| 1610 | for (const attachment of attachments) { | 1613 | for (const attachment of attachments) { |
| 1611 | const attachmentPath = resolveAttachmentPath({ | 1614 | const attachmentPath = resolveAttachmentPath({ |
| 1612 | attachmentsDir: serverConfig.attachmentsDir, | 1615 | attachmentsDir: serverConfig.attachmentsDir, |
| 1613 | attachment, | 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 | attachmentPath === null | 1623 | attachmentPath === null |
| 1617 | ? undefined | 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 | for (const attachment of attachments) { | 1636 | for (const attachment of attachments) { |
| 1622 | const source = | 1637 | const source = |
apps/server/src/provider/acp/AntigravityAcpSupport.test.ts+45| ... | @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { | ... | @@ -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 | it.effect("sends supported audio files as native audio content", () => | 327 | it.effect("sends supported audio files as native audio content", () => |
| 283 | Effect.gen(function* () { | 328 | Effect.gen(function* () { |
| 284 | const fixture = yield* makeAttachmentFixture(); | 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,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi |
| 264 | let totalBytes = 0; | 264 | let totalBytes = 0; |
| 265 | 265 | ||
| 266 | for (const attachment of input.attachments ?? []) { | 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 | const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; | 274 | const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; |
| 268 | const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); | 275 | const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); |
| 269 | const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); | 276 | const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); |
| ... | @@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi | ... | @@ -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 | const size = Number(info.size); | 314 | const size = Number(info.size); |
| 300 | const limit = image | 315 | const limit = image |
| 301 | ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES | 316 | ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES |
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+14| ... | @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { | ... | @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { |
| 318 | ["application/pdf", "text/markdown", "image/png"], | 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,6 +462,13 @@ export function toOpenCodeFileParts(input: { |
| 462 | const parts: Array<FilePartInput> = []; | 462 | const parts: Array<FilePartInput> = []; |
| 463 | 463 | ||
| 464 | for (const attachment of input.attachments ?? []) { | 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 | if (!isOpenCodeNativeFilePart(attachment)) { | 472 | if (!isOpenCodeNativeFilePart(attachment)) { |
| 466 | continue; | 473 | continue; |
| 467 | } | 474 | } |
apps/web/src/components/ChatView.tsx+25-6| ... | @@ -46,6 +46,8 @@ import { | ... | @@ -46,6 +46,8 @@ import { |
| 46 | } from "@t3tools/contracts"; | 46 | } from "@t3tools/contracts"; |
| 47 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | 47 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 48 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; | 48 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 49 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; | ||
| 50 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | ||
| 49 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 51 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 50 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 52 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 51 | import { | 53 | import { |
| ... | @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; | ... | @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; |
| 95 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 97 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 96 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; | 98 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 97 | import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; | 99 | import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; |
| 100 | import { isMacPlatform } from "../lib/utils"; | ||
| 98 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | 101 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 99 | import { useShallow } from "zustand/react/shallow"; | 102 | import { useShallow } from "zustand/react/shallow"; |
| 100 | import { | 103 | import { |
| ... | @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1608 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); | 1611 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 1609 | const localComposerRef = useRef<ChatComposerHandle | null>(null); | 1612 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1610 | const composerRef = useComposerHandleContext() ?? localComposerRef; | 1613 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1614 | const pasteAsTextShortcutUntilRef = useRef(0); | ||
| 1611 | const [restingComposerControlsHost, setRestingComposerControlsHost] = | 1615 | const [restingComposerControlsHost, setRestingComposerControlsHost] = |
| 1612 | useState<HTMLDivElement | null>(null); | 1616 | useState<HTMLDivElement | null>(null); |
| 1613 | const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); | 1617 | const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); |
| ... | @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { |
| 6519 | // so a paste that follows has no editable target and would be dropped. | 6523 | // so a paste that follows has no editable target and would be dropped. |
| 6520 | // Route it to the composer like a typed key, which also expands it. | 6524 | // Route it to the composer like a typed key, which also expands it. |
| 6521 | useEffect(() => { | 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 | const handler = (event: ClipboardEvent) => { | 6534 | const handler = (event: ClipboardEvent) => { |
| 6523 | if (!activeThreadId || isCommandPaletteOpen()) return; | 6535 | if (!activeThreadId || isCommandPaletteOpen()) return; |
| 6524 | if (getTerminalFocusOwner() !== null) return; | 6536 | if (getTerminalFocusOwner() !== null) return; |
| 6525 | if (composerRef.current?.isModelPickerOpen()) return; | 6537 | if (composerRef.current?.isModelPickerOpen()) return; |
| 6526 | const text = pasteTextToFocusComposer(event); | 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 | if ( | 6543 | if ( |
| 6529 | composerRef.current?.insertTextAtEnd( | 6544 | ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && |
| 6530 | text, | 6545 | composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || |
| 6531 | event.clipboardData ? { clipboardData: event.clipboardData } : undefined, | 6546 | composerRef.current?.insertTextAtEnd(text, { clipboardData }) |
| 6532 | ) | ||
| 6533 | ) { | 6547 | ) { |
| 6534 | event.preventDefault(); | 6548 | event.preventDefault(); |
| 6535 | event.stopPropagation(); | 6549 | event.stopPropagation(); |
| 6536 | } | 6550 | } |
| 6537 | }; | 6551 | }; |
| 6552 | window.addEventListener("keydown", keyHandler, true); | ||
| 6538 | window.addEventListener("paste", handler, true); | 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 | }, [activeThreadId, composerRef]); | 6558 | }, [activeThreadId, composerRef]); |
| 6541 | 6559 | ||
| 6542 | const [pendingRevert, setPendingRevert] = useState<{ | 6560 | const [pendingRevert, setPendingRevert] = useState<{ |
| ... | @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7257 | mimeType: attachment.mimeType, | 7275 | mimeType: attachment.mimeType, |
| 7258 | sizeBytes: attachment.sizeBytes, | 7276 | sizeBytes: attachment.sizeBytes, |
| 7259 | downloadable: false, | 7277 | downloadable: false, |
| 7278 | ...(attachment.source ? { source: attachment.source } : {}), | ||
| 7260 | }, | 7279 | }, |
| 7261 | ); | 7280 | ); |
| 7262 | const shouldAnchorFirstMessage = | 7281 | const shouldAnchorFirstMessage = |
apps/web/src/components/ComposerPromptEditor.test.ts+96-84| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | ||
| 19 | import { | 19 | import { |
| 20 | importPastedComposerText, | 20 | importPastedComposerText, |
| 21 | readPastedComposerContext, | ||
| 21 | registerComposerInlineTokenPaste, | 22 | registerComposerInlineTokenPaste, |
| 22 | } from "./composerInlineTokenPaste"; | 23 | } from "./composerInlineTokenPaste"; |
| 23 | import { | 24 | import { |
| ... | @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { | ... | @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { |
| 498 | }); | 499 | }); |
| 499 | 500 | ||
| 500 | describe("context reference paste", () => { | 501 | describe("context reference paste", () => { |
| 501 | it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { | 502 | it.each([ |
| 502 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | 503 | { focus: "focused", prefix: "" }, |
| 503 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | 504 | { focus: "blurred", prefix: "" }, |
| 504 | editor.update( | 505 | { focus: "focused", prefix: "log ".repeat(10_000) }, |
| 505 | () => { | 506 | { focus: "blurred", prefix: "log ".repeat(10_000) }, |
| 506 | const paragraph = $createParagraphNode(); | 507 | ])( |
| 507 | $getRoot().append(paragraph); | 508 | "imports structured paste when $focus with $prefix.length extra characters", |
| 508 | paragraph.selectEnd(); | 509 | ({ focus, prefix }) => { |
| 509 | }, | 510 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); |
| 510 | { discrete: true }, | 511 | const editor = createEditor({ nodes: [ComposerCitationNode] }); |
| 511 | ); | 512 | editor.update( |
| 512 | const imported: string[] = []; | 513 | () => { |
| 513 | const importFragment = ( | 514 | const paragraph = $createParagraphNode(); |
| 514 | fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, | 515 | $getRoot().append(paragraph); |
| 515 | ) => { | 516 | paragraph.selectEnd(); |
| 516 | imported.push(...fragment.records.map((record) => record.contextId)); | 517 | }, |
| 517 | return new Map([["img-old", "img-new"]]); | 518 | { discrete: true }, |
| 518 | }; | 519 | ); |
| 519 | registerComposerInlineTokenPaste(editor, { | 520 | const imported: string[] = []; |
| 520 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | 521 | const importFragment = ( |
| 521 | createCitationNode: $createComposerCitationNode, | 522 | fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, |
| 522 | createContextReferenceNode: (reference) => | 523 | ) => { |
| 523 | $createTextNode(`<context:${reference.contextId}>`), | 524 | imported.push(...fragment.records.map((record) => record.contextId)); |
| 524 | getExpandedAbsoluteOffsetForPoint: () => 0, | 525 | return new Map([["img-old", "img-new"]]); |
| 525 | importContextFragment: importFragment, | 526 | }; |
| 526 | }); | 527 | registerComposerInlineTokenPaste(editor, { |
| 527 | const event = new TestClipboardEvent( | 528 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), |
| 528 | " and [T](t3-context://v1/terminal/ctx-t)", | 529 | createCitationNode: $createComposerCitationNode, |
| 529 | { | 530 | createContextReferenceNode: (reference) => |
| 530 | "web application/x-t3-context-fragment+json": JSON.stringify({ | 531 | $createTextNode(`<context:${reference.contextId}>`), |
| 531 | version: 1, | 532 | getExpandedAbsoluteOffsetForPoint: () => 0, |
| 532 | source: { environmentId: "env-1" }, | 533 | importContextFragment: importFragment, |
| 533 | records: [ | 534 | }); |
| 534 | { | 535 | const event = new TestClipboardEvent( |
| 535 | version: 1, | 536 | `${prefix} and [T](t3-context://v1/terminal/ctx-t)`, |
| 536 | contextId: "img-old", | 537 | { |
| 537 | kind: "image", | 538 | "web application/x-t3-context-fragment+json": JSON.stringify({ |
| 538 | label: "shot", | 539 | version: 1, |
| 539 | attachmentId: "a", | 540 | source: { environmentId: "env-1" }, |
| 540 | name: "shot.png", | 541 | records: [ |
| 541 | mimeType: "image/png", | 542 | { |
| 542 | sizeBytes: 1, | 543 | version: 1, |
| 543 | }, | 544 | contextId: "img-old", |
| 544 | { | 545 | kind: "image", |
| 545 | version: 1, | 546 | label: "shot", |
| 546 | contextId: "ctx-t", | 547 | attachmentId: "a", |
| 547 | kind: "terminal", | 548 | name: "shot.png", |
| 548 | label: "T", | 549 | mimeType: "image/png", |
| 549 | terminalId: "t", | 550 | sizeBytes: 1, |
| 550 | terminalLabel: "T", | 551 | }, |
| 551 | lineStart: 1, | 552 | { |
| 552 | lineEnd: 1, | 553 | version: 1, |
| 553 | text: "x", | 554 | contextId: "ctx-t", |
| 554 | }, | 555 | kind: "terminal", |
| 555 | { | 556 | label: "T", |
| 556 | version: 1, | 557 | terminalId: "t", |
| 557 | contextId: "img-unrelated", | 558 | terminalLabel: "T", |
| 558 | kind: "image", | 559 | lineStart: 1, |
| 559 | label: "other", | 560 | lineEnd: 1, |
| 560 | attachmentId: "b", | 561 | text: "x", |
| 561 | name: "other.png", | 562 | }, |
| 562 | mimeType: "image/png", | 563 | { |
| 563 | sizeBytes: 1, | 564 | version: 1, |
| 564 | }, | 565 | contextId: "img-unrelated", |
| 565 | ], | 566 | kind: "image", |
| 566 | }), | 567 | label: "other", |
| 567 | }, | 568 | attachmentId: "b", |
| 568 | ); | 569 | name: "other.png", |
| 569 | if (focus === "blurred") { | 570 | mimeType: "image/png", |
| 570 | expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( | 571 | sizeBytes: 1, |
| 571 | " and [T](t3-context://v1/terminal/ctx-t)", | 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 | expect(imported).toEqual(["img-old", "ctx-t"]); | 593 | expect(imported).toEqual(["img-old", "ctx-t"]); |
| 574 | return; | 594 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( |
| 575 | } | 595 | `${prefix}<context:img-new> and <context:ctx-t>`, |
| 576 | editor.update( | 596 | ); |
| 577 | () => { | 597 | }, |
| 578 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | 598 | ); |
| 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 | }); | ||
| 587 | 599 | ||
| 588 | it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { | 600 | it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { |
| 589 | const copied = upgradeLegacyContextMessage( | 601 | const copied = upgradeLegacyContextMessage( |
apps/web/src/components/ComposerPromptEditor.tsx+6-1| ... | @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { | ... | @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { |
| 838 | focus: () => void; | 838 | focus: () => void; |
| 839 | focusAt: (cursor: number) => void; | 839 | focusAt: (cursor: number) => void; |
| 840 | focusAtEnd: () => void; | 840 | focusAtEnd: () => void; |
| 841 | readSelectionRange: () => { start: number; end: number }; | ||
| 841 | requestCitationComment: (request: ComposerCitationCommentRequest) => void; | 842 | requestCitationComment: (request: ComposerCitationCommentRequest) => void; |
| 842 | readSnapshot: () => { | 843 | readSnapshot: () => { |
| 843 | value: string; | 844 | value: string; |
| ... | @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ | ... | @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ |
| 1854 | ), | 1855 | ), |
| 1855 | ); | 1856 | ); |
| 1856 | }, | 1857 | }, |
| 1858 | readSelectionRange: () => { | ||
| 1859 | readSnapshot(); | ||
| 1860 | return selectionRangeRef.current; | ||
| 1861 | }, | ||
| 1857 | requestCitationComment: (request) => { | 1862 | requestCitationComment: (request) => { |
| 1858 | citationCommentRequestRef.current = request; | 1863 | citationCommentRequestRef.current = request; |
| 1859 | const target = editor | 1864 | const target = editor |
| ... | @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ | ... | @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ |
| 2021 | }} | 2026 | }} |
| 2022 | onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} | 2027 | onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} |
| 2023 | onBlur={onPageScrollRelease} | 2028 | onBlur={onPageScrollRelease} |
| 2024 | onPaste={onPaste} | 2029 | onPasteCapture={onPaste} |
| 2025 | /> | 2030 | /> |
| 2026 | } | 2031 | } |
| 2027 | placeholder={ | 2032 | placeholder={ |
apps/web/src/components/chat/ChatComposer.tsx+226-35| ... | @@ -1,13 +1,15 @@ | ... | @@ -1,13 +1,15 @@ |
| 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; | ||
| 1 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; | 2 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; |
| 2 | import { useRightPanelStore } from "~/rightPanelStore"; | 3 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 3 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; | 4 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; |
| 4 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | 5 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 5 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; | 6 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; |
| 6 | import { importPastedComposerText } from "../composerInlineTokenPaste"; | 7 | import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; |
| 7 | import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; | 8 | import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; |
| 8 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 9 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 9 | import { | 10 | import { |
| 10 | questionAttachmentDraftId, | 11 | questionAttachmentDraftId, |
| 12 | countQuestionAttachments, | ||
| 11 | useQuestionAttachmentPreparation, | 13 | useQuestionAttachmentPreparation, |
| 12 | changeQuestionAttachmentPreparation, | 14 | changeQuestionAttachmentPreparation, |
| 13 | } from "../../questionAttachments"; | 15 | } from "../../questionAttachments"; |
| ... | @@ -34,8 +36,15 @@ import { | ... | @@ -34,8 +36,15 @@ import { |
| 34 | ProviderInstanceId, | 36 | ProviderInstanceId, |
| 35 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 37 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 36 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 38 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 39 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 37 | } from "@t3tools/contracts"; | 40 | } from "@t3tools/contracts"; |
| 38 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | 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 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 48 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 40 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 49 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 41 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; | 50 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; |
| ... | @@ -194,6 +203,7 @@ import { | ... | @@ -194,6 +203,7 @@ import { |
| 194 | ensureInlineContextReferences, | 203 | ensureInlineContextReferences, |
| 195 | formatInlineContextReference, | 204 | formatInlineContextReference, |
| 196 | insertInlineContextReference, | 205 | insertInlineContextReference, |
| 206 | inlineContextReferenceReplacement, | ||
| 197 | toKindScopedComposerContextId, | 207 | toKindScopedComposerContextId, |
| 198 | } from "~/lib/composerContextReferences"; | 208 | } from "~/lib/composerContextReferences"; |
| 199 | import { | 209 | import { |
| ... | @@ -284,7 +294,7 @@ import { | ... | @@ -284,7 +294,7 @@ import { |
| 284 | } from "../../lib/snapShotAnimation"; | 294 | } from "../../lib/snapShotAnimation"; |
| 285 | import { resizeSnapShotSource } from "../../lib/snapShotSource"; | 295 | import { resizeSnapShotSource } from "../../lib/snapShotSource"; |
| 286 | import { basenameOfPath } from "../../pierre-icons"; | 296 | import { basenameOfPath } from "../../pierre-icons"; |
| 287 | import { cn, randomUUID } from "~/lib/utils"; | 297 | import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; |
| 288 | import { | 298 | import { |
| 289 | getComposerPromptLengthValidationMessage, | 299 | getComposerPromptLengthValidationMessage, |
| 290 | getComposerSubmissionValidationMessage, | 300 | getComposerSubmissionValidationMessage, |
| ... | @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { | ... | @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { |
| 1212 | text: string, | 1222 | text: string, |
| 1213 | options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, | 1223 | options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, |
| 1214 | ) => boolean; | 1224 | ) => boolean; |
| 1225 | /** Apply large-paste folding for text redirected from a blurred composer. */ | ||
| 1226 | pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; | ||
| 1215 | citeAssistantText: ( | 1227 | citeAssistantText: ( |
| 1216 | citation: AssistantCitation, | 1228 | citation: AssistantCitation, |
| 1217 | sourceAnchor: AssistantCitationSourceAnchor, | 1229 | sourceAnchor: AssistantCitationSourceAnchor, |
| ... | @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2064 | // Refs | 2076 | // Refs |
| 2065 | // ------------------------------------------------------------------ | 2077 | // ------------------------------------------------------------------ |
| 2066 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 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 | const attachmentInputRef = useRef<HTMLInputElement>(null); | 2084 | const attachmentInputRef = useRef<HTMLInputElement>(null); |
| 2068 | const composerFormRef = useRef<HTMLFormElement>(null); | 2085 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 2069 | const composerFooterControlsRef = useRef<HTMLDivElement>(null); | 2086 | const composerFooterControlsRef = useRef<HTMLDivElement>(null); |
| ... | @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2099 | const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); | 2116 | const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); |
| 2100 | isRevertingCheckpointRef.current = isRevertingCheckpoint; | 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 | // Derived: composer send state | 2163 | // Derived: composer send state |
| 2104 | // ------------------------------------------------------------------ | 2164 | // ------------------------------------------------------------------ |
| ... | @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4041 | mimeType: file.mimeType, | 4101 | mimeType: file.mimeType, |
| 4042 | sizeBytes: file.sizeBytes, | 4102 | sizeBytes: file.sizeBytes, |
| 4043 | file: null, | 4103 | file: null, |
| 4104 | ...(file.source ? { source: file.source } : {}), | ||
| 4044 | // An expired upload carries no ids, so it hydrates as a | 4105 | // An expired upload carries no ids, so it hydrates as a |
| 4045 | // needs-reattach row and the "Attach again" flow takes over. | 4106 | // needs-reattach row and the "Attach again" flow takes over. |
| 4046 | ...(expired | 4107 | ...(expired |
| ... | @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4319 | sizeBytes: file.sizeBytes, | 4380 | sizeBytes: file.sizeBytes, |
| 4320 | attachmentId: upload.attachmentId, | 4381 | attachmentId: upload.attachmentId, |
| 4321 | environmentId, | 4382 | environmentId, |
| 4383 | ...(file.source ? { source: file.source } : {}), | ||
| 4322 | }); | 4384 | }); |
| 4323 | } | 4385 | } |
| 4324 | // A repeat ⌘S on the *same* still-unencoded snapshot would stash it | 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,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5032 | // ------------------------------------------------------------------ | 5094 | // ------------------------------------------------------------------ |
| 5033 | // Callbacks: attachments | 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 | /** Resolves true when at least one chip was inserted for the accepted attachments. */ | 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 | if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; | 5127 | if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; |
| 5038 | if ( | 5128 | if ( |
| 5039 | pendingUserInputs.length > 0 && | 5129 | pendingUserInputs.length > 0 && |
| ... | @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5056 | // accepted files reserve their attachment slots (via the pending counter) | 5146 | // accepted files reserve their attachment slots (via the pending counter) |
| 5057 | // before the first await, keeping the total under the limit. | 5147 | // before the first await, keeping the total under the limit. |
| 5058 | const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; | 5148 | const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; |
| 5059 | const otherQuestionAttachments = | 5149 | let reservedCount = countReservedAttachments(); |
| 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; | ||
| 5083 | // A pick that matches a needs-reattach marker replaces it in the draft, so | 5150 | // A pick that matches a needs-reattach marker replaces it in the draft, so |
| 5084 | // it must not consume a slot; a draft full of markers would otherwise hit | 5151 | // it must not consume a slot; a draft full of markers would otherwise hit |
| 5085 | // the capacity error before the replacement path could run. | 5152 | // the capacity error before the replacement path could run. |
| ... | @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5146 | mimeType: fileMimeType, | 5213 | mimeType: fileMimeType, |
| 5147 | sizeBytes: attachmentFile.size, | 5214 | sizeBytes: attachmentFile.size, |
| 5148 | file: attachmentFile, | 5215 | file: attachmentFile, |
| 5216 | ...(options?.source ? { source: options.source } : {}), | ||
| 5149 | }); | 5217 | }); |
| 5150 | } | 5218 | } |
| 5151 | if (!matchingReattachMarker) { | 5219 | if (!matchingReattachMarker) { |
| ... | @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5160 | const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); | 5228 | const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); |
| 5161 | const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); | 5229 | const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); |
| 5162 | if (storedFiles.length > 0) { | 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 | if (acceptedImages.length === 0) return insertedAny; | 5248 | if (acceptedImages.length === 0) return insertedAny; |
| ... | @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5246 | */ | 5328 | */ |
| 5247 | const insertAttachmentReferences = ( | 5329 | const insertAttachmentReferences = ( |
| 5248 | references: ReadonlyArray<ComposerContextReference>, | 5330 | references: ReadonlyArray<ComposerContextReference>, |
| 5331 | selection?: { start: number; end: number }, | ||
| 5249 | ): boolean => { | 5332 | ): boolean => { |
| 5250 | if (references.length === 0) return false; | 5333 | if (references.length === 0) return false; |
| 5251 | // Question answers carry attachments beside the answer, never as chips. Falling back to | 5334 | // Question answers carry attachments beside the answer, never as chips. Falling back to |
| 5252 | // the thread prompt here would hide the file behind a reference the question never shows. | 5335 | // the thread prompt here would hide the file behind a reference the question never shows. |
| 5253 | if (questionAttachmentTarget) return false; | 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 | const text = references.map(formatInlineContextReference).join(" "); | 5341 | const text = references.map(formatInlineContextReference).join(" "); |
| 5255 | const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); | 5342 | const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); |
| 5256 | if (!inserted) { | 5343 | if (!inserted) { |
| ... | @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5284 | // ------------------------------------------------------------------ | 5371 | // ------------------------------------------------------------------ |
| 5285 | // Callbacks: paste / drag | 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 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | 5450 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 5288 | const files = Array.from(event.clipboardData.files); | 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 | // Claimable pastes go through even when agent questions are pending or the | 5455 | // Claimable pastes go through even when agent questions are pending or the |
| 5290 | // composer is at its attachment limit: `addComposerAttachments` surfaces | 5456 | // composer is at its attachment limit: `addComposerAttachments` surfaces |
| 5291 | // those as a toast and a thread error. An early return here would swallow | 5457 | // those as a toast and a thread error. An early return here would swallow |
| 5292 | // the paste with no feedback. | 5458 | // the paste with no feedback. |
| 5293 | if ( | 5459 | if ( |
| 5294 | files.length === 0 || | 5460 | files.length > 0 && |
| 5295 | !activeThreadId || | 5461 | activeThreadId && |
| 5296 | !shouldHandleComposerAttachmentPaste({ | 5462 | shouldHandleComposerAttachmentPaste({ files, plainText }) |
| 5297 | files, | ||
| 5298 | plainText: event.clipboardData.getData("text/plain"), | ||
| 5299 | }) | ||
| 5300 | ) { | 5463 | ) { |
| 5464 | event.preventDefault(); | ||
| 5465 | event.stopPropagation(); | ||
| 5466 | void addComposerAttachments(files); | ||
| 5301 | return; | 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 | event.preventDefault(); | 5476 | event.preventDefault(); |
| 5304 | void addComposerAttachments(files); | 5477 | event.stopPropagation(); |
| 5305 | }; | 5478 | }; |
| 5306 | 5479 | ||
| 5307 | const insertComposerText = useCallback( | 5480 | const insertComposerText = useCallback( |
| ... | @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5526 | hasPendingAttachments: () => | 5699 | hasPendingAttachments: () => |
| 5527 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, | 5700 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, |
| 5528 | insertTextAtEnd: insertComposerTextAtEnd, | 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 | citeAssistantText: (citation, sourceAnchor) => | 5719 | citeAssistantText: (citation, sourceAnchor) => |
| 5530 | insertComposerText( | 5720 | insertComposerText( |
| 5531 | formatAssistantCitationForComposer(citation, citation.comment), | 5721 | formatAssistantCitationForComposer(citation, citation.comment), |
| ... | @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5626 | [ | 5816 | [ |
| 5627 | activeThread, | 5817 | activeThread, |
| 5628 | addComposerAttachments, | 5818 | addComposerAttachments, |
| 5819 | foldPastedText, | ||
| 5629 | composerDraftTarget, | 5820 | composerDraftTarget, |
| 5630 | composerCursor, | 5821 | composerCursor, |
| 5631 | composerTerminalContexts, | 5822 | composerTerminalContexts, |
apps/web/src/components/composerInlineTokenPaste.ts+28-25| ... | @@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste( | ... | @@ -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. */ | 147 | /** Clipboard records referenced by the copied text, including dependent screenshots. */ |
| 148 | export function importPastedComposerText( | 148 | export function readPastedComposerContext( |
| 149 | clipboardData: Pick<DataTransfer, "getData">, | 149 | clipboardData: Pick<DataTransfer, "getData">, |
| 150 | importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], | 150 | ): ComposerContextClipboardFragment | null { |
| 151 | ): string { | ||
| 152 | const pastedText = clipboardData.getData("text/plain"); | 151 | const pastedText = clipboardData.getData("text/plain"); |
| 153 | // Only records whose links are in the pasted text get imported; a fragment may carry | 152 | // Only records whose links are in the pasted text get imported; a fragment may carry |
| 154 | // more (it was built for a larger copy) and must not start transfers for those. | 153 | // more (it was built for a larger copy) and must not start transfers for those. |
| 155 | const decodedFragment = importContextFragment | 154 | const decodedFragment = |
| 156 | ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? | 155 | decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? |
| 157 | decodeComposerContextClipboardHtml(clipboardData.getData("text/html"))) | 156 | decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); |
| 158 | : null; | 157 | if (decodedFragment === null) return null; |
| 159 | const pastedIds = new Set<string>( | 158 | const pastedIds = new Set<string>( |
| 160 | collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), | 159 | collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), |
| 161 | ); | 160 | ); |
| 162 | if (decodedFragment) { | 161 | for (const record of decodedFragment.records) { |
| 163 | for (const record of decodedFragment.records) { | 162 | if ( |
| 164 | if ( | 163 | record.kind === "preview-annotation" && |
| 165 | record.kind === "preview-annotation" && | 164 | !("payload" in record) && |
| 166 | !("payload" in record) && | 165 | pastedIds.has(record.contextId) && |
| 167 | pastedIds.has(record.contextId) && | 166 | record.screenshotContextId |
| 168 | record.screenshotContextId | 167 | ) { |
| 169 | ) { | 168 | pastedIds.add(record.screenshotContextId); |
| 170 | pastedIds.add(record.screenshotContextId); | ||
| 171 | } | ||
| 172 | } | 169 | } |
| 173 | } | 170 | } |
| 174 | const fragment = | 171 | return { |
| 175 | decodedFragment === null | 172 | ...decodedFragment, |
| 176 | ? null | 173 | records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), |
| 177 | : { | 174 | }; |
| 178 | ...decodedFragment, | 175 | } |
| 179 | records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), | 176 | |
| 180 | }; | 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 | const rewrittenIds = | 184 | const rewrittenIds = |
| 182 | fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; | 185 | fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; |
| 183 | const text = | 186 | const text = |
apps/web/src/components/contextChipParts.tsx+23-22| ... | @@ -119,29 +119,30 @@ export function PullRequestChip(props: { | ... | @@ -119,29 +119,30 @@ export function PullRequestChip(props: { |
| 119 | onOpen: (event: MouseEvent<HTMLElement>, url: string) => void; | 119 | onOpen: (event: MouseEvent<HTMLElement>, url: string) => void; |
| 120 | }) { | 120 | }) { |
| 121 | return ( | 121 | return ( |
| 122 | <ContextChipPopover | 122 | <Tooltip> |
| 123 | accessibleLabel={`${props.kindLabel} ${props.label}: ${props.metadata.title}`} | 123 | <TooltipTrigger |
| 124 | {...(props.copyMarkdown ? { copyMarkdown: props.copyMarkdown } : {})} | 124 | render={ |
| 125 | triggerClassName={props.className} | 125 | <Button |
| 126 | chip={ | 126 | variant="chip" |
| 127 | <> | 127 | className={cn( |
| 128 | <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} /> | 128 | props.className, |
| 129 | <span className={props.labelClassName}>{props.label}</span> | 129 | CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME, |
| 130 | </> | 130 | CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME, |
| 131 | } | 131 | "cursor-pointer", |
| 132 | > | 132 | )} |
| 133 | <div className="space-y-3 p-2"> | 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 | <PullRequestContextDetails metadata={props.metadata} /> | 143 | <PullRequestContextDetails metadata={props.metadata} /> |
| 135 | <p className="text-xs text-muted-foreground">Captured pull request context</p> | 144 | </TooltipPopup> |
| 136 | <Button | 145 | </Tooltip> |
| 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> | ||
| 145 | ); | 146 | ); |
| 146 | } | 147 | } |
| 147 | 148 |
apps/web/src/components/files/AttachmentFilePreview.tsx+21| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | Eye, | 12 | Eye, |
| 13 | Table2, | 13 | Table2, |
| 14 | Trash2Icon, | 14 | Trash2Icon, |
| 15 | WrapTextIcon, | ||
| 15 | XIcon, | 16 | XIcon, |
| 16 | } from "lucide-react"; | 17 | } from "lucide-react"; |
| 17 | import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; | 18 | import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; |
| ... | @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; | ... | @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; |
| 21 | import { ScrollArea } from "~/components/ui/scroll-area"; | 22 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 22 | import { toastManager } from "~/components/ui/toast"; | 23 | import { toastManager } from "~/components/ui/toast"; |
| 23 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 24 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 25 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | ||
| 24 | import { cn } from "~/lib/utils"; | 26 | import { cn } from "~/lib/utils"; |
| 25 | 27 | ||
| 26 | import { AudioPreview } from "./AudioPreview"; | 28 | import { AudioPreview } from "./AudioPreview"; |
| ... | @@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: { | ... | @@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: { |
| 180 | return () => controller.abort(); | 182 | return () => controller.abort(); |
| 181 | }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); | 183 | }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); |
| 182 | const failure = error ?? (needsText ? contentError : null); | 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 | const save = () => { | 196 | const save = () => { |
| 185 | setSaving(true); | 197 | setSaving(true); |
| ... | @@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: { | ... | @@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: { |
| 302 | )} | 314 | )} |
| 303 | </FileSurfaceAction> | 315 | </FileSurfaceAction> |
| 304 | ) : null} | 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 | {content ? ( | 326 | {content ? ( |
| 306 | <FileSurfaceAction | 327 | <FileSurfaceAction |
| 307 | label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"} | 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,7 +21,7 @@ import { |
| 21 | squashAtomCommandFailure, | 21 | squashAtomCommandFailure, |
| 22 | } from "@t3tools/client-runtime/state/runtime"; | 22 | } from "@t3tools/client-runtime/state/runtime"; |
| 23 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; | 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 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 27 | 27 | ||
| ... | @@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; | ... | @@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 31 | import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; | 31 | import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; |
| 32 | import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; | 32 | import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; |
| 33 | import { useRemoteOpenState } from "~/remoteOpen"; | 33 | import { useRemoteOpenState } from "~/remoteOpen"; |
| 34 | import { useClientSettings } from "~/hooks/useSettings"; | 34 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; |
| 35 | import { useTheme } from "~/hooks/useTheme"; | 35 | import { useTheme } from "~/hooks/useTheme"; |
| 36 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; | 36 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| 37 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | 37 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| ... | @@ -995,6 +995,15 @@ export default function FilePreviewPanel({ | ... | @@ -995,6 +995,15 @@ export default function FilePreviewPanel({ |
| 995 | ? ("html" as const) | 995 | ? ("html" as const) |
| 996 | : null; | 996 | : null; |
| 997 | const canToggleRendered = attachment === undefined && renderedMode !== null; | 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 | const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile; | 1007 | const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile; |
| 999 | const setRenderedPreferred = isMarkdown | 1008 | const setRenderedPreferred = isMarkdown |
| 1000 | ? setRenderMarkdownPreferred | 1009 | ? setRenderMarkdownPreferred |
| ... | @@ -1122,6 +1131,15 @@ export default function FilePreviewPanel({ | ... | @@ -1122,6 +1131,15 @@ export default function FilePreviewPanel({ |
| 1122 | )} | 1131 | )} |
| 1123 | </FileSurfaceAction> | 1132 | </FileSurfaceAction> |
| 1124 | ) : null} | 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 | {canOpenInBrowser ? ( | 1143 | {canOpenInBrowser ? ( |
| 1126 | <FileSurfaceAction label="Open file in preview browser" onPress={handleOpenInBrowser}> | 1144 | <FileSurfaceAction label="Open file in preview browser" onPress={handleOpenInBrowser}> |
| 1127 | <Globe2 className="size-3.5" /> | 1145 | <Globe2 className="size-3.5" /> |
apps/web/src/composerDraftStore.test.ts+5-1| ... | @@ -436,7 +436,9 @@ describe("composerDraftStore file attachments", () => { | ... | @@ -436,7 +436,9 @@ describe("composerDraftStore file attachments", () => { |
| 436 | 436 | ||
| 437 | it("persists uploaded file references without including file contents", () => { | 437 | it("persists uploaded file references without including file contents", () => { |
| 438 | const store = useComposerDraftStore.getState(); | 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 | store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); | 442 | store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); |
| 441 | 443 | ||
| 442 | const persistApi = useComposerDraftStore.persist as unknown as { | 444 | const persistApi = useComposerDraftStore.persist as unknown as { |
| ... | @@ -459,6 +461,7 @@ describe("composerDraftStore file attachments", () => { | ... | @@ -459,6 +461,7 @@ describe("composerDraftStore file attachments", () => { |
| 459 | sizeBytes: 6, | 461 | sizeBytes: 6, |
| 460 | attachmentId: "pending-report-pdf", | 462 | attachmentId: "pending-report-pdf", |
| 461 | environmentId: TEST_ENVIRONMENT_ID, | 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,6 +477,7 @@ describe("composerDraftStore file attachments", () => { |
| 474 | file: null, | 477 | file: null, |
| 475 | uploadedAttachmentId: "pending-report-pdf", | 478 | uploadedAttachmentId: "pending-report-pdf", |
| 476 | uploadEnvironmentId: TEST_ENVIRONMENT_ID, | 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,6 +13,7 @@ import { |
| 13 | ProviderOptionSelection, | 13 | ProviderOptionSelection, |
| 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 15 | PreviewAnnotationPayloadSchema, | 15 | PreviewAnnotationPayloadSchema, |
| 16 | PastedTextAttachmentSource, | ||
| 16 | type PreviewAnnotationPayload, | 17 | type PreviewAnnotationPayload, |
| 17 | RuntimeMode, | 18 | RuntimeMode, |
| 18 | type ServerProvider, | 19 | type ServerProvider, |
| ... | @@ -191,6 +192,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({ | ... | @@ -191,6 +192,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({ |
| 191 | sizeBytes: Schema.Number, | 192 | sizeBytes: Schema.Number, |
| 192 | attachmentId: Schema.String, | 193 | attachmentId: Schema.String, |
| 193 | environmentId: EnvironmentId, | 194 | environmentId: EnvironmentId, |
| 195 | source: Schema.optional(PastedTextAttachmentSource), | ||
| 194 | }); | 196 | }); |
| 195 | export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; | 197 | export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; |
| 196 | 198 | ||
| ... | @@ -207,6 +209,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({ | ... | @@ -207,6 +209,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({ |
| 207 | sizeBytes: Schema.Number, | 209 | sizeBytes: Schema.Number, |
| 208 | attachmentId: Schema.optionalKey(Schema.String), | 210 | attachmentId: Schema.optionalKey(Schema.String), |
| 209 | environmentId: Schema.optionalKey(EnvironmentId), | 211 | environmentId: Schema.optionalKey(EnvironmentId), |
| 212 | source: Schema.optional(PastedTextAttachmentSource), | ||
| 210 | }); | 213 | }); |
| 211 | export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; | 214 | export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; |
| 212 | const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); | 215 | const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); |
| ... | @@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState( | ... | @@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState( |
| 2144 | name: file.name, | 2147 | name: file.name, |
| 2145 | mimeType: file.mimeType, | 2148 | mimeType: file.mimeType, |
| 2146 | sizeBytes: file.sizeBytes, | 2149 | sizeBytes: file.sizeBytes, |
| 2150 | ...(file.source ? { source: file.source } : {}), | ||
| 2147 | ...(file.uploadedAttachmentId && file.uploadEnvironmentId | 2151 | ...(file.uploadedAttachmentId && file.uploadEnvironmentId |
| 2148 | ? { | 2152 | ? { |
| 2149 | attachmentId: file.uploadedAttachmentId, | 2153 | attachmentId: file.uploadedAttachmentId, |
| ... | @@ -2423,6 +2427,7 @@ function toHydratedThreadDraft( | ... | @@ -2423,6 +2427,7 @@ function toHydratedThreadDraft( |
| 2423 | mimeType: file.mimeType, | 2427 | mimeType: file.mimeType, |
| 2424 | sizeBytes: file.sizeBytes, | 2428 | sizeBytes: file.sizeBytes, |
| 2425 | file: null, | 2429 | file: null, |
| 2430 | ...(file.source ? { source: file.source } : {}), | ||
| 2426 | // A marker without an attachment id hydrates as needs-reattach: no | 2431 | // A marker without an attachment id hydrates as needs-reattach: no |
| 2427 | // bytes, no server-side upload, only the metadata to tell the user | 2432 | // bytes, no server-side upload, only the metadata to tell the user |
| 2428 | // what to attach again. | 2433 | // what to attach again. |
apps/web/src/lib/attachmentUploadQueue.test.ts+5-1| ... | @@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => { | ... | @@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => { |
| 238 | }); | 238 | }); |
| 239 | 239 | ||
| 240 | it("uploads generic files and sends file attachment references", async () => { | 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 | startAttachmentUpload({ environmentId: firstEnvironment, image: file }); | 245 | startAttachmentUpload({ environmentId: firstEnvironment, image: file }); |
| 243 | await Promise.resolve(); | 246 | await Promise.resolve(); |
| 244 | 247 | ||
| ... | @@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => { | ... | @@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => { |
| 268 | name: "report.pdf", | 271 | name: "report.pdf", |
| 269 | mimeType: "application/pdf", | 272 | mimeType: "application/pdf", |
| 270 | sizeBytes: 3, | 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,7 +563,7 @@ export function getUploadedAttachments(input: { |
| 563 | name: image.name, | 563 | name: image.name, |
| 564 | mimeType: image.mimeType, | 564 | mimeType: image.mimeType, |
| 565 | sizeBytes: image.sizeBytes, | 565 | sizeBytes: image.sizeBytes, |
| 566 | ...(image.type === "image" && image.source ? { source: image.source } : {}), | 566 | ...(image.source ? { source: image.source } : {}), |
| 567 | }); | 567 | }); |
| 568 | } | 568 | } |
| 569 | return attachments; | 569 | return attachments; |
apps/web/src/lib/composerContextReferences.test.ts+12| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ensureInlineContextReferences, | 6 | ensureInlineContextReferences, |
| 7 | formatInlineContextReference, | 7 | formatInlineContextReference, |
| 8 | insertInlineContextReference, | 8 | insertInlineContextReference, |
| 9 | inlineContextReferenceReplacement, | ||
| 9 | removeInlineContextReference, | 10 | removeInlineContextReference, |
| 10 | stripInlineContextReferences, | 11 | stripInlineContextReferences, |
| 11 | toComposerContextId, | 12 | toComposerContextId, |
| ... | @@ -18,6 +19,17 @@ const reviewLink = "[a.ts L4](t3-context://v1/review-comment/rc-1)"; | ... | @@ -18,6 +19,17 @@ const reviewLink = "[a.ts L4](t3-context://v1/review-comment/rc-1)"; |
| 18 | const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)"; | 19 | const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)"; |
| 19 | 20 | ||
| 20 | describe("composerContextReferences", () => { | 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 | it("formats, collects and strips references of any kind", () => { | 33 | it("formats, collects and strips references of any kind", () => { |
| 22 | expect(formatInlineContextReference(review)).toBe(reviewLink); | 34 | expect(formatInlineContextReference(review)).toBe(reviewLink); |
| 23 | const prompt = `x ${reviewLink} y ${previewLink} ${reviewLink}`; | 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,19 +93,34 @@ function isBoundaryWhitespace(char: string | undefined): boolean { |
| 93 | return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; | 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 | /** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */ | 112 | /** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */ |
| 97 | export function insertInlineContextReference( | 113 | export function insertInlineContextReference( |
| 98 | prompt: string, | 114 | prompt: string, |
| 99 | cursorInput: number, | 115 | cursorInput: number, |
| 100 | reference: ComposerContextReference, | 116 | reference: ComposerContextReference, |
| 101 | ): { prompt: string; cursor: number } { | 117 | ): { prompt: string; cursor: number } { |
| 102 | const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput))); | 118 | const edit = inlineContextReferenceReplacement(prompt, { start: cursorInput, end: cursorInput }, [ |
| 103 | const needsLeadingSpace = !isBoundaryWhitespace(prompt[cursor - 1]); | 119 | reference, |
| 104 | const replacement = `${needsLeadingSpace ? " " : ""}${formatInlineContextReference(reference)} `; | 120 | ]); |
| 105 | const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor; | ||
| 106 | return { | 121 | return { |
| 107 | prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`, | 122 | prompt: `${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`, |
| 108 | cursor: cursor + replacement.length, | 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,6 +3,7 @@ import { beforeEach, expect, it, vi } from "vite-plus/test"; |
| 3 | import { useComposerDraftStore } from "./composerDraftStore"; | 3 | import { useComposerDraftStore } from "./composerDraftStore"; |
| 4 | import { | 4 | import { |
| 5 | questionAttachmentDraftId, | 5 | questionAttachmentDraftId, |
| 6 | countQuestionAttachments, | ||
| 6 | questionAttachmentDraftPrefix, | 7 | questionAttachmentDraftPrefix, |
| 7 | changeQuestionAttachmentPreparation, | 8 | changeQuestionAttachmentPreparation, |
| 8 | clearQuestionAttachmentDraft, | 9 | clearQuestionAttachmentDraft, |
| ... | @@ -98,3 +99,43 @@ it("does not clear another environment whose id contains a question prefix", () | ... | @@ -98,3 +99,43 @@ it("does not clear another environment whose id contains a question prefix", () |
| 98 | expect(store.getComposerDraft(ownKey)).toBeNull(); | 99 | expect(store.getComposerDraft(ownKey)).toBeNull(); |
| 99 | expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer"); | 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,6 +25,16 @@ export const useQuestionAttachmentPreparation = create<{ counts: Record<string, |
| 25 | counts: {}, | 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 | export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void { | 38 | export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void { |
| 29 | useQuestionAttachmentPreparation.setState((state) => | 39 | useQuestionAttachmentPreparation.setState((state) => |
| 30 | delta < 0 && !(key in state.counts) | 40 | delta < 0 && !(key in state.counts) |
apps/web/src/routes/__root.tsx+2| ... | @@ -67,6 +67,7 @@ import { | ... | @@ -67,6 +67,7 @@ import { |
| 67 | } from "../components/KeybindingsUpdateToast.logic"; | 67 | } from "../components/KeybindingsUpdateToast.logic"; |
| 68 | 68 | ||
| 69 | import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; | 69 | import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; |
| 70 | import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; | ||
| 70 | import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; | 71 | import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; |
| 71 | 72 | ||
| 72 | export const Route = createRootRoute({ | 73 | export const Route = createRootRoute({ |
| ... | @@ -108,6 +109,7 @@ export const Route = createRootRoute({ | ... | @@ -108,6 +109,7 @@ export const Route = createRootRoute({ |
| 108 | }); | 109 | }); |
| 109 | 110 | ||
| 110 | function RootRouteView() { | 111 | function RootRouteView() { |
| 112 | useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); | ||
| 111 | const pathname = useLocation({ select: (location) => location.pathname }); | 113 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 112 | const { authGateState } = Route.useRouteContext(); | 114 | const { authGateState } = Route.useRouteContext(); |
| 113 | const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; | 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 +6,12 @@ include a skill when the task needs more context. |
| 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer | 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer |
| 7 | so you can shorten them or split them into several messages. | 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 | ## Attach files | 15 | ## Attach files |
| 10 | 16 | ||
| 11 | Attach up to eight files per message. Images can be up to 10 MB; other files can | 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,6 +103,10 @@ |
| 103 | "types": "./src/state/attachments.ts", | 103 | "types": "./src/state/attachments.ts", |
| 104 | "default": "./src/state/attachments.ts" | 104 | "default": "./src/state/attachments.ts" |
| 105 | }, | 105 | }, |
| 106 | "./text-paste": { | ||
| 107 | "types": "./src/textPaste.ts", | ||
| 108 | "default": "./src/textPaste.ts" | ||
| 109 | }, | ||
| 106 | "./state/connections": { | 110 | "./state/connections": { |
| 107 | "types": "./src/state/connections.ts", | 111 | "types": "./src/state/connections.ts", |
| 108 | "default": "./src/state/connections.ts" | 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,6 +1308,8 @@ export interface DesktopBridge { |
| 1308 | * builds lack it; callers fall back to VS Code only. | 1308 | * builds lack it; callers fall back to VS Code only. |
| 1309 | */ | 1309 | */ |
| 1310 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; | 1310 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1311 | /** Present when the desktop shell can perform an ordered plain-text paste. */ | ||
| 1312 | pasteAsText?: () => Promise<void>; | ||
| 1311 | onMenuAction: (listener: (action: string) => void) => () => void; | 1313 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1312 | onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; | 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,6 +310,9 @@ export const ChatImageAttachment = Schema.Struct({ |
| 310 | }); | 310 | }); |
| 311 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | 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 | export const ChatFileAttachment = Schema.Struct({ | 316 | export const ChatFileAttachment = Schema.Struct({ |
| 314 | type: Schema.Literal("file"), | 317 | type: Schema.Literal("file"), |
| 315 | id: ChatAttachmentId, | 318 | id: ChatAttachmentId, |
| ... | @@ -319,6 +322,10 @@ export const ChatFileAttachment = Schema.Struct({ | ... | @@ -319,6 +322,10 @@ export const ChatFileAttachment = Schema.Struct({ |
| 319 | Schema.isGreaterThanOrEqualTo(1), | 322 | Schema.isGreaterThanOrEqualTo(1), |
| 320 | Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), | 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 | export type ChatFileAttachment = typeof ChatFileAttachment.Type; | 330 | export type ChatFileAttachment = typeof ChatFileAttachment.Type; |
| 324 | 331 |
packages/shared/src/composerContextLegacy.test.ts+16| ... | @@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => { | ... | @@ -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 | it("handles the combined legacy send order: terminal, element, preview, review", () => { | 601 | it("handles the combined legacy send order: terminal, element, preview, review", () => { |
| 586 | const text = [ | 602 | const text = [ |
| 587 | "See ", | 603 | "See ", |
packages/shared/src/composerContextLegacy.ts+13-6| ... | @@ -339,15 +339,22 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext | ... | @@ -339,15 +339,22 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext |
| 339 | 339 | ||
| 340 | // Blocks were appended in send order (terminal, element, preview, review), so they peel | 340 | // Blocks were appended in send order (terminal, element, preview, review), so they peel |
| 341 | // off the end in reverse. Each peel exposes the next block as trailing. | 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 | 342 | // Peel reviews only when they hide another trailing context block. A review at the end |
| 343 | // review that sat before other blocks keeps its place. | 343 | // of ordinary prose can still be inline; keep its original spacing and line breaks. |
| 344 | const trailingReviewTokens: number[] = []; | 344 | const trailingReviewTokens: number[] = []; |
| 345 | const tokens = trailingReviewTokenPattern.exec(rest); | 345 | const tokens = trailingReviewTokenPattern.exec(rest); |
| 346 | if (tokens && tokens[0].length > 0) { | 346 | if (tokens && tokens[0].length > 0) { |
| 347 | trailingReviewTokens.push( | 347 | const preceding = rest.slice(0, tokens.index); |
| 348 | ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])), | 348 | if ( |
| 349 | ); | 349 | TRAILING_PREVIEW.test(preceding) || |
| 350 | rest = rest.slice(0, tokens.index).replace(/\n+$/, ""); | 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 | for (;;) { | 359 | for (;;) { |
| 353 | const preview = stripTrailing(rest, TRAILING_PREVIEW); | 360 | const preview = stripTrailing(rest, TRAILING_PREVIEW); |
packages/shared/src/composerContextLegacySend.test.ts+25| ... | @@ -99,6 +99,31 @@ describe("serializeLegacyContextMessage", () => { | ... | @@ -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 | it("retains picked-element details for preview annotations sent through an older server", () => { | 127 | it("retains picked-element details for preview annotations sent through an older server", () => { |
| 103 | const text = `Update ${formatComposerContextReference(annotation)}`; | 128 | const text = `Update ${formatComposerContextReference(annotation)}`; |
| 104 | const upgraded = upgradeLegacyContextMessage( | 129 | const upgraded = upgradeLegacyContextMessage( |