authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-09-13 01:28:28+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 17:28:28-07:00
log68c2277f500bbbb299396bcdcd0aec60dcb5db9d
treeeb01b378954f9176fac589107cdff9cf4ae1711c
parent36caf200c2b24c12838570966f5c85786a1fc1f8
signature Signed by PGP key B5690EEEBB952194

feat(composer): fold large pastes into text attachments (#11442)


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";
7export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";7export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
8export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";8export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
9export const MENU_ACTION_CHANNEL = "desktop:menu-action";9export const MENU_ACTION_CHANNEL = "desktop:menu-action";
10export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
10export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";11export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
11export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";12export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
12export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";13export 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";
66
7import type * as Electron from "electron";7import type * as Electron from "electron";
88
9const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({
10 focusedWebContents: vi.fn(),
11 ownerWindow: vi.fn(),
12}));
13vi.mock("electron", () => ({
14 webContents: { getFocusedWebContents: focusedWebContents },
15 BrowserWindow: { fromWebContents: ownerWindow },
16}));
17
9import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";18import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
10import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";19import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
11import * as ElectronDialog from "../../electron/ElectronDialog.ts";20import * 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";
13import {22import {
14 getLocalEnvironmentBootstraps,23 getLocalEnvironmentBootstraps,
15 getWindowFullscreenState,24 getWindowFullscreenState,
25 pasteAsText,
16 pickProjectFavicon,26 pickProjectFavicon,
17} from "./window.ts";27} from "./window.ts";
1828
...@@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => {...@@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => {
153 });163 });
154});164});
155165
166describe("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
156describe("pickProjectFavicon", () => {210describe("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});
347347
348export 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 the374/** 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. */
350const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;376const 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 );
155155
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 Electron186 // Zoom must route through DesktopWindow.zoomMain instead of the Electron
157 // zoom roles: the roles zoom whichever webContents has focus, which breaks187 // 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});
5153
52const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (54const 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.compileSdkVersion9 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
11 defaultConfig {15 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion16 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion17 targetSdkVersion rootProject.ext.targetSdkVersion
...@@ -17,4 +21,12 @@ android {...@@ -17,4 +21,12 @@ android {
17dependencies {21dependencies {
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
28tasks.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 }
119125
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 )
130137
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 @@
1package expo.modules.t3composereditor1package expo.modules.t3composereditor
22
3import android.content.Context3import android.content.ClipData
4import android.content.ClipboardManager4import android.content.ClipboardManager
5import android.content.Context
5import android.graphics.Color6import android.graphics.Color
6import android.graphics.Canvas7import android.graphics.Canvas
7import android.graphics.Paint8import 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 = false54 private var applyingNativeValue = false
53 private var desiredLineHeightPx = 055 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 false132 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 }
307329
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? InputMethodManager340 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 = defaultHighlightColor395 editor.highlightColor = defaultHighlightColor
366 }396 }
367397
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 )
373406
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}
546579
547private class SelectionAwareEditText(context: Context) : EditText(context) {580internal class SelectionAwareEditText(context: Context) : EditText(context) {
548 var readOnly = false581 var readOnly = false
549 var selectionListener: ((Int, Int) -> Unit)? = null582 var selectionListener: ((Int, Int) -> Unit)? = null
550 var pasteImagesListener: ((List<String>) -> Unit)? = null583 var pasteImagesListener: ((List<String>) -> Unit)? = null
551 var pasteContextListener: ((Map<String, String>) -> Unit)? = null584 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 = ""
553589
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 -> false658 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 true676 return true
638 }677 }
678 return pasteImagesOrInterceptedText()
679 }
680
681 private fun pasteImagesOrInterceptedText(): Boolean {
639 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager682 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
640 val clip = clipboard?.primaryClip683 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 @@
1package expo.modules.t3composereditor
2
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.view.KeyEvent
7import org.junit.Assert.assertEquals
8import org.junit.Assert.assertFalse
9import org.junit.Assert.assertNull
10import org.junit.Assert.assertTrue
11import org.junit.Test
12import org.junit.runner.RunWith
13import org.robolectric.RobolectricTestRunner
14import org.robolectric.RuntimeEnvironment
15import org.robolectric.annotation.Config
16
17@RunWith(RobolectricTestRunner::class)
18@Config(sdk = [36], manifest = Config.NONE)
19class 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) in87 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 }
9096
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 )
102109
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 {
8686
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 = false93 var isReadOnly = false
94 var textPasteThresholdBytes = 0
95 var maxInputChars = Int.max
96 private var bypassTextPasteInterception = false
9397
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 = true106 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 commands118 return commands
105 }119 }
106120
...@@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView {...@@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView {
108 onSubmit?()122 onSubmit?()
109 }123 }
110124
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 false136 return false
...@@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView {...@@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView {
150 return173 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 }
155183
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 return200 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()
372415
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 in432 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] in453 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 : .no657 textView.spellCheckingType = spellCheck ? .yes : .no
597 }658 }
598659
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@end33@end
3434
35@implementation T3ContextCopyTextView35@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)sender45- (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 <ComposerImageAttachment232 <ComposerImageAttachment
231 {...props}233 {...props}
232 attachment={234 attachment={
233 attachment.type === "image"235 attachment.type === "image"
234 ? attachment236 ? 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
9import {9import {
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";
19import {21import { importComposerContextClipboard } from "../lib/composerContextClipboard";
20 importComposerContextClipboard,
21 type NativeContextClipboard,
22} from "../lib/composerContextClipboard";
23import { ComposerContextSheet } from "./ComposerContextSheet";22import { ComposerContextSheet } from "./ComposerContextSheet";
24import { AppText as Text } from "./AppText";23import { AppText as Text } from "./AppText";
25import {24import {
...@@ -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};
3845
39export function ComposerEditor({46export 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 ...props53 ...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}
232export type { ComposerEditorHandle, ComposerEditorSelection } from "../native/T3ComposerEditor";245export 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";
20import { useUniwindTheme } from "../../lib/useUniwindTheme";20import { useUniwindTheme } from "../../lib/useUniwindTheme";
21import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts";21import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts";
22import { FileMarkdownPreview } from "./FileMarkdownPreview";22import { FileMarkdownPreview } from "./FileMarkdownPreview";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
23import { SourceFileSurface } from "./SourceFileSurface";24import { SourceFileSurface } from "./SourceFileSurface";
24import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";25import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
2526
...@@ -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 <FileMarkdownPreview142 <FileMarkdownPreview
142 cwd=""143 cwd=""
143 relativePath=""144 relativePath=""
...@@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: {...@@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: {
147 captured148 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: {
175176
176export function AttachmentFileScreen(props: AttachmentFileScreenProps) {177export 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]);
251253
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 content285 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.MenuAction395 <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";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
3import type { ComponentType } from "react";3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native";5import {
6 FlatList,
7 RefreshControl,
8 ScrollView,
9 Text as NativeText,
10 useWindowDimensions,
11 View,
12} from "react-native";
613
7import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
8import { LoadingStrip } from "../../components/LoadingStrip";15import { 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";
26import { prepareSourceFileDocument } from "./source-file-document";33import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive";
34
35import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
27import { sourceHighlightAtom } from "./sourceHighlightingState";36import { sourceHighlightAtom } from "./sourceHighlightingState";
2837
29interface SourceFileSurfaceProps {38interface 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";
131142
132 return { lines, rowsJson, status, targetIndex, theme, tokens };143 return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens };
133}144}
134145
135function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {146function 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}
148159
149function NativeSourceFileSurface(160function 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
176function 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}
213229
214function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {230function 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;
218243
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 );
242267
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 <FlatList318 <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 );
268343
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 list365 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) {
282383
283export function SourceFileSurface(props: SourceFileSurfaceProps) {384export 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 ...(mediaSource777 ...(mediaSource
767 ? mediaActions.actions778 ? 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 !== null808 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 ]);
837863
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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { prepareSourceFileDocument } from "./source-file-document";3import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
44
5describe("prepareSourceFileDocument", () => {5describe("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
18it("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 @@
1import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
1import { buildNativeSourceRows } from "./nativeSourceFileAdapter";2import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
23
3const MAX_CACHED_DOCUMENTS = 8;4const MAX_CACHED_DOCUMENTS = 8;
...@@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument...@@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument
5253
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.
59export 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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3import {
4 nextPastedTextFileName,
5 pastedTextDisposition,
6 replaceTextSelection,
7} from "@t3tools/client-runtime/text-paste";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import {9import {
4 CommonActions,10 CommonActions,
...@@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily";...@@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily";
2228
23import {29import {
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";
2835
29import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";36import {
37 ComposerEditor,
38 type ComposerEditorHandle,
39 type ComposerTextPaste,
40} from "../../components/ComposerEditor";
30import { composerContextImportsAtom } from "../../state/use-composer-drafts";41import { composerContextImportsAtom } from "../../state/use-composer-drafts";
31import {42import {
32 composerContextSendBlockReason,43 composerContextSendBlockReason,
...@@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";...@@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
41import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";52import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
42import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";53import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
43import { composerStripAttachments } from "../../lib/composerImages";54import { composerStripAttachments } from "../../lib/composerImages";
44import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
45import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";55import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
46import {56import {
47 composerAttachmentUploadBlockReason,57 composerAttachmentUploadBlockReason,
...@@ -73,13 +83,17 @@ import {...@@ -73,13 +83,17 @@ import {
73import { makeTurnCommandMetadata } from "../../lib/commandMetadata";83import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
74import {84import {
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";
80import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";92import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
81import {93import {
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 === true925 capabilities?.attachmentUploads === true
910 ? capabilities.fileAttachments?.maxUploadBytes926 ? 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 > 0933 ...(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 one962 // 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 );
970995
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 <ComposerActionButton1611 <ComposerActionButton
1487 accessibilityLabel={1612 accessibilityLabel={
1488 attachmentBlockReason ??1613 attachmentBlockReason ??
1489 (flow.submitting1614 (pendingPastedTextAttachmentCount > 0
1490 ? "Starting task"1615 ? "Attaching pasted text"
1491 : attachmentsUploading1616 : flow.submitting
1492 ? "Queue task, sends when uploads finish"1617 ? "Starting task"
1493 : environmentConnected1618 : 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 @@
1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
3import type {4import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
4 EnvironmentId,5import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste";
5 MessageId,6import {
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";
13import {18import {
14 collectProviderUsageLimits,19 collectProviderUsageLimits,
...@@ -46,7 +51,10 @@ import Animated, {...@@ -46,7 +51,10 @@ import Animated, {
46import { useUniwindTheme } from "../../lib/useUniwindTheme";51import { useUniwindTheme } from "../../lib/useUniwindTheme";
47import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";52import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
48import { scopedThreadKey } from "../../lib/scopedEntities";53import { scopedThreadKey } from "../../lib/scopedEntities";
49import { composerContextImportsAtom } from "../../state/use-composer-drafts";54import {
55 composerContextImportsAtom,
56 countComposerDraftAttachmentsAfterSelection,
57} from "../../state/use-composer-drafts";
50import type { ComposerDocumentAttachment } from "../../lib/composerContext";58import type { ComposerDocumentAttachment } from "../../lib/composerContext";
51import { useProject } from "../../state/entities";59import { useProject } from "../../state/entities";
52import { scopeProjectRef } from "@t3tools/client-runtime/environment";60import { 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";
75import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
76import {83import {
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 attachments304 // 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 the486 // 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 multiline748 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 @@
1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";2import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import {3import {
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";
44import { useEnvironmentQuery } from "../../state/query";44import { useEnvironmentQuery } from "../../state/query";
45import {45import {
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 can604 // 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 });
9292
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);
95102
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";
11import { loadLocalAttachmentPreview } from "./localAttachmentPreview";11import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
12import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";12import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
13import { useRefreshAssetUrl } from "../state/assets";13import { useRefreshAssetUrl } from "../state/assets";
14import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation";
1415
15const isLocalUri = (uri: string) => /^(file|content):/.test(uri);16const isLocalUri = (uri: string) => /^(file|content):/.test(uri);
1617
...@@ -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 @@
1import { expect, it } from "vite-plus/test";
2import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation";
3
4it.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 @@
1import type { FilePreviewKind } from "@t3tools/shared/filePreview";
2
3/** The available preview and selected body must agree, including source-only draft files. */
4export 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 });
361361
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 });
364371
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}
203207
204function attachmentUploadInput(attachment: DraftComposerAttachment) {208function attachmentUploadInput(attachment: DraftComposerAttachment) {
apps/mobile/src/lib/composer-image-schema.ts+2-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts";
33
4export const DraftComposerImageAttachmentSchema = Schema.Struct({4export 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 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";2import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
33
4const files = new Map<string, { base64: string; deleted: boolean }>();4const files = new Map<string, { base64: string; deleted: boolean; text?: string }>();
5
6const clipboard = vi.hoisted(() => ({
7 hasImageAsync: vi.fn(),
8 getImageAsync: vi.fn(),
9 hasStringAsync: vi.fn(),
10 getStringAsync: vi.fn(),
11}));
12
13vi.mock("expo-clipboard", () => clipboard);
514
6vi.mock("expo-file-system", () => ({15vi.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 };
920
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 }
1327
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}));
3473
35vi.mock("./uuid", () => ({74vi.mock("./uuid", () => ({
36 uuidv4: () => "attachment-id",75 uuidv4: () => "attachment-id",
37}));76}));
3877
39import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages";78import {
79 convertPastedImagesToAttachments,
80 createPastedTextComposerAttachment,
81 isOwnedPastedImageUri,
82 pasteComposerClipboard,
83} from "./composerImages";
84
85describe("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});
40130
41describe("native pasted image cleanup", () => {131describe("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});
95205
96describe("composerStripAttachments", () => {206describe("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 };
121231
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 });
128237
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 });
133252
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});
139267
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";
13import type { DocumentPickerResult } from "expo-document-picker";14import 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";
21import { videoMimeType } from "@t3tools/shared/video";22import { videoMimeType } from "@t3tools/shared/video";
22import { beginForegroundHandoff } from "./foreground-handoff";23import { beginForegroundHandoff } from "./foreground-handoff";
23import { uuidv4 } from "./uuid";24import { uuidv4 } from "./uuid";
25import { writeFileAtomically } from "./atomic-file";
2426
25export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> {27export 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}
4649
50export 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
47export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;79export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
4880
49/**81/**
50 * What the strip above the composer shows. Media previews there because a thumbnail is the82 * 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 only83 * 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's84 * 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 */
55export function composerStripAttachments(93export 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}
66100
...@@ -539,11 +573,19 @@ export async function pickComposerMedia(input: {...@@ -539,11 +573,19 @@ export async function pickComposerMedia(input: {
539 };573 };
540}574}
541575
542export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{576/** Clipboard images take priority over their alternate text representation. */
543 readonly images: ReadonlyArray<DraftComposerImageAttachment>;577export 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 }
612653
apps/mobile/src/native/T3ComposerEditor.ios.tsx+47-2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste";
2import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
1import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
2import { composerContextEditorTokens } from "../lib/composerContext";4import { composerContextEditorTokens } from "../lib/composerContext";
3import { requireNativeView } from "expo";5import { 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}>;
5456
57type NativePasteTextEvent = NativeSyntheticEvent<{
58 readonly value: string;
59 readonly eventCount: number;
60 readonly text: string;
61 readonly selection: ComposerEditorSelection;
62}>;
63
55interface NativeComposerEditorRef {64interface 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 @@
1import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste";
2import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
1import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
2import { composerContextEditorTokens } from "../lib/composerContext";4import { composerContextEditorTokens } from "../lib/composerContext";
3import { requireNativeView } from "expo";5import { 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}>;
5759
60type NativePasteTextEvent = NativeSyntheticEvent<{
61 readonly value: string;
62 readonly eventCount: number;
63 readonly text: string;
64 readonly selection: ComposerEditorSelection;
65}>;
66
58interface NativeComposerEditorRef {67interface 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};
99
10export type ComposerTextPaste = {
11 readonly value: string;
12 readonly eventCount: number;
13 readonly text: string;
14 readonly selection: ComposerEditorSelection;
15};
16
10export interface ComposerEditorHandle {17export 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
196describe("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}));
149149
150import type { DraftComposerAttachment } from "../lib/composerImages";150import type { DraftComposerAttachment } from "../lib/composerImages";
151import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
151import { appAtomRegistry } from "./atom-registry";152import { appAtomRegistry } from "./atom-registry";
152import { threadOutboxManager } from "./thread-outbox";153import { threadOutboxManager } from "./thread-outbox";
153import {154import {
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}
234239
235describe("mobile composer drafts", () => {240describe("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 });
305385
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 });
428532
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 );
464584
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 });
570892
...@@ -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]);
23152663
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";
24import { writeFileAtomically } from "../lib/atomic-file";24import { writeFileAtomically } from "../lib/atomic-file";
25import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext";25import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext";
26import {26import {
27 collectComposerContextReferences,
27 formatComposerContextReference,28 formatComposerContextReference,
28 sanitizeComposerContextLabel,29 sanitizeComposerContextLabel,
29 replaceComposerContextReferences,30 replaceComposerContextReferences,
30} from "@t3tools/shared/composerContextReferences";31} from "@t3tools/shared/composerContextReferences";
31import { imageMimeType } from "@t3tools/shared/image";32import { imageMimeType } from "@t3tools/shared/image";
33import { videoMimeType } from "@t3tools/shared/video";
32import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";34import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
33import {35import {
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}
9799
100export 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. */
107export 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
119function 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
133function 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
143export function countComposerDraftAttachmentsAfterSelection(
144 draftKey: string,
145 target: ComposerDraftInsertion,
146): number {
147 return getComposerDraftAfterSelection(draftKey, target).attachments.length;
148}
149
150export function getComposerDraftAfterSelection(
151 draftKey: string,
152 target: ComposerDraftInsertion,
153): ComposerDraft {
154 return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target);
155}
156
157function 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. */
99export function createComposerDraftContextHistory() {184export function createComposerDraftContextHistory() {
100 const restoreContext = createComposerContextHistory();185 const restoreContext = createComposerContextHistory();
...@@ -162,16 +247,36 @@ export function setComposerDraftContext(...@@ -162,16 +247,36 @@ export function setComposerDraftContext(
162247
163export function insertComposerDraftContext(248export 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}
177282
...@@ -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}
205306
206export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()(307export 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}
354455
456function 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. */
477function 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
355function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {518function 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}
11131262
1263export 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
1114export function appendComposerDraftText(draftKey: string, value: string): void {1278export 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?.allowOverflow1318 const remaining = options?.allowOverflow
1150 ? attachments.length1319 ? 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?.appendReference1327 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}
12021364
apps/mobile/src/state/use-thread-composer-state.ts+161-9
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3import { Alert } from "react-native";4import { Alert } from "react-native";
45
5import {6import {
...@@ -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";
16import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";18import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
19import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
20import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste";
17import {21import {
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";
29import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";33import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
30import {34import {
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";
36import type { DraftComposerImageAttachment } from "../lib/composerImages";42import type { DraftComposerImageAttachment } from "../lib/composerImages";
37import { scopedThreadKey } from "../lib/scopedEntities";43import { scopedThreadKey } from "../lib/scopedEntities";
...@@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry";...@@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry";
42import { pendingThreadCreationMessage } from "./pending-thread-creation";48import { pendingThreadCreationMessage } from "./pending-thread-creation";
43import {49import {
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 );
135160
136 useEffect(() => {161 useEffect(() => {
137 ensureComposerDraftsLoaded();162 ensureComposerDraftsLoaded();
...@@ -479,9 +504,10 @@ export function useThreadComposerState() {...@@ -479,9 +504,10 @@ export function useThreadComposerState() {
479 }504 }
480505
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 === true512 capabilities?.attachmentUploads === true
487 ? capabilities.fileAttachments?.maxUploadBytes513 ? 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 }
515542
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 one554 // 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 }
542571
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 ]);
561662
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 }
567668
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 );
588690
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]);
22302230
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) => {
46834702
4684const validation = makeProviderServiceLayer();4703const validation = makeProviderServiceLayer();
4685validation.layer("ProviderServiceLive validation", (it) => {4704validation.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* (
15931593
1594 // Every attachment gets an on-disk path in the prompt so the model's tools1594 // 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: OpenCode1596 // and each adapter decides what its provider ingests natively. Folded
1597 // sends generic files as file parts, the others send images only and rely1597 // clipboard text remains path-only everywhere: eagerly embedding it would
1598 // on the path line for everything else. Unresolvable ids are skipped here1598 // 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 = inputTextWithAttachmentContext1604 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 === null1623 attachmentPath === null
1617 ? undefined1624 ? 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 );
281281
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;
265265
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 = image315 const limit = image
301 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES316 ? 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> = [];
463463
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";
47import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";47import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
48import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";48import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
49import { readPastedComposerContext } from "./composerInlineTokenPaste";
50import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
49import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";51import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
50import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";52import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
51import {53import {
...@@ -95,6 +97,7 @@ import { flushSync } from "react-dom";...@@ -95,6 +97,7 @@ import { flushSync } from "react-dom";
95import { useLocation, useNavigate } from "@tanstack/react-router";97import { useLocation, useNavigate } from "@tanstack/react-router";
96import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";98import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
97import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";99import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
100import { isMacPlatform } from "../lib/utils";
98import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";101import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
99import { useShallow } from "zustand/react/shallow";102import { useShallow } from "zustand/react/shallow";
100import {103import {
...@@ -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]);
65416559
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 {
1818
19import {19import {
20 importPastedComposerText,20 importPastedComposerText,
21 readPastedComposerContext,
21 registerComposerInlineTokenPaste,22 registerComposerInlineTokenPaste,
22} from "./composerInlineTokenPaste";23} from "./composerInlineTokenPaste";
23import {24import {
...@@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => {...@@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => {
498});499});
499500
500describe("context reference paste", () => {501describe("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 "![shot](t3-context://v1/image/img-old) 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}![shot](t3-context://v1/image/img-old) 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 "![shot](t3-context://v1/image/img-new) 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}![shot](t3-context://v1/image/img-new) 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 });
587599
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 = editor1864 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 @@
1import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
1import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";2import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
2import { useRightPanelStore } from "~/rightPanelStore";3import { useRightPanelStore } from "~/rightPanelStore";
3import { AttachmentFilePreview } from "../files/AttachmentFilePreview";4import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
4import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";5import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
5import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";6import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
6import { importPastedComposerText } from "../composerInlineTokenPaste";7import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
7import { elementContextToPreviewAnnotation } from "../../lib/elementContext";8import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
8import { RefreshIcon } from "~/components/ui/refresh-icon";9import { RefreshIcon } from "~/components/ui/refresh-icon";
9import {10import {
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";
38import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";41import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
42import {
43 isPasteAsTextShortcut,
44 nextPastedTextFileName,
45 pastedTextDisposition,
46 wouldTextPasteExceedLimit,
47} from "@t3tools/client-runtime/text-paste";
39import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";48import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
40import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";49import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
41import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";50import { 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";
199import {209import {
...@@ -284,7 +294,7 @@ import {...@@ -284,7 +294,7 @@ import {
284} from "../../lib/snapShotAnimation";294} from "../../lib/snapShotAnimation";
285import { resizeSnapShotSource } from "../../lib/snapShotSource";295import { resizeSnapShotSource } from "../../lib/snapShotSource";
286import { basenameOfPath } from "../../pierre-icons";296import { basenameOfPath } from "../../pierre-icons";
287import { cn, randomUUID } from "~/lib/utils";297import { cn, isMacPlatform, randomUUID } from "~/lib/utils";
288import {298import {
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 // Refs2076 // 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;
21012118
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 state2163 // 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 a4105 // 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 ...(expired4107 ...(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 it4386 // 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: attachments5095 // 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, so5150 // 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 hit5151 // 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 to5334 // 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 / drag5372 // 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 the5455 // Claimable pastes go through even when agent questions are pending or the
5290 // composer is at its attachment limit: `addComposerAttachments` surfaces5456 // composer is at its attachment limit: `addComposerAttachments` surfaces
5291 // those as a toast and a thread error. An early return here would swallow5457 // 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 };
53065479
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}
146146
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. */
148export function importPastedComposerText(148export 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 carry152 // 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 = importContextFragment154 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.screenshotContextId167 ) {
169 ) {168 pastedIds.add(record.screenshotContextId);
170 pastedIds.add(record.screenshotContextId);
171 }
172 }169 }
173 }170 }
174 const fragment =171 return {
175 decodedFragment === null172 ...decodedFragment,
176 ? null173 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. */
178export 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 <ContextChipPopover122 <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 <Button145 </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}
147148
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";
17import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";18import { 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";
21import { ScrollArea } from "~/components/ui/scroll-area";22import { ScrollArea } from "~/components/ui/scroll-area";
22import { toastManager } from "~/components/ui/toast";23import { toastManager } from "~/components/ui/toast";
23import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";24import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
25import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
24import { cn } from "~/lib/utils";26import { cn } from "~/lib/utils";
2527
26import { AudioPreview } from "./AudioPreview";28import { 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);
183195
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 <FileSurfaceAction327 <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";
23import { mediaFileReference } from "@t3tools/client-runtime/media-reference";23import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
24import { Code2, Eye, FolderTree, Globe2, Table2 } from "lucide-react";24import { Code2, Eye, FolderTree, Globe2, Table2, WrapTextIcon } from "lucide-react";
25import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
26import { useCallback, useEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2727
...@@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";...@@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";
31import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";31import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
32import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";32import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
33import { useRemoteOpenState } from "~/remoteOpen";33import { useRemoteOpenState } from "~/remoteOpen";
34import { useClientSettings } from "~/hooks/useSettings";34import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
35import { useTheme } from "~/hooks/useTheme";35import { useTheme } from "~/hooks/useTheme";
36import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";36import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
37import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";37import { 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 = isMarkdown1008 const setRenderedPreferred = isMarkdown
1000 ? setRenderMarkdownPreferred1009 ? 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", () => {
436436
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");
441443
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});
195export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type;197export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type;
196198
...@@ -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});
211export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;214export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;
212const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment);215const 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.uploadEnvironmentId2151 ...(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: no2431 // A marker without an attachment id hydrates as needs-reattach: no
2427 // bytes, no server-side upload, only the metadata to tell the user2432 // 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 });
239239
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();
244247
...@@ -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)";
18const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)";19const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)";
1920
20describe("composerContextReferences", () => {21describe("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}
9595
96/** Replaces a selected range with chips, keeping word boundaries and the trailing caret space. */
97export 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. */
97export function insertInlineContextReference(113export 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}
111126
apps/web/src/lib/desktopPasteAsText.test.ts created+32
...@@ -0,0 +1,32 @@
1import type { DesktopBridge } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3import { DESKTOP_PASTE_AS_TEXT_EVENT, installDesktopPasteAsText } from "./desktopPasteAsText";
4
5describe("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 @@
1import type { DesktopBridge } from "@t3tools/contracts";
2
3export const DESKTOP_PASTE_AS_TEXT_EVENT = "t3:paste-as-text";
4
5/** Arm composer paste handling before Electron delivers the native clipboard event. */
6export 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";
3import { useComposerDraftStore } from "./composerDraftStore";3import { useComposerDraftStore } from "./composerDraftStore";
4import {4import {
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
103it("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}));
2727
28/** Count both staged files and in-flight preparation against the shared question limit. */
29export 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
28export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void {38export 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";
6868
69import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot";69import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot";
70import { installDesktopPasteAsText } from "../lib/desktopPasteAsText";
70import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume";71import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume";
7172
72export const Route = createRootRoute({73export const Route = createRootRoute({
...@@ -108,6 +109,7 @@ export const Route = createRootRoute({...@@ -108,6 +109,7 @@ export const Route = createRootRoute({
108});109});
109110
110function RootRouteView() {111function 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.
6Messages can contain up to 120,000 characters. Longer drafts stay in the composer6Messages can contain up to 120,000 characters. Longer drafts stay in the composer
7so you can shorten them or split them into several messages.7so you can shorten them or split them into several messages.
88
9Pasting 32 KiB or more of text adds that fragment as a text-file attachment so
10the agent can inspect it without filling the model context. A smaller paste also
11becomes an attachment when inserting it would exceed the message limit. On a
12hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere
13to keep a large paste editable in the composer instead.
14
9## Attach files15## Attach files
1016
11Attach up to eight files per message. Images can be up to 10 MB; other files can17Attach 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 nextPastedTextFileName,
5 PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES,
6 isPasteAsTextShortcut,
7 pastedTextDisposition,
8 replaceTextSelection,
9 wouldTextPasteExceedLimit,
10} from "./textPaste.ts";
11
12describe("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
53describe("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
62describe("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
121describe("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 @@
1export const PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES = 32 * 1024;
2
3const textEncoder = new TextEncoder();
4
5export type PastedTextDisposition = "attachment" | "inline";
6
7export 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 */
24export 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. */
41export 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
54export 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
67export 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});
311export type ChatImageAttachment = typeof ChatImageAttachment.Type;311export type ChatImageAttachment = typeof ChatImageAttachment.Type;
312312
313export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {});
314export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type;
315
313export const ChatFileAttachment = Schema.Struct({316export 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});
323export type ChatFileAttachment = typeof ChatFileAttachment.Type;330export type ChatFileAttachment = typeof ChatFileAttachment.Type;
324331
packages/shared/src/composerContextLegacy.test.ts+16
...@@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => {...@@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => {
582 ]);582 ]);
583 });583 });
584584
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
339339
340 // Blocks were appended in send order (terminal, element, preview, review), so they peel340 // 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; a342 // 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 });
101101
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(