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 {
4040 openExternal,
4141 openSystemSettings,
4242 checkSystemPermission,
43 pasteAsText,
4344 probeRemoteEditors,
4445 pickFolder,
4546 pickProjectFavicon,
......@@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
124125 yield* ipc.handle(openExternal);
125126 yield* ipc.handle(openSystemSettings);
126127 yield* ipc.handle(checkSystemPermission);
128 yield* ipc.handle(pasteAsText);
127129 yield* ipc.handle(probeRemoteEditors);
128130 yield* ipc.handle(getUpdateState);
129131 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+1
......@@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
77export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
88export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
99export const MENU_ACTION_CHANNEL = "desktop:menu-action";
10export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
1011export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
1112export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
1213export 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";
66
77import 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
918import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
1019import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
1120import * as ElectronDialog from "../../electron/ElectronDialog.ts";
......@@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts";
1322import {
1423 getLocalEnvironmentBootstraps,
1524 getWindowFullscreenState,
25 pasteAsText,
1626 pickProjectFavicon,
1727} from "./window.ts";
1828
......@@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => {
153163 });
154164});
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
156210describe("pickProjectFavicon", () => {
157211 it.effect("opens a single-image picker from the project directory", () =>
158212 Effect.gen(function* () {
apps/desktop/src/ipc/methods/window.ts+26
......@@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
345345 }),
346346});
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
348374/** Theme files are a few KB; anything larger returns empty text and lets the
349375 * renderer reject it by size without the contents ever crossing the bridge. */
350376const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
apps/desktop/src/preload.ts+1
......@@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
157157 openSystemSettings: (pane: string) =>
158158 ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
159159 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
160 pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
160161 onMenuAction: (listener) => {
161162 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
162163 if (typeof action !== "string") return;
apps/desktop/src/window/DesktopApplicationMenu.test.ts+30
......@@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => {
153153 }),
154154 );
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
156186 // Zoom must route through DesktopWindow.zoomMain instead of the Electron
157187 // zoom roles: the roles zoom whichever webContents has focus, which breaks
158188 // 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
4646 action: string,
4747): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
4848 const desktopWindow = yield* DesktopWindow.DesktopWindow;
49 yield* desktopWindow.dispatchMenuAction(action);
49 yield* desktopWindow.dispatchMenuAction(action, {
50 reveal: action !== "paste-as-text",
51 });
5052});
5153
5254const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
......@@ -135,6 +137,9 @@ export const make = Effect.gen(function* () {
135137 const settingsClick = () => {
136138 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
137139 };
140 const pasteAsTextClick = () => {
141 runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
142 };
138143 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
139144 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
140145 };
......@@ -184,7 +189,34 @@ export const make = Effect.gen(function* () {
184189 { role: environment.platform === "darwin" ? "close" : "quit" },
185190 ],
186191 },
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 },
188220 {
189221 label: "View",
190222 submenu: [
apps/mobile/modules/t3-composer-editor/android/build.gradle+12
......@@ -8,6 +8,10 @@ android {
88 namespace 'expo.modules.t3composereditor'
99 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
1115 defaultConfig {
1216 minSdkVersion rootProject.ext.minSdkVersion
1317 targetSdkVersion rootProject.ext.targetSdkVersion
......@@ -17,4 +21,12 @@ android {
1721dependencies {
1822 implementation project(':expo-modules-core')
1923 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 }
2032}
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt+7
......@@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() {
116116 Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean ->
117117 view.setSpellCheck(spellCheck)
118118 }
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
120126 Events(
121127 "onComposerChange",
......@@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() {
125131 "onComposerPasteImages",
126132 "onComposerContextPress",
127133 "onComposerPasteContext",
134 "onComposerPasteText",
128135 "onComposerContentSizeChange",
129136 )
130137
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+94-10
......@@ -1,7 +1,8 @@
11package expo.modules.t3composereditor
22
3import android.content.Context
3import android.content.ClipData
44import android.content.ClipboardManager
5import android.content.Context
56import android.graphics.Color
67import android.graphics.Canvas
78import android.graphics.Paint
......@@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
4849 private val onComposerPasteImages by EventDispatcher()
4950 private val onComposerContextPress by EventDispatcher()
5051 private val onComposerPasteContext by EventDispatcher()
52 private val onComposerPasteText by EventDispatcher()
5153 private val onComposerContentSizeChange by EventDispatcher()
5254 private var applyingNativeValue = false
5355 private var desiredLineHeightPx = 0
......@@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
8688 editor.pasteImagesListener = { uris ->
8789 onComposerPasteImages(mapOf("uris" to uris))
8890 }
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 }
90101 val contextGestures =
91102 GestureDetector(
92103 context,
......@@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
120131 contextGestures.onTouchEvent(event)
121132 false
122133 }
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 }
123145 editor.setOnFocusChangeListener { _, hasFocus ->
124146 if (hasFocus) {
125147 onComposerFocus(emptyMap<String, Any>())
......@@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
305327 updateInputFlags()
306328 }
307329
330 fun setTextPasteThresholdBytes(threshold: Int) {
331 editor.textPasteThresholdBytes = threshold
332 }
333
334 fun setMaxInputChars(maxInputChars: Int) {
335 editor.maxInputChars = maxInputChars
336 }
337
308338 fun focusEditor() {
309339 editor.requestFocus()
310340 val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
......@@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
365395 editor.highlightColor = defaultHighlightColor
366396 }
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> =
369402 mapOf(
370 "start" to editor.selectionStart.coerceAtLeast(0),
371 "end" to editor.selectionEnd.coerceAtLeast(0),
403 "start" to minOf(start, end).coerceAtLeast(0),
404 "end" to maxOf(start, end).coerceAtLeast(0),
372405 )
373406
374407 private fun emitSelectionChange(start: Int, end: Int) {
......@@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
379412 onComposerSelectionChange(
380413 mapOf(
381414 "value" to editor.text.toString(),
382 "selection" to mapOf("start" to start, "end" to end),
415 "selection" to currentSelectionPayload(start, end),
383416 "eventCount" to nativeEventCount,
384417 ),
385418 )
......@@ -544,11 +577,14 @@ private fun parseTokens(value: String): List<ComposerToken> = try {
544577 emptyList()
545578}
546579
547private class SelectionAwareEditText(context: Context) : EditText(context) {
580internal class SelectionAwareEditText(context: Context) : EditText(context) {
548581 var readOnly = false
549582 var selectionListener: ((Int, Int) -> Unit)? = null
550583 var pasteImagesListener: ((List<String>) -> Unit)? = null
551584 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
552588 var clipboardFragment = ""
553589
554590 private fun deleteChip(backwards: Boolean): Boolean {
......@@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
602638 super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
603639 }
604640 }
605
606641 override fun onSelectionChanged(selStart: Int, selEnd: Int) {
607642 super.onSelectionChanged(selStart, selEnd)
608643 selectionListener?.invoke(selStart, selEnd)
......@@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
615650 }
616651 val handled = when {
617652 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 }
618657 pasting -> pasteContextOrImages()
619658 else -> false
620659 }
......@@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
636675 pasteContextListener?.invoke(payload)
637676 return true
638677 }
678 return pasteImagesOrInterceptedText()
679 }
680
681 private fun pasteImagesOrInterceptedText(): Boolean {
639682 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
640683 val clip = clipboard?.primaryClip
641684 val imageUris = buildList {
......@@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) {
648691 }
649692 }
650693 }
651 if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris)
652 return imageUris.isNotEmpty()
694 return when {
695 imageUris.isNotEmpty() -> {
696 pasteImagesListener?.invoke(imageUris)
697 true
698 }
699 else -> pasteInterceptedText(clip)
700 }
701 }
702
703 private fun pasteInterceptedText(clip: ClipData?, foldLargeText: Boolean = true): Boolean {
704 val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null
705 if (text.isNullOrEmpty()) return false
706 val start = minOf(selectionStart, selectionEnd).coerceIn(0, length())
707 val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length())
708 val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars
709 val shouldFold = foldLargeText && (
710 text.length >= textPasteThresholdBytes ||
711 text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes
712 )
713 val shouldIntercept = exceedsInputLimit || shouldFold
714 if (shouldIntercept) {
715 pasteTextListener?.invoke(text, start, end)
716 }
717 // Let EditText perform ordinary pastes, retaining its native undo history.
718 return shouldIntercept
719 }
720
721 // coerceToText opens content: URIs synchronously. Leave URI-backed
722 // clipboard items to Android's normal paste path so the UI thread never
723 // reads an arbitrary provider just to measure a text paste.
724 private fun ClipData.plainText(): String? =
725 takeIf { itemCount > 0 }
726 ?.getItemAt(0)
727 ?.takeIf { it.uri == null }
728 ?.coerceToText(context)
729 ?.toString()
730 ?.takeIf(String::isNotEmpty)
731
732 override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean {
733 if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) {
734 return onTextContextMenuItem(android.R.id.pasteAsPlainText)
735 }
736 return super.onKeyShortcut(keyCode, event)
653737 }
654738}
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 {
8787 Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
8888 view.setSpellCheck(spellCheck)
8989 }
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
9197 Events(
9298 "onComposerChange",
......@@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module {
97103 "onComposerPasteImages",
98104 "onComposerContextPress",
99105 "onComposerPasteContext",
106 "onComposerPasteText",
100107 "onComposerContentSizeChange"
101108 )
102109
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+70-1
......@@ -86,10 +86,14 @@ private final class ComposerTextView: UITextView {
8686
8787 var onPasteImages: (([String]) -> Void)?
8888 var onPasteContext: (([String: String]) -> Void)?
89 var onPasteText: ((String, NSRange) -> Void)?
8990 var clipboardFragment = ""
9091 var onAttributedMutation: (() -> Void)?
9192 var onSubmit: (() -> Void)?
9293 var isReadOnly = false
94 var textPasteThresholdBytes = 0
95 var maxInputChars = Int.max
96 private var bypassTextPasteInterception = false
9397
9498 override var keyCommands: [UIKeyCommand]? {
9599 var commands = super.keyCommands ?? []
......@@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView {
101105 submit.discoverabilityTitle = "Send Message"
102106 submit.wantsPriorityOverSystemBehavior = true
103107 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 }
104118 return commands
105119 }
106120
......@@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView {
108122 onSubmit?()
109123 }
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
111134 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
112135 if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) {
113136 return false
......@@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView {
150173 return
151174 }
152175 }
176 if !bypassTextPasteInterception,
177 let text = pasteboard.string, shouldInterceptTextPaste(text) {
178 onPasteText?(text, selectedRange)
179 return
180 }
153181 super.paste(sender)
154182 }
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
156198 override func deleteBackward() {
157199 guard !isReadOnly else {
158200 return
......@@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
368410 let onComposerPasteImages = EventDispatcher()
369411 let onComposerContextPress = EventDispatcher()
370412 let onComposerPasteContext = EventDispatcher()
413 let onComposerPasteText = EventDispatcher()
371414 let onComposerContentSizeChange = EventDispatcher()
372415
373416 public required init(appContext: AppContext? = nil) {
......@@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
387430 self?.onComposerPasteImages(["uris": urls])
388431 }
389432 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 ])
391452 }
392453 textView.onAttributedMutation = { [weak self] in
393454 self?.emitTextChange()
......@@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
596657 textView.spellCheckingType = spellCheck ? .yes : .no
597658 }
598659
660 func setTextPasteThresholdBytes(_ threshold: Int) {
661 textView.textPasteThresholdBytes = threshold
662 }
663
664 func setMaxInputChars(_ maxInputChars: Int) {
665 textView.maxInputChars = maxInputChars
666 }
667
599668 func focusEditor() {
600669 textView.becomeFirstResponder()
601670 }
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+9
......@@ -33,6 +33,15 @@ using namespace facebook::react;
3333@end
3434
3535@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
3645- (void)copy:(id)sender
3746{
3847 NSRange selected = self.selectedRange;
apps/mobile/src/components/ComposerAttachmentStrip.tsx+3-1
......@@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
226226 // The document picker types every pick as a plain file, so a picture arrives here as one.
227227 // What it *is* decides how it presents, the same way videos are already recognised below.
228228 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;
229231 return (
230232 <ComposerImageAttachment
231233 {...props}
232234 attachment={
233235 attachment.type === "image"
234236 ? attachment
235 : { ...attachment, type: "image", previewUri: attachment.fileUri }
237 : { ...rest, type: "image", previewUri: attachment.fileUri }
236238 }
237239 />
238240 );
apps/mobile/src/components/ComposerEditor.tsx+44-27
......@@ -9,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native
99import {
1010 appendComposerDraftAttachments,
1111 createComposerDraftContextHistory,
12 getComposerDraftAfterSelection,
1213 getComposerDraftSnapshot,
1314 insertComposerDraftContext,
15 insertComposerDraftText,
1416 rememberComposerDraftSelection,
1517 setComposerDraftContext,
1618 setComposerContextImporting,
1719 useComposerDraft,
1820} from "../state/use-composer-drafts";
19import {
20 importComposerContextClipboard,
21 type NativeContextClipboard,
22} from "../lib/composerContextClipboard";
21import { importComposerContextClipboard } from "../lib/composerContextClipboard";
2322import { ComposerContextSheet } from "./ComposerContextSheet";
2423import { AppText as Text } from "./AppText";
2524import {
......@@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & {
3433 readonly onOpenMention?: (path: string) => void;
3534 /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */
3635 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;
3744};
3845
3946export function ComposerEditor({
......@@ -41,6 +48,8 @@ export function ComposerEditor({
4148 environmentId,
4249 onOpenMention,
4350 onOpenAttachment,
51 chipsInert,
52 onInertChipPress,
4453 ...props
4554}: ComposerEditorProps) {
4655 const draft = useComposerDraft(draftKey ?? null);
......@@ -68,40 +77,35 @@ export function ComposerEditor({
6877 },
6978 [draftKey],
7079 );
71 const pasteContext = async (clipboard: NativeContextClipboard) => {
80 const pasteContext = async (
81 clipboard: Parameters<NonNullable<NativeComposerEditorProps["onPasteContext"]>>[0],
82 ) => {
7283 if (!draftKey || importRef.current || props.readOnly || props.editable === false) return;
84 const insertion = { text: clipboard.value, ...clipboard.selection };
7385 const controller = new AbortController();
7486 importRef.current = controller;
7587 setImporting(true);
7688 setComposerContextImporting(draftKey, true);
7789 try {
90 const retained = getComposerDraftAfterSelection(draftKey, insertion);
7891 const result = await importComposerContextClipboard(
7992 clipboard,
80 getComposerDraftSnapshot(draftKey).attachments.length,
93 retained.attachments.length,
8194 controller.signal,
82 getComposerDraftSnapshot(draftKey).context?.records.length ?? 0,
95 retained.context?.records.length ?? 0,
8396 );
8497 if (!result) {
85 insertComposerDraftContext(draftKey, {
86 text: clipboard.text,
87 context: { version: 1, records: [] },
88 });
98 insertComposerDraftText(draftKey, clipboard.text, insertion);
8999 return;
90100 }
91 const rejected = appendComposerDraftAttachments(draftKey, result.attachments);
92 const ids = new Set(
93 getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id),
94 );
95 insertComposerDraftContext(draftKey, {
96 text: result.text,
97 context: {
98 version: 1,
99 records: result.context.records.filter(
100 (record) => !("attachmentId" in record) || ids.has(record.attachmentId),
101 ),
102 },
103 });
104 if (result.failures.length > 0 || rejected > 0)
101 if (!insertComposerDraftContext(draftKey, result, insertion)) {
102 Alert.alert(
103 "Could not paste context",
104 "Remove some attachments or context items from the draft, then paste again.",
105 );
106 return;
107 }
108 if (result.failures.length > 0)
105109 Alert.alert(
106110 "Some attachments could not be copied",
107111 "Reconnect to the source environment and copy them again. References without their files are marked unavailable.",
......@@ -161,6 +165,10 @@ export function ComposerEditor({
161165 onPasteContext={(clipboard) => void pasteContext(clipboard)}
162166 context={draft.context}
163167 onContextPress={(selection) => {
168 if (chipsInert) {
169 onInertChipPress?.();
170 return;
171 }
164172 const path = composerMentionPath(selection.source, draft.context);
165173 if (path && onOpenMention) {
166174 onOpenMention(path);
......@@ -174,7 +182,12 @@ export function ComposerEditor({
174182 setSelected(selection);
175183 }}
176184 onSelectionChange={(selection) => {
177 if (draftKey) rememberComposerDraftSelection(draftKey, props.value, selection);
185 if (draftKey)
186 rememberComposerDraftSelection(
187 draftKey,
188 getComposerDraftSnapshot(draftKey).text,
189 selection,
190 );
178191 props.onSelectionChange?.(selection);
179192 }}
180193 />
......@@ -229,4 +242,8 @@ export function ComposerEditor({
229242 </>
230243 );
231244}
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";
2020import { useUniwindTheme } from "../../lib/useUniwindTheme";
2121import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts";
2222import { FileMarkdownPreview } from "./FileMarkdownPreview";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2324import { SourceFileSurface } from "./SourceFileSurface";
2425import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
2526
......@@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: {
99100 </Text>
100101 </View>
101102 ) : null}
102 {table && document.rendered ? (
103 {table && document.activeMode === "table" ? (
103104 <ScrollView className="flex-1">
104105 {table.truncated ? (
105106 <View className="border-b border-warning-border bg-warning px-4 py-2">
......@@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: {
137138 </View>
138139 </ScrollView>
139140 </ScrollView>
140 ) : document.kind === "markdown" && document.rendered && props.environmentId ? (
141 ) : document.activeMode === "markdown" && props.environmentId ? (
141142 <FileMarkdownPreview
142143 cwd=""
143144 relativePath=""
......@@ -147,7 +148,7 @@ function AttachmentDocumentBody(props: {
147148 captured
148149 />
149150 ) : (
150 <SourceFileSurface contents={content.text} path={props.name} />
151 <SourceFileSurface contents={content.text} path={props.name} selectable />
151152 )}
152153 </View>
153154 );
......@@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: {
175176
176177export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
177178 const navigation = useNavigation();
179 const { appearance, setCodeWordBreak } = useAppearancePreferences();
178180 const iconColor = useUniwindTheme()["--color-icon"];
179181 const isAndroid = Platform.OS === "android";
180182 const params = props.route.params;
......@@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
249251 handleBack();
250252 }, [draftKey, handleBack, params.attachmentId]);
251253
252 const { content, renderedMode, rendered, setRendered, share, sharing } = document;
254 const { content, renderedMode, activeMode, setRendered, share, sharing } = document;
253255 const menuActions = useMemo(
254256 () =>
255257 [
......@@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
271273 onPress: () => setRendered(false),
272274 } as const)
273275 : 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,
274285 content
275286 ? ({
276287 id: "copy",
......@@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
312323 } as const)
313324 : null,
314325 ].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 ],
316339 );
317 const activeMode = rendered ? "preview" : "source";
340 const selectedAction = activeMode === "source" ? "source" : "preview";
318341 const androidMenuActions = useMemo<MenuAction[]>(
319342 () =>
320343 menuActions.map((action) => ({
321344 id: action.id,
322345 title: action.title,
323346 image: action.icon,
324 state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined,
347 state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined,
325348 ...("destructive" in action ? { attributes: { destructive: true } } : {}),
326349 })),
327 [activeMode, menuActions],
350 [selectedAction, menuActions],
328351 );
329352 const handleAndroidMenuAction = useCallback(
330353 (event: { nativeEvent: { event: string } }) => {
......@@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
372395 <NativeHeaderToolbar.MenuAction
373396 key={action.id}
374397 icon={action.icon}
375 isOn={action.id === activeMode}
398 isOn={action.id === selectedAction}
376399 onPress={action.onPress}
377400 >
378401 {action.title}
apps/mobile/src/features/files/SourceFileSurface.tsx+124-19
......@@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react";
22import { AsyncResult } from "effect/unstable/reactivity";
33import type { ComponentType } from "react";
44import { 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
714import { AppText as Text } from "../../components/AppText";
815import { LoadingStrip } from "../../components/LoadingStrip";
......@@ -23,13 +30,17 @@ import {
2330 NATIVE_SOURCE_CONTENT_WIDTH,
2431 nativeSourceRowId,
2532} from "./nativeSourceFileAdapter";
26import { prepareSourceFileDocument } from "./source-file-document";
33import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive";
34
35import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
2736import { sourceHighlightAtom } from "./sourceHighlightingState";
2837
2938interface SourceFileSurfaceProps {
3039 readonly contents: string;
3140 readonly path: string;
3241 readonly initialLine?: number | null;
42 /** Keep the entire document in one native text-selection scope. */
43 readonly selectable?: boolean;
3344 /** Enables native pull-to-refresh on the source surface. */
3445 readonly onRefresh?: () => Promise<void> | void;
3546}
......@@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) {
129140 ? "ready"
130141 : "highlighting";
131142
132 return { lines, rowsJson, status, targetIndex, theme, tokens };
143 return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens };
133144}
134145
135146function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
......@@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat
146157 return null;
147158}
148159
149function NativeSourceFileSurface(
150 props: SourceFileSurfaceProps & {
151 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
152 },
153) {
154 const { NativeView, onRefresh } = props;
155 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
156 const { themeAppearance, themeId } = useAppearancePreferences();
157 const appTheme = useUniwindTheme();
158 const { width: viewportWidth } = useWindowDimensions();
159 const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
160function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) {
160161 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
161162 const handlePullToRefresh = useCallback(async () => {
162163 if (!onRefresh) {
......@@ -169,6 +170,21 @@ function NativeSourceFileSurface(
169170 setIsPullRefreshing(false);
170171 }
171172 }, [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);
172188 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
173189 const selectedRowIdsJson = useMemo(
174190 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
......@@ -212,9 +228,18 @@ function NativeSourceFileSurface(
212228}
213229
214230function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
231 const foreground = useUniwindTheme()["--color-foreground"];
215232 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 );
217238 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
219244 useEffect(() => {
220245 if (targetIndex === null) {
......@@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
240265 [codeSurface, codeWordBreak, targetIndex, tokens],
241266 );
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
243317 const list = (
244318 <FlatList
245319 ref={listRef}
320 refreshControl={refreshControl}
246321 data={lines}
247322 keyExtractor={(_line, index) => String(index)}
248323 initialNumToRender={80}
......@@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
266341 />
267342 );
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
269361 return (
270362 <View className="relative flex-1 bg-sheet">
271363 <SourceHighlightStatusView status={status} />
272 {codeWordBreak ? (
273 list
364 {usesLineList ? (
365 codeWordBreak ? (
366 list
367 ) : (
368 <ScrollView horizontal bounces={false} className="flex-1">
369 {list}
370 </ScrollView>
371 )
372 ) : codeWordBreak ? (
373 padded
274374 ) : (
375 // Without wrapping the text keeps its natural width and the reader pans to it.
275376 <ScrollView horizontal bounces={false} className="flex-1">
276 {list}
377 {padded}
277378 </ScrollView>
278379 )}
279380 </View>
......@@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
282383
283384export function SourceFileSurface(props: SourceFileSurfaceProps) {
284385 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 ? (
286391 <NativeSourceFileSurface {...props} NativeView={NativeView} />
287392 ) : (
288393 <JavaScriptSourceFileSurface {...props} />
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+26
......@@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
583583 useAdaptiveWorkspacePaneRole("inspector");
584584 const navigation = useNavigation();
585585 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
586 const { appearance, setCodeWordBreak } = useAppearancePreferences();
586587 const iconColor = useUniwindTheme()["--color-icon"];
587588 const isAndroid = Platform.OS === "android";
588589 const params = props.route.params;
......@@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
763764 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
764765 } as const)
765766 : 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,
766777 ...(mediaSource
767778 ? mediaActions.actions
768779 .filter(({ id }) => id !== "open-file")
......@@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
783794 onPress: () => copyTextWithHaptic(relativePath),
784795 } as const,
785796 ]),
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,
786808 isPdfFile({ name: relativePath }) && previewUri !== null
787809 ? ({
788810 id: "open-pdf",
......@@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
821843 : null,
822844 ].filter((action) => action !== null);
823845 }, [
846 appearance.codeWordBreak,
847 setCodeWordBreak,
824848 assetPreviewUri,
825849 assetPreview.refresh,
826850 previewUri,
......@@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
833857 resolvedActiveMode,
834858 mediaSource,
835859 mediaActions.actions,
860 fileData?.contents,
861 fileData?.truncated,
836862 ]);
837863
838864 const androidFileMenuActions = useMemo<MenuAction[]>(
apps/mobile/src/features/files/source-file-document.test.ts+12-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { prepareSourceFileDocument } from "./source-file-document";
3import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
44
55describe("prepareSourceFileDocument", () => {
66 it("normalizes and serializes source rows once for repeated consumers", () => {
......@@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => {
1414 expect(second).toBe(first);
1515 });
1616});
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 @@
1import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
12import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
23
34const MAX_CACHED_DOCUMENTS = 8;
......@@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument
5253
5354 return document;
5455}
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 @@
11import { 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";
28import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
39import {
410 CommonActions,
......@@ -22,11 +28,16 @@ import { useFontFamily } from "../../lib/useFontFamily";
2228
2329import {
2430 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
31 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
2532 resolveEnvironmentMachineKind,
2633 type EnvironmentId,
2734} from "@t3tools/contracts";
2835
29import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
36import {
37 ComposerEditor,
38 type ComposerEditorHandle,
39 type ComposerTextPaste,
40} from "../../components/ComposerEditor";
3041import { composerContextImportsAtom } from "../../state/use-composer-drafts";
3142import {
3243 composerContextSendBlockReason,
......@@ -41,7 +52,6 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
4152import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
4253import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
4354import { composerStripAttachments } from "../../lib/composerImages";
44import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
4555import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
4656import {
4757 composerAttachmentUploadBlockReason,
......@@ -73,13 +83,17 @@ import {
7383import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
7484import {
7585 convertPastedImagesToAttachments,
86 createPastedTextComposerAttachment,
7687 pickComposerFiles,
7788 pickComposerMedia,
89 removePersistedComposerAttachmentFile,
7890 type DraftComposerFileAttachment,
7991} from "../../lib/composerImages";
8092import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
8193import {
8294 clearComposerDraftContent,
95 captureComposerDraftInsertion,
96 countComposerDraftAttachmentsAfterSelection,
8397 getComposerDraftSnapshot,
8498 mergeComposerDraftContent,
8599 restoreComposerDraftSnapshot,
......@@ -297,6 +311,12 @@ export function NewTaskDraftScreen(props: {
297311 const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>());
298312 const activeShareImportTokenRef = useRef<symbol | null>(null);
299313 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 });
300320 const latestDraftKeyRef = useRef(flow.draftKey);
301321 const latestIncomingShareIdRef = useRef(props.incomingShareId);
302322 latestDraftKeyRef.current = flow.draftKey;
......@@ -344,16 +364,8 @@ export function NewTaskDraftScreen(props: {
344364 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
345365 // Media needs its thumbnail; every other file already reads as its inline chip.
346366 const stripAttachments = useMemo(
347 () =>
348 composerStripAttachments(
349 flow.attachments,
350 new Set(
351 collectComposerContextReferences(flow.prompt).map(
352 (occurrence) => occurrence.contextId as string,
353 ),
354 ),
355 ),
356 [flow.attachments, flow.prompt],
367 () => composerStripAttachments(flow.attachments),
368 [flow.attachments],
357369 );
358370 const composerMenu = useComposerCommandMenu({
359371 draftMessage: flow.prompt,
......@@ -903,15 +915,19 @@ export function NewTaskDraftScreen(props: {
903915 return;
904916 }
905917 const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
918 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
906919 const result = await pickComposerMedia({
907 existingCount: flow.attachments.length,
920 existingCount:
921 flow.draftKey && insertion
922 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
923 : flow.attachments.length,
908924 maxVideoBytes:
909925 capabilities?.attachmentUploads === true
910926 ? capabilities.fileAttachments?.maxUploadBytes
911927 : undefined,
912928 });
913929 const rejectedCount =
914 result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0;
930 result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0;
915931 const problems = [
916932 ...(result.error ? [result.error] : []),
917933 ...(rejectedCount > 0
......@@ -933,11 +949,16 @@ export function NewTaskDraftScreen(props: {
933949 Alert.alert("File attachments are not available on this server.");
934950 return;
935951 }
952 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
936953 const result = await pickComposerFiles({
937 existingCount: flow.attachments.length,
954 existingCount:
955 flow.draftKey && insertion
956 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
957 : flow.attachments.length,
938958 maxBytes,
939959 });
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;
941962 // The picker error and the live-cap rejection can both happen in one
942963 // pick; report both in a single alert.
943964 const problems = [
......@@ -954,12 +975,16 @@ export function NewTaskDraftScreen(props: {
954975 const handleNativePasteImages = useCallback(
955976 async (uris: ReadonlyArray<string>) => {
956977 try {
978 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
957979 const images = await convertPastedImagesToAttachments({
958980 uris,
959 existingCount: flow.attachments.length,
981 existingCount:
982 flow.draftKey && insertion
983 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
984 : flow.attachments.length,
960985 });
961986 if (images.length > 0) {
962 flow.appendAttachments(images);
987 flow.appendAttachments(images, insertion);
963988 }
964989 } catch (error) {
965990 console.error("[native paste] error converting images", error);
......@@ -968,8 +993,106 @@ export function NewTaskDraftScreen(props: {
968993 [flow],
969994 );
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
9711094 async function handleStart(): Promise<void> {
972 if (voiceInput.blocksSubmission) return;
1095 if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
9731096 const selectedProject = flow.selectedProject;
9741097 const draftKey = flow.draftKey;
9751098 if (!selectedProject || !draftKey) {
......@@ -1139,6 +1262,7 @@ export function NewTaskDraftScreen(props: {
11391262 isIncomingShareReady &&
11401263 !isImportingShare &&
11411264 !flow.submitting &&
1265 pendingPastedTextAttachmentCount === 0 &&
11421266 !voiceInput.blocksSubmission &&
11431267 !(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
11441268 const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
......@@ -1195,6 +1319,7 @@ export function NewTaskDraftScreen(props: {
11951319 onFocus={() => setIsComposerFocused(true)}
11961320 onBlur={() => setIsComposerFocused(false)}
11971321 onPasteImages={(uris) => void handleNativePasteImages(uris)}
1322 onPasteText={(paste) => void handleNativePasteText(paste)}
11981323 placeholder="Ask anything…"
11991324 singleLineCentered={false}
12001325 contentInsetVertical={0}
......@@ -1486,13 +1611,15 @@ export function NewTaskDraftScreen(props: {
14861611 <ComposerActionButton
14871612 accessibilityLabel={
14881613 attachmentBlockReason ??
1489 (flow.submitting
1490 ? "Starting task"
1491 : attachmentsUploading
1492 ? "Queue task, sends when uploads finish"
1493 : environmentConnected
1494 ? "Start task"
1495 : "Queue task")
1614 (pendingPastedTextAttachmentCount > 0
1615 ? "Attaching pasted text"
1616 : flow.submitting
1617 ? "Starting task"
1618 : attachmentsUploading
1619 ? "Queue task, sends when uploads finish"
1620 : environmentConnected
1621 ? "Start task"
1622 : "Queue task")
14961623 }
14971624 disabled={!canStart}
14981625 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
apps/mobile/src/features/threads/ThreadComposer.tsx+103-25
......@@ -1,14 +1,19 @@
1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
23import { useAtomValue } from "@effect/atom-react";
3import type {
4 EnvironmentId,
5 MessageId,
6 ModelSelection,
7 OrchestrationThreadShell,
8 ProviderInteractionMode,
9 RuntimeMode,
10 ServerConfig as T3ServerConfig,
11 UsageLimitsReport,
4import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
5import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste";
6import {
7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
9 type EnvironmentId,
10 type MessageId,
11 type ModelSelection,
12 type OrchestrationThreadShell,
13 type ProviderInteractionMode,
14 type RuntimeMode,
15 type ServerConfig as T3ServerConfig,
16 type UsageLimitsReport,
1217} from "@t3tools/contracts";
1318import {
1419 collectProviderUsageLimits,
......@@ -46,7 +51,10 @@ import Animated, {
4651import { useUniwindTheme } from "../../lib/useUniwindTheme";
4752import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4853import { scopedThreadKey } from "../../lib/scopedEntities";
49import { composerContextImportsAtom } from "../../state/use-composer-drafts";
54import {
55 composerContextImportsAtom,
56 countComposerDraftAttachmentsAfterSelection,
57} from "../../state/use-composer-drafts";
5058import type { ComposerDocumentAttachment } from "../../lib/composerContext";
5159import { useProject } from "../../state/entities";
5260import { scopeProjectRef } from "@t3tools/client-runtime/environment";
......@@ -72,7 +80,6 @@ import {
7280 type DraftComposerAttachment,
7381 type DraftComposerFileAttachment,
7482} from "../../lib/composerImages";
75import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
7683import {
7784 buildModelOptions,
7885 groupByProvider,
......@@ -135,6 +142,7 @@ export interface ThreadComposerProps {
135142 readonly onPickDraftMedia: () => Promise<void>;
136143 readonly onPickDraftFiles: () => Promise<void>;
137144 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
145 readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
138146 readonly onRemoveDraftImage: (imageId: string) => void;
139147 readonly onStopThread: () => void;
140148 readonly onSendMessage: () => Promise<MessageId | null>;
......@@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
278286 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
279287 const inputRef = props.editorRef ?? fallbackInputRef;
280288 const [isFocused, setIsFocused] = useState(false);
289 const pendingPastedTextAttachmentCountRef = useRef(0);
290 const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0);
281291 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
282292 editorRef: inputRef,
283293 isEditorFocused: isFocused,
......@@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
291301 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
292302 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
293303 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
294 // Attachment context ids are the attachment id, so the prompt alone says which attachments
295 // already read as an inline chip and need no strip tile.
304 // Only media belongs above the composer; every other file reads as its inline chip.
296305 const stripAttachments = useMemo(
297 () =>
298 composerStripAttachments(
299 props.draftAttachments,
300 new Set(
301 collectComposerContextReferences(props.draftMessage).map(
302 (occurrence) => occurrence.contextId as string,
303 ),
304 ),
305 ),
306 [props.draftAttachments, props.draftMessage],
306 () => composerStripAttachments(props.draftAttachments),
307 [props.draftAttachments],
307308 );
308309 const showStopAction =
309310 !hasContent &&
......@@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
422423 states: uploadStates,
423424 });
424425 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;
426430 const canSend =
427431 hasContent &&
428432 !contextImports[composerOwnerKey] &&
......@@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
478482 onEditorFocusChange?.(false);
479483 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]);
480484 const handleSend = useCallback(async () => {
485 if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
481486 // Typed out in full rather than picked from the menu. Attachments mean the
482487 // user is sending a prompt, so those go through as usual.
483488 if (
......@@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
488493 if (openUsageLimits()) onChangeDraftMessage("");
489494 return;
490495 }
491 if (voiceInput.blocksSubmission) return;
492496 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
493497 if (inFlightThreadIdsRef.current.has(threadKey)) return;
494498 inFlightThreadIdsRef.current.add(threadKey);
......@@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
736740 });
737741 }}
738742 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()}
739747 ref={inputRef}
740748 multiline
741749 value={props.draftMessage}
......@@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
745753 onChangeText={props.onChangeDraftMessage}
746754 onSelectionChange={composerMenu.onSelectionChange}
747755 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 }}
748826 placeholder={props.placeholder}
749827 onFocus={handleFocus}
750828 onBlur={handleBlur}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3
......@@ -1,3 +1,4 @@
1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
12import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
23import {
34 appendCodexArtifactTemplateUsePrompt,
......@@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps {
155156 readonly onPickDraftMedia: () => Promise<void>;
156157 readonly onPickDraftFiles: () => Promise<void>;
157158 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
159 readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
158160 readonly onRemoveDraftImage: (imageId: string) => void;
159161 readonly onStopThread: () => void;
160162 readonly onSendMessage: () => Promise<MessageId | null>;
......@@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
10491051 onPickDraftMedia={props.onPickDraftMedia}
10501052 onPickDraftFiles={props.onPickDraftFiles}
10511053 onNativePasteImages={props.onNativePasteImages}
1054 onNativePasteText={props.onNativePasteText}
10521055 onRemoveDraftImage={props.onRemoveDraftImage}
10531056 onStopThread={props.onStopThread}
10541057 onSendMessage={handleSendMessage}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
......@@ -891,6 +891,7 @@ function ThreadRouteContent(
891891 onPickDraftMedia={composer.onPickDraftMedia}
892892 onPickDraftFiles={composer.onPickDraftFiles}
893893 onNativePasteImages={composer.onNativePasteImages}
894 onNativePasteText={composer.onNativePasteText}
894895 onRemoveDraftImage={composer.onRemoveDraftImage}
895896 serverConfig={serverConfig}
896897 onStopThread={handleStopThread}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+10-2
......@@ -44,6 +44,7 @@ import { projectEnvironment } from "../../state/projects";
4444import { useEnvironmentQuery } from "../../state/query";
4545import {
4646 appendComposerDraftAttachments,
47 type ComposerDraftInsertion,
4748 clearComposerDraft,
4849 composerDraftsAtom,
4950 createNewTaskDraft,
......@@ -203,7 +204,10 @@ type NewTaskFlowContextValue = {
203204 readonly setPrompt: (value: string) => void;
204205 readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerAttachment>) => void;
205206 /** 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;
207211 readonly removeAttachment: (imageId: string) => void;
208212 readonly clearAttachments: () => void;
209213 readonly setSubmitting: (value: boolean) => void;
......@@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
600604 // Returns how many attachments the live cap rejected so the caller can
601605 // tell the user (a concurrent add can fill the draft mid-pick).
602606 const appendAttachments = useCallback(
603 (nextAttachments: ReadonlyArray<DraftComposerAttachment>): number => {
607 (
608 nextAttachments: ReadonlyArray<DraftComposerAttachment>,
609 insertion?: ComposerDraftInsertion,
610 ): number => {
604611 if (!selectedProjectDraftKey) {
605612 return 0;
606613 }
607614 return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, {
608615 appendReference: true,
616 insertion,
609617 });
610618 },
611619 [selectedProjectDraftKey],
apps/mobile/src/lib/appearancePreferences.test.ts+7
......@@ -90,6 +90,13 @@ describe("appearancePreferences", () => {
9090 expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true);
9191 });
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
93100 it("returns the authored text scale at the 16pt default", () => {
94101 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";
1111import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
1212import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
1313import { useRefreshAssetUrl } from "../state/assets";
14import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation";
1415
1516const isLocalUri = (uri: string) => /^(file|content):/.test(uri);
1617
......@@ -32,8 +33,6 @@ export function useAttachmentDocument(input: {
3233}) {
3334 const kind = filePreviewKind(input);
3435 const delimiter = filePreviewDelimiter(input);
35 const renderedMode =
36 kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null;
3736 const shareController = useRef<AbortController | null>(null);
3837 useEffect(() => () => shareController.current?.abort(), []);
3938 const resource = useMemo(
......@@ -61,6 +60,12 @@ export function useAttachmentDocument(input: {
6160 const [contentError, setContentError] = useState<string | null>(null);
6261 const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null);
6362 const [rendered, setRendered] = useState(true);
63 const presentation = attachmentDocumentPresentation({
64 kind,
65 hasTable: table !== null,
66 hasEnvironment: input.environmentId !== null,
67 rendered,
68 });
6469 const [revision, setRevision] = useState(0);
6570 const [sharing, setSharing] = useState(false);
6671 const uri = input.attachment ? localUri : remoteUri;
......@@ -185,7 +190,7 @@ export function useAttachmentDocument(input: {
185190 };
186191 return {
187192 kind,
188 renderedMode,
193 ...presentation,
189194 uri,
190195 /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */
191196 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", () => {
360360 });
361361
362362 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
365372 expect(mocks.upload).toHaveBeenCalledWith(
366373 "file:///documents/report.pdf",
......@@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => {
379386 name: "report.pdf",
380387 mimeType: "application/pdf",
381388 sizeBytes: 42,
389 source: { _tag: "pasted-text" },
382390 });
383391 expect(prepared.attachments[1]?.type).toBe("image");
384392 expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]);
385393 expect(prepared.draftAttachments[0]).toEqual({
386 ...file,
394 ...pastedFile,
387395 uploadedAttachmentId: MINTED_ID,
388396 uploadEnvironmentId: environmentId,
389397 });
apps/mobile/src/lib/attachmentUpload.ts+5-1
......@@ -198,7 +198,11 @@ function uploadedReference(
198198 // chat view with nothing to show a thumbnail from, on every client.
199199 return isComposerImageAttachment(attachment)
200200 ? { type: "image", ...fields }
201 : { type: "file", ...fields };
201 : {
202 type: "file",
203 ...fields,
204 ...(attachment.source ? { source: attachment.source } : {}),
205 };
202206}
203207
204208function attachmentUploadInput(attachment: DraftComposerAttachment) {
apps/mobile/src/lib/composer-image-schema.ts+2-1
......@@ -1,5 +1,5 @@
11import * as Schema from "effect/Schema";
2import { EnvironmentId } from "@t3tools/contracts";
2import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts";
33
44export const DraftComposerImageAttachmentSchema = Schema.Struct({
55 id: Schema.String,
......@@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({
2929 mimeType: Schema.String,
3030 sizeBytes: Schema.Number,
3131 fileUri: Schema.String,
32 source: Schema.optional(PastedTextAttachmentSource),
3233 uploadedAttachmentId: Schema.optional(Schema.String),
3334 uploadEnvironmentId: Schema.optional(EnvironmentId),
3435});
apps/mobile/src/lib/composerImages.test.ts+139-11
......@@ -1,14 +1,28 @@
11import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
22import { 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
615vi.mock("expo-file-system", () => ({
716 File: class {
817 readonly uri: string;
18 readonly name: string;
19 readonly parentDirectory: { readonly uri: string };
920
10 constructor(uri: string) {
11 this.uri = uri;
21 constructor(parent: string | { readonly uri: string }, name?: string) {
22 const parentUri = typeof parent === "string" ? parent : parent.uri;
23 this.uri = name ? `${parentUri}/${name}` : parentUri;
24 this.name = name ?? this.uri.split("/").at(-1) ?? "file";
25 this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) };
1226 }
1327
1428 get exists(): boolean {
......@@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({
2943 entry.deleted = true;
3044 }
3145 }
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 }
3261 },
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" },
3372}));
3473
3574vi.mock("./uuid", () => ({
3675 uuidv4: () => "attachment-id",
3776}));
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
41131describe("native pasted image cleanup", () => {
42132 beforeEach(() => {
......@@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => {
91181 expect(files.get(overflow)?.deleted).toBe(true);
92182 expect(files.get(userOwned)?.deleted).toBe(false);
93183 });
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 });
94204});
95205
96206describe("composerStripAttachments", () => {
......@@ -119,21 +229,39 @@ describe("composerStripAttachments", () => {
119229 fileUri: "file:///notes.txt",
120230 };
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 () => {
123233 const { composerStripAttachments } = await import("./composerImages");
124 const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"]));
125 // A thumbnail is the only way to see media, so it stays regardless of the chip.
234 const kept = composerStripAttachments([image, video] as never);
126235 expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]);
127236 });
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 () => {
130239 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 ]);
132251 });
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 () => {
135254 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"]);
137265 });
138266});
139267
apps/mobile/src/lib/composerImages.ts+57-16
......@@ -8,6 +8,7 @@ import {
88 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
99 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1010 type EnvironmentId,
11 type PastedTextAttachmentSource,
1112 type UploadChatImageAttachment,
1213} from "@t3tools/contracts";
1314import type { DocumentPickerResult } from "expo-document-picker";
......@@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image";
2122import { videoMimeType } from "@t3tools/shared/video";
2223import { beginForegroundHandoff } from "./foreground-handoff";
2324import { uuidv4 } from "./uuid";
25import { writeFileAtomically } from "./atomic-file";
2426
2527export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> {
2628 readonly id: string;
......@@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment {
4042 readonly mimeType: string;
4143 readonly sizeBytes: number;
4244 readonly fileUri: string;
45 readonly source?: PastedTextAttachmentSource;
4346 readonly uploadedAttachmentId?: string;
4447 readonly uploadEnvironmentId?: EnvironmentId;
4548}
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
4779export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
4880
4981/**
50 * What the strip above the composer shows. Media previews there because a thumbnail is the
51 * only way to see it; every other file is already legible as its inline chip, so it only
52 * falls back to the strip when the prompt carries no reference to it. Mirrors web's
53 * `composerOtherFilesForPresentation`.
82 * What the strip above the composer shows: media, and nothing else. A thumbnail is the only
83 * way to see a picture or a video, so those always preview there. Everything else reads as
84 * its inline chip, which carries the name, the type and the size in the line of prose the
85 * file belongs to — a square tile showing a generic document glyph says strictly less.
86 *
87 * The chip is not optional for a non-media file. Every path that attaches one also writes
88 * its reference, so a file with no chip means the draft lost it rather than that the strip
89 * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces
90 * whose attachments never get chips (a question answer) pass them to the strip directly
91 * instead of through this filter.
5492 */
5593export function composerStripAttachments(
5694 attachments: ReadonlyArray<DraftComposerAttachment>,
57 inlineAttachmentIds: ReadonlySet<string>,
5895): ReadonlyArray<DraftComposerAttachment> {
5996 return attachments.filter(
60 (attachment) =>
61 isComposerImageAttachment(attachment) ||
62 videoMimeType(attachment) !== null ||
63 !inlineAttachmentIds.has(attachment.id),
97 (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null,
6498 );
6599}
66100
......@@ -539,11 +573,19 @@ export async function pickComposerMedia(input: {
539573 };
540574}
541575
542export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{
543 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
544 readonly text: string | null;
545 readonly error: string | null;
546}> {
576/** Clipboard images take priority over their alternate text representation. */
577export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<
578 | {
579 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
580 readonly text: null;
581 readonly error: string | null;
582 }
583 | {
584 readonly images: readonly [];
585 readonly text: string;
586 readonly error: null;
587 }
588> {
547589 let clipboard: Awaited<ReturnType<typeof loadClipboard>>;
548590 try {
549591 clipboard = await loadClipboard();
......@@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu
605647 const text = await clipboard.getStringAsync();
606648 return {
607649 images: [],
608 text: text.length > 0 ? text : null,
609 error: text.length > 0 ? null : "Clipboard is empty.",
650 ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }),
610651 };
611652 }
612653
apps/mobile/src/native/T3ComposerEditor.ios.tsx+47-2
......@@ -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";
13import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
24import { composerContextEditorTokens } from "../lib/composerContext";
35import { requireNativeView } from "expo";
......@@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{
5254 readonly uris: ReadonlyArray<string>;
5355}>;
5456
57type NativePasteTextEvent = NativeSyntheticEvent<{
58 readonly value: string;
59 readonly eventCount: number;
60 readonly text: string;
61 readonly selection: ComposerEditorSelection;
62}>;
63
5564interface NativeComposerEditorRef {
5665 focus: () => Promise<void>;
5766 blur: () => Promise<void>;
......@@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps {
8190 event: NativeSyntheticEvent<{ source: string; start: number; end: number }>,
8291 ) => void;
8392 readonly onComposerPasteContext?: (
84 event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>,
93 event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>,
8594 ) => void;
95 readonly textPasteThresholdBytes: number;
96 readonly maxInputChars: number;
97 readonly onComposerPasteText?: (event: NativePasteTextEvent) => void;
8698 readonly onComposerFocus?: () => void;
8799 readonly onComposerBlur?: () => void;
88100 readonly onComposerSubmit?: () => void;
......@@ -108,6 +120,7 @@ export function ComposerEditor({
108120 onChangeText,
109121 onSelectionChange,
110122 onPasteImages,
123 onPasteText,
111124 onFocus,
112125 onBlur,
113126 onSubmit,
......@@ -282,6 +295,8 @@ export function ComposerEditor({
282295 autoFocus={props.autoFocus ?? false}
283296 autoCorrect={props.autoCorrect ?? true}
284297 spellCheck={props.spellCheck ?? true}
298 textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0}
299 maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS}
285300 style={style as StyleProp<ViewStyle>}
286301 onComposerChange={(event) => {
287302 const acknowledgedEventCount = acceptNativeEvent(
......@@ -315,7 +330,36 @@ export function ComposerEditor({
315330 }}
316331 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
317332 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 }}
319363 onComposerFocus={onFocus}
320364 onComposerBlur={onBlur}
321365 onComposerSubmit={onSubmit}
......@@ -327,4 +371,5 @@ export type {
327371 ComposerEditorHandle,
328372 ComposerEditorProps,
329373 ComposerEditorSelection,
374 ComposerTextPaste,
330375} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.native.tsx+47-2
......@@ -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";
13import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
24import { composerContextEditorTokens } from "../lib/composerContext";
35import { requireNativeView } from "expo";
......@@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{
5557 readonly uris: ReadonlyArray<string>;
5658}>;
5759
60type NativePasteTextEvent = NativeSyntheticEvent<{
61 readonly value: string;
62 readonly eventCount: number;
63 readonly text: string;
64 readonly selection: ComposerEditorSelection;
65}>;
66
5867interface NativeComposerEditorRef {
5968 focus: () => Promise<void>;
6069 blur: () => Promise<void>;
......@@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps {
8594 event: NativeSyntheticEvent<{ source: string; start: number; end: number }>,
8695 ) => void;
8796 readonly onComposerPasteContext?: (
88 event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>,
97 event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>,
8998 ) => void;
99 readonly textPasteThresholdBytes: number;
100 readonly maxInputChars: number;
101 readonly onComposerPasteText?: (event: NativePasteTextEvent) => void;
90102 readonly onComposerFocus?: () => void;
91103 readonly onComposerBlur?: () => void;
92104}
......@@ -111,6 +123,7 @@ export function ComposerEditor({
111123 onChangeText,
112124 onSelectionChange,
113125 onPasteImages,
126 onPasteText,
114127 onFocus,
115128 onBlur,
116129 contentInsetVertical = 0,
......@@ -291,6 +304,8 @@ export function ComposerEditor({
291304 autoFocus={props.autoFocus ?? false}
292305 autoCorrect={props.autoCorrect ?? true}
293306 spellCheck={props.spellCheck ?? true}
307 textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0}
308 maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS}
294309 style={{ flex: 1, minHeight: 0 }}
295310 onComposerChange={(event) => {
296311 const acknowledgedEventCount = acceptNativeEvent(
......@@ -325,7 +340,36 @@ export function ComposerEditor({
325340 }}
326341 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
327342 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 }}
329373 onComposerFocus={onFocus}
330374 onComposerBlur={onBlur}
331375 />
......@@ -337,4 +381,5 @@ export type {
337381 ComposerEditorHandle,
338382 ComposerEditorProps,
339383 ComposerEditorSelection,
384 ComposerTextPaste,
340385} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.tsx+2
......@@ -12,6 +12,7 @@ export function ComposerEditor({
1212 skills: _skills,
1313 selection,
1414 onPasteImages,
15 onPasteText: _onPasteText,
1516 style,
1617 textStyle,
1718 contentInsetVertical = 0,
......@@ -65,4 +66,5 @@ export type {
6566 ComposerEditorHandle,
6667 ComposerEditorProps,
6768 ComposerEditorSelection,
69 ComposerTextPaste,
6870} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.types.ts+14-5
......@@ -7,6 +7,13 @@ export type ComposerEditorSelection = {
77 readonly end: number;
88};
99
10export type ComposerTextPaste = {
11 readonly value: string;
12 readonly eventCount: number;
13 readonly text: string;
14 readonly selection: ComposerEditorSelection;
15};
16
1017export interface ComposerEditorHandle {
1118 focus: () => void;
1219 blur: () => void;
......@@ -18,11 +25,12 @@ export interface ComposerEditorProps {
1825 readonly value: string;
1926 readonly context?: OrchestrationMessageContext;
2027 readonly clipboardFragment?: string;
21 readonly onPasteContext?: (clipboard: {
22 readonly text: string;
23 readonly fragment: string;
24 readonly html: string;
25 }) => void;
28 readonly onPasteContext?: (
29 clipboard: ComposerTextPaste & {
30 readonly fragment: string;
31 readonly html: string;
32 },
33 ) => void;
2634 readonly skills?: ReadonlyArray<
2735 Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> &
2836 Partial<Pick<ServerProviderSkill, "path">>
......@@ -50,6 +58,7 @@ export interface ComposerEditorProps {
5058 readonly start: number;
5159 readonly end: number;
5260 }) => void;
61 readonly onPasteText?: (paste: ComposerTextPaste) => void;
5362 readonly onFocus?: () => void;
5463 readonly onBlur?: () => void;
5564 /** 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", () => {
192192 );
193193 });
194194});
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", () => ({
148148}));
149149
150150import type { DraftComposerAttachment } from "../lib/composerImages";
151import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
151152import { appAtomRegistry } from "./atom-registry";
152153import { threadOutboxManager } from "./thread-outbox";
153154import {
154155 appendComposerDraftAttachments,
156 captureComposerDraftInsertion,
157 countComposerDraftAttachmentsAfterSelection,
158 getComposerDraftAfterSelection,
155159 archiveCloudComposerDrafts,
156160 clearComposerDraftContent,
157161 clearComposerDraftContentState,
......@@ -181,6 +185,7 @@ import {
181185 retargetNewTaskDraft,
182186 setComposerDraftText,
183187 insertComposerDraftContext,
188 insertComposerDraftText,
184189 rememberComposerDraftSelection,
185190 setComposerDraftAttachmentUpload,
186191 waitForComposerDraftsLoaded,
......@@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft {
233238}
234239
235240describe("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 });
236316 it.each([false, true])(
237317 "restores deleted file chips and releases undo history (uploaded: %s)",
238318 async (uploaded) => {
......@@ -303,6 +383,30 @@ describe("mobile composer drafts", () => {
303383 expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200);
304384 });
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
306410 it("drops chips and records for attachments a replace no longer keeps", () => {
307411 const key = "new-task:draft-1";
308412 const kept = {
......@@ -426,41 +530,57 @@ describe("mobile composer drafts", () => {
426530 expect(reloaded.cloudDrafts.signedOut).toEqual({});
427531 });
428532
429 it("removes a file only after its last reference is deleted, while retaining images", async () => {
430 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
431 onTestFinished(() => outboxLoad.mockRestore());
432 const cleanup = Promise.withResolvers<void>();
433 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
434 cleanup.resolve();
435 });
436 const key = "environment-1:remove-context-files";
437 const file = {
438 id: "file-1",
439 type: "file" as const,
440 name: "notes.txt",
441 mimeType: "text/plain",
442 sizeBytes: 4,
443 fileUri: "file:///notes.txt",
444 };
445 const image = {
446 ...file,
447 id: "image-1",
448 type: "image" as const,
449 name: "image.png",
450 mimeType: "image/png",
451 fileUri: "file:///image.png",
452 previewUri: "file:///image.png",
453 };
454 appendComposerDraftAttachments(key, [file, image], { appendReference: true });
455 const fileLink = "[notes.txt](t3-context://v1/file/file-1)";
456 setComposerDraftText(key, `${fileLink} ${fileLink}`);
457 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
458 setComposerDraftText(key, fileLink);
459 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
460 setComposerDraftText(key, "plain text");
461 expect(getComposerDraftSnapshot(key).attachments).toEqual([image]);
462 await cleanup.promise;
463 });
533 it.each([
534 { name: "notes.txt", mimeType: "text/plain" },
535 { name: "document-photo.png", mimeType: "image/png" },
536 { name: "recording.mp4", mimeType: "video/mp4" },
537 ])(
538 "removes $name after its last reference is deleted, while retaining native images",
539 async ({ name, mimeType }) => {
540 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
541 onTestFinished(() => outboxLoad.mockRestore());
542 const cleanup = Promise.withResolvers<void>();
543 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
544 cleanup.resolve();
545 });
546 const key = "environment-1:remove-context-files";
547 const file = {
548 id: "file-1",
549 type: "file" as const,
550 name,
551 mimeType,
552 sizeBytes: 4,
553 fileUri: `file:///${name}`,
554 };
555 const image = {
556 ...file,
557 id: "image-1",
558 type: "image" as const,
559 name: "image.png",
560 mimeType: "image/png",
561 fileUri: "file:///image.png",
562 previewUri: "file:///image.png",
563 };
564 appendComposerDraftAttachments(key, [file, image], { appendReference: true });
565 const fileLink = formatComposerContextReference(
566 getComposerDraftSnapshot(key).context!.records[0]!,
567 );
568 setComposerDraftText(key, `${fileLink} ${fileLink}`);
569 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
570 setComposerDraftText(key, fileLink);
571 expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]);
572 expect(
573 countComposerDraftAttachmentsAfterSelection(key, {
574 text: fileLink,
575 start: 0,
576 end: fileLink.length,
577 }),
578 ).toBe(1);
579 setComposerDraftText(key, "plain text");
580 expect(getComposerDraftSnapshot(key).attachments).toEqual([image]);
581 await cleanup.promise;
582 },
583 );
464584
465585 it("rejects attachments atomically when no context slots remain", async () => {
466586 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
......@@ -506,6 +626,190 @@ describe("mobile composer drafts", () => {
506626 expect(getComposerDraftSnapshot(key)).toEqual(before);
507627 await cleanup.promise;
508628 });
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
509813 it("inserts context at the saved caret and retains its payload through persistence and restore", () => {
510814 const draftKey = "context-environment:context-thread";
511815 const record = {
......@@ -564,7 +868,25 @@ describe("mobile composer drafts", () => {
564868 },
565869 }).drafts,
566870 ).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 },
568890 });
569891 });
570892
......@@ -882,8 +1204,34 @@ describe("mobile composer drafts", () => {
8821204 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
8831205 onTestFinished(() => enqueue.mockRestore());
8841206 await restoreCloudComposerDrafts("account-a");
885 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });
886 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");
1207 expect(getComposerDraftSnapshot(key)).toEqual(
1208 type === "image"
1209 ? { text: "Unsent notes", attachments: [file] }
1210 : {
1211 text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ",
1212 attachments: [file],
1213 context: {
1214 version: 1,
1215 records: [
1216 {
1217 version: 1,
1218 contextId: file.id,
1219 kind: "file",
1220 label: file.name,
1221 attachmentId: file.id,
1222 name: file.name,
1223 mimeType: file.mimeType,
1224 sizeBytes: file.sizeBytes,
1225 },
1226 ],
1227 },
1228 },
1229 );
1230 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe(
1231 type === "image"
1232 ? "Edited queued task"
1233 : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ",
1234 );
8871235 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
8881236 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
8891237 const persisted = decodePersistedComposerState(
......@@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => {
23142662 await fresh.releaseUnusedComposerAttachmentFiles([file]);
23152663
23162664 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 },
23182684 });
23192685 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
23202686 });
apps/mobile/src/state/use-composer-drafts.ts+213-51
......@@ -24,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity";
2424import { writeFileAtomically } from "../lib/atomic-file";
2525import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext";
2626import {
27 collectComposerContextReferences,
2728 formatComposerContextReference,
2829 sanitizeComposerContextLabel,
2930 replaceComposerContextReferences,
3031} from "@t3tools/shared/composerContextReferences";
3132import { imageMimeType } from "@t3tools/shared/image";
33import { videoMimeType } from "@t3tools/shared/video";
3234import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
3335import {
3436 composerAttachmentFileReferenceKey,
......@@ -95,6 +97,89 @@ export function readComposerDraftSelection(
9597 return { start: lastComposerSelection.start, end: lastComposerSelection.end };
9698}
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
98183/** Retains file bytes while native text undo can restore their references. */
99184export function createComposerDraftContextHistory() {
100185 const restoreContext = createComposerContextHistory();
......@@ -162,16 +247,36 @@ export function setComposerDraftContext(
162247
163248export function insertComposerDraftContext(
164249 draftKey: string,
165 content: { text: string; context: OrchestrationMessageContext },
250 content: {
251 text: string;
252 context: OrchestrationMessageContext;
253 attachments?: ReadonlyArray<DraftComposerAttachment>;
254 },
255 target?: ComposerDraftInsertion,
166256): boolean {
167257 let inserted = false;
258 let removed: ReadonlyArray<DraftComposerAttachment> = [];
168259 updateComposerDrafts((current) => {
169260 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 );
171274 if (!nextDraft) return current;
172275 inserted = true;
276 removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment));
173277 return { ...current, [draftKey]: nextDraft };
174278 });
279 scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? []));
175280 return inserted;
176281}
177282
......@@ -179,13 +284,9 @@ function draftWithInsertedContext(
179284 draftKey: string,
180285 draft: ComposerDraft,
181286 content: { text: string; context: OrchestrationMessageContext },
287 target?: ComposerDraftInsertion,
182288): ComposerDraft | null {
183 const selection =
184 lastComposerSelection?.draftKey === draftKey && lastComposerSelection.text === draft.text
185 ? lastComposerSelection
186 : null;
187 const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length));
188 const end = Math.max(start, Math.min(selection?.end ?? start, draft.text.length));
289 const { start, end } = contextInsertionRange(draftKey, draft, target);
189290 const before = draft.text.slice(0, start);
190291 const after = draft.text.slice(end);
191292 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(
200301 start: start + insertion.length,
201302 end: start + insertion.length,
202303 };
203 return { ...draft, text, context };
304 return withReferencedContextFiles(draft, text, context);
204305}
205306
206307export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()(
......@@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void {
352453 persistRetryNeeded = false;
353454}
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
355518function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
356519 if (!draft) {
357520 return EMPTY_DRAFT;
......@@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft(
421584 draft: ComposerDraft,
422585 now: string,
423586): readonly [key: string, draft: ComposerDraft] {
587 const restored = restoreMissingComposerFileReferences(draft);
424588 const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
425589 if (legacy === null) {
426 return [key, draft];
590 return [key, restored];
427591 }
428592 return [
429593 newTaskDraftKey(newDraftId()),
430594 {
431 ...draft,
595 ...restored,
432596 project: {
433597 environmentId: EnvironmentIdSchema.make(legacy.environmentId),
434598 projectId: ProjectIdSchema.make(legacy.projectId),
......@@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void {
10891253 updateComposerDrafts((current) => {
10901254 const existing = normalizeDraft(current[draftKey]);
10911255 const context = referencedComposerContext(value, existing.context);
1092 const retainedIds = new Set(context?.records.map((record) => record.contextId));
1093 const removedFileIds = new Set(
1094 existing.context?.records.flatMap((record) =>
1095 record.kind === "file" && "attachmentId" in record && !retainedIds.has(record.contextId)
1096 ? [record.attachmentId]
1097 : [],
1098 ),
1099 );
1100 removed = existing.attachments.filter(
1101 (attachment) => attachment.type !== "image" && removedFileIds.has(attachment.id),
1102 );
1103 const draft = {
1104 ...existing,
1105 text: value,
1106 context,
1107 attachments: existing.attachments.filter((attachment) => !removed.includes(attachment)),
1108 };
1256 const draft = withReferencedContextFiles(existing, value, context);
1257 removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment));
11091258 return withComposerDraft(current, draftKey, draft);
11101259 });
11111260 scheduleUnusedComposerAttachmentCleanup(removed);
11121261}
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
11141278export function appendComposerDraftText(draftKey: string, value: string): void {
11151279 updateComposerDrafts((current) => {
11161280 const existing = normalizeDraft(current[draftKey]);
......@@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments(
11371301 options?: {
11381302 readonly allowOverflow?: boolean;
11391303 readonly appendReference?: boolean;
1304 readonly insertion?: ComposerDraftInsertion;
11401305 readonly maxAttachments?: number;
11411306 },
11421307): number {
......@@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments(
11441309 return 0;
11451310 }
11461311 let rejected: ReadonlyArray<DraftComposerAttachment> = [];
1312 let removed: ReadonlyArray<DraftComposerAttachment> = [];
11471313 updateComposerDrafts((current) => {
11481314 const existing = normalizeDraft(current[draftKey]);
1315 const retained = options?.appendReference
1316 ? draftWithoutInsertionSelection(draftKey, existing, options.insertion)
1317 : existing;
11491318 const remaining = options?.allowOverflow
11501319 ? attachments.length
11511320 : Math.max(
......@@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments(
11531322 Math.min(
11541323 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
11551324 options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1156 ) - existing.attachments.length,
1325 ) - retained.attachments.length,
11571326 );
11581327 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))
11601329 : attachments.length;
11611330 const accepted = attachments.slice(0, Math.min(remaining, contextCapacity));
11621331 rejected = attachments.slice(accepted.length);
......@@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments(
11651334 }
11661335 let draft = { ...existing, attachments: [...existing.attachments, ...accepted] };
11671336 if (options?.appendReference) {
1168 const records = accepted.map((attachment) => {
1169 const common = {
1170 version: 1 as const,
1171 contextId: ComposerContextId.make(attachment.id),
1172 label: sanitizeComposerContextLabel(attachment.name, attachment.type),
1173 attachmentId: attachment.id,
1174 name: attachment.name,
1175 mimeType: attachment.mimeType,
1176 sizeBytes: attachment.sizeBytes,
1177 };
1178 // A picture picked through the document picker is typed as a plain file, but the
1179 // record has to say what it is or no client will offer to open it as an image.
1180 return attachment.type === "image" || imageMimeType(attachment) !== null
1181 ? { ...common, kind: "image" as const }
1182 : { ...common, kind: "file" as const };
1183 });
1184 const inserted = draftWithInsertedContext(draftKey, draft, {
1185 text: records.map(formatComposerContextReference).join(" "),
1186 context: { version: 1, records },
1187 });
1337 const records = accepted.map((attachment) => attachmentContextRecord(attachment));
1338 const inserted = draftWithInsertedContext(
1339 draftKey,
1340 draft,
1341 {
1342 text: records.map(formatComposerContextReference).join(" "),
1343 context: { version: 1, records },
1344 },
1345 options?.insertion,
1346 );
11881347 if (!inserted) {
11891348 rejected = attachments;
11901349 return current;
11911350 }
11921351 draft = { ...inserted, attachments: [...inserted.attachments] };
1352 removed = existing.attachments.filter(
1353 (attachment) => !draft.attachments.includes(attachment),
1354 );
11931355 }
11941356 return {
11951357 ...current,
11961358 [draftKey]: draft,
11971359 };
11981360 });
1199 scheduleUnusedComposerAttachmentCleanup(rejected);
1361 scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]);
12001362 return rejected.length;
12011363}
12021364
apps/mobile/src/state/use-thread-composer-state.ts+161-9
......@@ -1,5 +1,6 @@
1import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
12import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
34import { Alert } from "react-native";
45
56import {
......@@ -7,6 +8,7 @@ import {
78 DEFAULT_PROVIDER_INTERACTION_MODE,
89 MessageId,
910 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
11 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1012 type EnvironmentId,
1113 type ModelSelection,
1214 type ProviderInteractionMode,
......@@ -14,6 +16,8 @@ import {
1416 type ThreadId,
1517} from "@t3tools/contracts";
1618import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
19import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
20import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste";
1721import {
1822 parseCodexFeedbackCommand,
1923 submitCodexFeedback,
......@@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions";
2933import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
3034import {
3135 convertPastedImagesToAttachments,
36 createPastedTextComposerAttachment,
3237 pasteComposerClipboard,
3338 pickComposerFiles,
3439 pickComposerMedia,
40 removePersistedComposerAttachmentFile,
3541} from "../lib/composerImages";
3642import type { DraftComposerImageAttachment } from "../lib/composerImages";
3743import { scopedThreadKey } from "../lib/scopedEntities";
......@@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry";
4248import { pendingThreadCreationMessage } from "./pending-thread-creation";
4349import {
4450 appendComposerDraftAttachments,
45 appendComposerDraftText,
51 captureComposerDraftInsertion,
52 countComposerDraftAttachmentsAfterSelection,
53 insertComposerDraftText,
4654 insertComposerDraftContext,
4755 clearComposerDraftContent,
4856 composerDraftsAtom,
......@@ -132,6 +140,23 @@ export function useThreadComposerState() {
132140 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
133141 reportFailure: false,
134142 });
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
136161 useEffect(() => {
137162 ensureComposerDraftsLoaded();
......@@ -479,9 +504,10 @@ export function useThreadComposerState() {
479504 }
480505
481506 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
507 const insertion = captureComposerDraftInsertion(threadKey);
482508 const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities;
483509 const result = await pickComposerMedia({
484 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
510 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
485511 maxVideoBytes:
486512 capabilities?.attachmentUploads === true
487513 ? capabilities.fileAttachments?.maxUploadBytes
......@@ -489,6 +515,7 @@ export function useThreadComposerState() {
489515 });
490516 const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, {
491517 appendReference: true,
518 insertion,
492519 });
493520 const problems = [
494521 ...(result.error ? [result.error] : []),
......@@ -514,13 +541,15 @@ export function useThreadComposerState() {
514541 }
515542
516543 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
544 const insertion = captureComposerDraftInsertion(threadKey);
517545 // pickComposerFiles clamps the advertised limit to the contract maximum.
518546 const result = await pickComposerFiles({
519 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
547 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
520548 maxBytes,
521549 });
522550 const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, {
523551 appendReference: true,
552 insertion,
524553 });
525554 // The picker error and the live-cap rejection can both happen in one
526555 // pick; report both in a single alert.
......@@ -541,14 +570,81 @@ export function useThreadComposerState() {
541570 }
542571
543572 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
573 const insertion = captureComposerDraftInsertion(threadKey);
544574 const result = await pasteComposerClipboard({
545 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
575 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
546576 });
547577 const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, {
548578 appendReference: true,
579 insertion,
549580 });
550581 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 }
552648 }
553649 if (result.error) {
554650 setPendingConnectionError(result.error);
......@@ -557,7 +653,12 @@ export function useThreadComposerState() {
557653 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
558654 );
559655 }
560 }, [composerDrafts, selectedThreadShell]);
656 }, [
657 composerDrafts,
658 reservePastedTextFileName,
659 selectedEnvironmentRuntime?.serverConfig,
660 selectedThreadShell,
661 ]);
561662
562663 const onNativePasteImages = useCallback(
563664 async (uris: ReadonlyArray<string>) => {
......@@ -566,13 +667,14 @@ export function useThreadComposerState() {
566667 }
567668
568669 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
670 const insertion = captureComposerDraftInsertion(threadKey);
569671 try {
570672 const images = await convertPastedImagesToAttachments({
571673 uris,
572 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
674 existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion),
573675 });
574676 if (images.length > 0) {
575 appendComposerDraftAttachments(threadKey, images, { appendReference: true });
677 appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion });
576678 }
577679 } catch (error) {
578680 console.error("[native paste] error converting images", {
......@@ -586,6 +688,55 @@ export function useThreadComposerState() {
586688 [composerDrafts, selectedThreadShell],
587689 );
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
589740 const onRemoveDraftImage = useCallback(
590741 (imageId: string) => {
591742 if (!selectedThreadShell) {
......@@ -663,6 +814,7 @@ export function useThreadComposerState() {
663814 onPickDraftFiles,
664815 onPasteIntoDraft,
665816 onNativePasteImages,
817 onNativePasteText,
666818 onRemoveDraftImage,
667819 onSendMessage,
668820 onUpdateModelSelection,
apps/server/src/provider/Layers/ProviderService.test.ts+46
......@@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => {
22282228 assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: ');
22292229 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
22312250 yield* provider.stopSession({ threadId: session.threadId });
22322251 }),
22332252 );
......@@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => {
46834702
46844703const validation = makeProviderServiceLayer();
46854704validation.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
46864732 it.effect("rejects citation-expanded input over the provider character limit", () =>
46874733 Effect.gen(function* () {
46884734 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+22-7
......@@ -1593,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
15931593
15941594 // Every attachment gets an on-disk path in the prompt so the model's tools
15951595 // can dereference the actual file. All attachments then go to the adapter,
1596 // and each adapter decides what its provider ingests natively: OpenCode
1597 // sends generic files as file parts, the others send images only and rely
1598 // on the path line for everything else. Unresolvable ids are skipped here
1599 // and surface as adapter errors when the file is read.
1596 // and each adapter decides what its provider ingests natively. Folded
1597 // clipboard text remains path-only everywhere: eagerly embedding it would
1598 // spend the same context the client deliberately preserved by folding it.
1599 // Unresolvable ids are skipped here and surface as adapter errors when the
1600 // file is read.
16001601 let inputTextWithAttachmentContext = inputTextWithCitations;
16011602 const appendAttachmentContext = (context: string | undefined) => {
1602 if (context === undefined) return;
1603 if (context === undefined) return true;
16031604 const candidate = inputTextWithAttachmentContext
16041605 ? `${inputTextWithAttachmentContext}\n\n${context}`
16051606 : context;
16061607 if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) {
16071608 inputTextWithAttachmentContext = candidate;
1609 return true;
16081610 }
1611 return false;
16091612 };
16101613 for (const attachment of attachments) {
16111614 const attachmentPath = resolveAttachmentPath({
16121615 attachmentsDir: serverConfig.attachmentsDir,
16131616 attachment,
16141617 });
1615 appendAttachmentContext(
1618 const isPastedText =
1619 attachment.type === "file" &&
1620 "source" in attachment &&
1621 attachment.source?._tag === "pasted-text";
1622 const appended = appendAttachmentContext(
16161623 attachmentPath === null
16171624 ? 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}]`,
16191628 );
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 }
16201635 }
16211636 for (const attachment of attachments) {
16221637 const source =
apps/server/src/provider/acp/AntigravityAcpSupport.test.ts+45
......@@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
279279 }),
280280 );
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
282327 it.effect("sends supported audio files as native audio content", () =>
283328 Effect.gen(function* () {
284329 const fixture = yield* makeAttachmentFixture();
apps/server/src/provider/acp/AntigravityAcpSupport.ts+15
......@@ -264,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi
264264 let totalBytes = 0;
265265
266266 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.
267274 const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? "";
268275 const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType);
269276 const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType);
......@@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi
296303 ),
297304 ),
298305 );
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 }
299314 const size = Number(info.size);
300315 const limit = image
301316 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+14
......@@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => {
318318 ["application/pdf", "text/markdown", "image/png"],
319319 );
320320 });
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 });
321335});
apps/server/src/provider/opencodeRuntime.ts+7
......@@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: {
462462 const parts: Array<FilePartInput> = [];
463463
464464 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 }
465472 if (!isOpenCodeNativeFilePart(attachment)) {
466473 continue;
467474 }
apps/web/src/components/ChatView.tsx+25-6
......@@ -46,6 +46,8 @@ import {
4646} from "@t3tools/contracts";
4747import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
4848import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
49import { readPastedComposerContext } from "./composerInlineTokenPaste";
50import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
4951import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
5052import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
5153import {
......@@ -95,6 +97,7 @@ import { flushSync } from "react-dom";
9597import { useLocation, useNavigate } from "@tanstack/react-router";
9698import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
9799import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
100import { isMacPlatform } from "../lib/utils";
98101import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
99102import { useShallow } from "zustand/react/shallow";
100103import {
......@@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) {
16081611 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
16091612 const localComposerRef = useRef<ChatComposerHandle | null>(null);
16101613 const composerRef = useComposerHandleContext() ?? localComposerRef;
1614 const pasteAsTextShortcutUntilRef = useRef(0);
16111615 const [restingComposerControlsHost, setRestingComposerControlsHost] =
16121616 useState<HTMLDivElement | null>(null);
16131617 const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
......@@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) {
65196523 // so a paste that follows has no editable target and would be dropped.
65206524 // Route it to the composer like a typed key, which also expands it.
65216525 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 };
65226534 const handler = (event: ClipboardEvent) => {
65236535 if (!activeThreadId || isCommandPaletteOpen()) return;
65246536 if (getTerminalFocusOwner() !== null) return;
65256537 if (composerRef.current?.isModelPickerOpen()) return;
65266538 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;
65286543 if (
6529 composerRef.current?.insertTextAtEnd(
6530 text,
6531 event.clipboardData ? { clipboardData: event.clipboardData } : undefined,
6532 )
6544 ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 &&
6545 composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) ||
6546 composerRef.current?.insertTextAtEnd(text, { clipboardData })
65336547 ) {
65346548 event.preventDefault();
65356549 event.stopPropagation();
65366550 }
65376551 };
6552 window.addEventListener("keydown", keyHandler, true);
65386553 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 };
65406558 }, [activeThreadId, composerRef]);
65416559
65426560 const [pendingRevert, setPendingRevert] = useState<{
......@@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) {
72577275 mimeType: attachment.mimeType,
72587276 sizeBytes: attachment.sizeBytes,
72597277 downloadable: false,
7278 ...(attachment.source ? { source: attachment.source } : {}),
72607279 },
72617280 );
72627281 const shouldAnchorFirstMessage =
apps/web/src/components/ComposerPromptEditor.test.ts+96-84
......@@ -18,6 +18,7 @@ import {
1818
1919import {
2020 importPastedComposerText,
21 readPastedComposerContext,
2122 registerComposerInlineTokenPaste,
2223} from "./composerInlineTokenPaste";
2324import {
......@@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => {
498499});
499500
500501describe("context reference paste", () => {
501 it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => {
502 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
503 const editor = createEditor({ nodes: [ComposerCitationNode] });
504 editor.update(
505 () => {
506 const paragraph = $createParagraphNode();
507 $getRoot().append(paragraph);
508 paragraph.selectEnd();
509 },
510 { discrete: true },
511 );
512 const imported: string[] = [];
513 const importFragment = (
514 fragment: import("@t3tools/contracts").ComposerContextClipboardFragment,
515 ) => {
516 imported.push(...fragment.records.map((record) => record.contextId));
517 return new Map([["img-old", "img-new"]]);
518 };
519 registerComposerInlineTokenPaste(editor, {
520 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
521 createCitationNode: $createComposerCitationNode,
522 createContextReferenceNode: (reference) =>
523 $createTextNode(`<context:${reference.contextId}>`),
524 getExpandedAbsoluteOffsetForPoint: () => 0,
525 importContextFragment: importFragment,
526 });
527 const event = new TestClipboardEvent(
528 "![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)",
529 {
530 "web application/x-t3-context-fragment+json": JSON.stringify({
531 version: 1,
532 source: { environmentId: "env-1" },
533 records: [
534 {
535 version: 1,
536 contextId: "img-old",
537 kind: "image",
538 label: "shot",
539 attachmentId: "a",
540 name: "shot.png",
541 mimeType: "image/png",
542 sizeBytes: 1,
543 },
544 {
545 version: 1,
546 contextId: "ctx-t",
547 kind: "terminal",
548 label: "T",
549 terminalId: "t",
550 terminalLabel: "T",
551 lineStart: 1,
552 lineEnd: 1,
553 text: "x",
554 },
555 {
556 version: 1,
557 contextId: "img-unrelated",
558 kind: "image",
559 label: "other",
560 attachmentId: "b",
561 name: "other.png",
562 mimeType: "image/png",
563 sizeBytes: 1,
564 },
565 ],
566 }),
567 },
568 );
569 if (focus === "blurred") {
570 expect(importPastedComposerText(event.clipboardData, importFragment)).toBe(
571 "![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)",
502 it.each([
503 { focus: "focused", prefix: "" },
504 { focus: "blurred", prefix: "" },
505 { focus: "focused", prefix: "log ".repeat(10_000) },
506 { focus: "blurred", prefix: "log ".repeat(10_000) },
507 ])(
508 "imports structured paste when $focus with $prefix.length extra characters",
509 ({ focus, prefix }) => {
510 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
511 const editor = createEditor({ nodes: [ComposerCitationNode] });
512 editor.update(
513 () => {
514 const paragraph = $createParagraphNode();
515 $getRoot().append(paragraph);
516 paragraph.selectEnd();
517 },
518 { discrete: true },
519 );
520 const imported: string[] = [];
521 const importFragment = (
522 fragment: import("@t3tools/contracts").ComposerContextClipboardFragment,
523 ) => {
524 imported.push(...fragment.records.map((record) => record.contextId));
525 return new Map([["img-old", "img-new"]]);
526 };
527 registerComposerInlineTokenPaste(editor, {
528 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
529 createCitationNode: $createComposerCitationNode,
530 createContextReferenceNode: (reference) =>
531 $createTextNode(`<context:${reference.contextId}>`),
532 getExpandedAbsoluteOffsetForPoint: () => 0,
533 importContextFragment: importFragment,
534 });
535 const event = new TestClipboardEvent(
536 `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`,
537 {
538 "web application/x-t3-context-fragment+json": JSON.stringify({
539 version: 1,
540 source: { environmentId: "env-1" },
541 records: [
542 {
543 version: 1,
544 contextId: "img-old",
545 kind: "image",
546 label: "shot",
547 attachmentId: "a",
548 name: "shot.png",
549 mimeType: "image/png",
550 sizeBytes: 1,
551 },
552 {
553 version: 1,
554 contextId: "ctx-t",
555 kind: "terminal",
556 label: "T",
557 terminalId: "t",
558 terminalLabel: "T",
559 lineStart: 1,
560 lineEnd: 1,
561 text: "x",
562 },
563 {
564 version: 1,
565 contextId: "img-unrelated",
566 kind: "image",
567 label: "other",
568 attachmentId: "b",
569 name: "other.png",
570 mimeType: "image/png",
571 sizeBytes: 1,
572 },
573 ],
574 }),
575 },
576 );
577 expect(
578 readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId),
579 ).toEqual(["img-old", "ctx-t"]);
580 if (focus === "blurred") {
581 expect(importPastedComposerText(event.clipboardData, importFragment)).toBe(
582 `${prefix}![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 },
572592 );
573593 expect(imported).toEqual(["img-old", "ctx-t"]);
574 return;
575 }
576 editor.update(
577 () => {
578 editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
579 },
580 { discrete: true },
581 );
582 expect(imported).toEqual(["img-old", "ctx-t"]);
583 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
584 "<context:img-new> and <context:ctx-t>",
585 );
586 });
594 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
595 `${prefix}<context:img-new> and <context:ctx-t>`,
596 );
597 },
598 );
587599
588600 it("converts a copied legacy element into a sendable annotation and rewrites its link", () => {
589601 const copied = upgradeLegacyContextMessage(
apps/web/src/components/ComposerPromptEditor.tsx+6-1
......@@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle {
838838 focus: () => void;
839839 focusAt: (cursor: number) => void;
840840 focusAtEnd: () => void;
841 readSelectionRange: () => { start: number; end: number };
841842 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
842843 readSnapshot: () => {
843844 value: string;
......@@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({
18541855 ),
18551856 );
18561857 },
1858 readSelectionRange: () => {
1859 readSnapshot();
1860 return selectionRangeRef.current;
1861 },
18571862 requestCitationComment: (request) => {
18581863 citationCommentRequestRef.current = request;
18591864 const target = editor
......@@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({
20212026 }}
20222027 onKeyUp={(event) => onPageScrollKeyUp?.(event.key)}
20232028 onBlur={onPageScrollRelease}
2024 onPaste={onPaste}
2029 onPasteCapture={onPaste}
20252030 />
20262031 }
20272032 placeholder={
apps/web/src/components/chat/ChatComposer.tsx+226-35
......@@ -1,13 +1,15 @@
1import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
12import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
23import { useRightPanelStore } from "~/rightPanelStore";
34import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
45import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
56import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
6import { importPastedComposerText } from "../composerInlineTokenPaste";
7import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
78import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
89import { RefreshIcon } from "~/components/ui/refresh-icon";
910import {
1011 questionAttachmentDraftId,
12 countQuestionAttachments,
1113 useQuestionAttachmentPreparation,
1214 changeQuestionAttachmentPreparation,
1315} from "../../questionAttachments";
......@@ -34,8 +36,15 @@ import {
3436 ProviderInstanceId,
3537 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
3638 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
39 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3740} from "@t3tools/contracts";
3841import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
42import {
43 isPasteAsTextShortcut,
44 nextPastedTextFileName,
45 pastedTextDisposition,
46 wouldTextPasteExceedLimit,
47} from "@t3tools/client-runtime/text-paste";
3948import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
4049import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
4150import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
......@@ -194,6 +203,7 @@ import {
194203 ensureInlineContextReferences,
195204 formatInlineContextReference,
196205 insertInlineContextReference,
206 inlineContextReferenceReplacement,
197207 toKindScopedComposerContextId,
198208} from "~/lib/composerContextReferences";
199209import {
......@@ -284,7 +294,7 @@ import {
284294} from "../../lib/snapShotAnimation";
285295import { resizeSnapShotSource } from "../../lib/snapShotSource";
286296import { basenameOfPath } from "../../pierre-icons";
287import { cn, randomUUID } from "~/lib/utils";
297import { cn, isMacPlatform, randomUUID } from "~/lib/utils";
288298import {
289299 getComposerPromptLengthValidationMessage,
290300 getComposerSubmissionValidationMessage,
......@@ -1212,6 +1222,8 @@ export interface ChatComposerHandle {
12121222 text: string,
12131223 options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer },
12141224 ) => boolean;
1225 /** Apply large-paste folding for text redirected from a blurred composer. */
1226 pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean;
12151227 citeAssistantText: (
12161228 citation: AssistantCitation,
12171229 sourceAnchor: AssistantCitationSourceAnchor,
......@@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20642076 // Refs
20652077 // ------------------------------------------------------------------
20662078 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 });
20672084 const attachmentInputRef = useRef<HTMLInputElement>(null);
20682085 const composerFormRef = useRef<HTMLFormElement>(null);
20692086 const composerFooterControlsRef = useRef<HTMLDivElement>(null);
......@@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20992116 const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
21002117 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
21022162 // ------------------------------------------------------------------
21032163 // Derived: composer send state
21042164 // ------------------------------------------------------------------
......@@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40414101 mimeType: file.mimeType,
40424102 sizeBytes: file.sizeBytes,
40434103 file: null,
4104 ...(file.source ? { source: file.source } : {}),
40444105 // An expired upload carries no ids, so it hydrates as a
40454106 // needs-reattach row and the "Attach again" flow takes over.
40464107 ...(expired
......@@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43194380 sizeBytes: file.sizeBytes,
43204381 attachmentId: upload.attachmentId,
43214382 environmentId,
4383 ...(file.source ? { source: file.source } : {}),
43224384 });
43234385 }
43244386 // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
......@@ -5032,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
50325094 // ------------------------------------------------------------------
50335095 // Callbacks: attachments
50345096 // ------------------------------------------------------------------
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 };
50355119 /** 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> => {
50375127 if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false;
50385128 if (
50395129 pendingUserInputs.length > 0 &&
......@@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
50565146 // accepted files reserve their attachment slots (via the pending counter)
50575147 // before the first await, keeping the total under the limit.
50585148 const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0;
5059 const otherQuestionAttachments =
5060 questionAttachmentTarget && pendingUserInputs[0]
5061 ? pendingUserInputs[0].questions.reduce((count, question) => {
5062 const target = questionAttachmentDraftId(
5063 environmentId,
5064 threadId,
5065 pendingUserInputs[0]!.requestId,
5066 question.id,
5067 );
5068 if (target === questionAttachmentTarget) return count;
5069 const draft = getComposerDraft(target);
5070 return (
5071 count +
5072 (draft?.images.length ?? 0) +
5073 (draft?.files.length ?? 0) +
5074 (useQuestionAttachmentPreparation.getState().counts[target] ?? 0)
5075 );
5076 }, 0)
5077 : 0;
5078 let reservedCount =
5079 composerImagesRef.current.length +
5080 composerFilesRef.current.length +
5081 pendingCount +
5082 otherQuestionAttachments;
5149 let reservedCount = countReservedAttachments();
50835150 // A pick that matches a needs-reattach marker replaces it in the draft, so
50845151 // it must not consume a slot; a draft full of markers would otherwise hit
50855152 // the capacity error before the replacement path could run.
......@@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51465213 mimeType: fileMimeType,
51475214 sizeBytes: attachmentFile.size,
51485215 file: attachmentFile,
5216 ...(options?.source ? { source: options.source } : {}),
51495217 });
51505218 }
51515219 if (!matchingReattachMarker) {
......@@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51605228 const storedIds = new Set(addComposerFilesToDraft(acceptedFiles));
51615229 const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id));
51625230 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 });
51645246 }
51655247 }
51665248 if (acceptedImages.length === 0) return insertedAny;
......@@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52465328 */
52475329 const insertAttachmentReferences = (
52485330 references: ReadonlyArray<ComposerContextReference>,
5331 selection?: { start: number; end: number },
52495332 ): boolean => {
52505333 if (references.length === 0) return false;
52515334 // Question answers carry attachments beside the answer, never as chips. Falling back to
52525335 // the thread prompt here would hide the file behind a reference the question never shows.
52535336 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 }
52545341 const text = references.map(formatInlineContextReference).join(" ");
52555342 const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true });
52565343 if (!inserted) {
......@@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52845371 // ------------------------------------------------------------------
52855372 // Callbacks: paste / drag
52865373 // ------------------------------------------------------------------
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
52875450 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
52885451 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;
52895455 // Claimable pastes go through even when agent questions are pending or the
52905456 // composer is at its attachment limit: `addComposerAttachments` surfaces
52915457 // those as a toast and a thread error. An early return here would swallow
52925458 // the paste with no feedback.
52935459 if (
5294 files.length === 0 ||
5295 !activeThreadId ||
5296 !shouldHandleComposerAttachmentPaste({
5297 files,
5298 plainText: event.clipboardData.getData("text/plain"),
5299 })
5460 files.length > 0 &&
5461 activeThreadId &&
5462 shouldHandleComposerAttachmentPaste({ files, plainText })
53005463 ) {
5464 event.preventDefault();
5465 event.stopPropagation();
5466 void addComposerAttachments(files);
53015467 return;
53025468 }
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
53035476 event.preventDefault();
5304 void addComposerAttachments(files);
5477 event.stopPropagation();
53055478 };
53065479
53075480 const insertComposerText = useCallback(
......@@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
55265699 hasPendingAttachments: () =>
55275700 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
55285701 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 },
55295719 citeAssistantText: (citation, sourceAnchor) =>
55305720 insertComposerText(
55315721 formatAssistantCitationForComposer(citation, citation.comment),
......@@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
56265816 [
56275817 activeThread,
56285818 addComposerAttachments,
5819 foldPastedText,
56295820 composerDraftTarget,
56305821 composerCursor,
56315822 composerTerminalContexts,
apps/web/src/components/composerInlineTokenPaste.ts+28-25
......@@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste(
144144 );
145145}
146146
147/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */
148export function importPastedComposerText(
147/** Clipboard records referenced by the copied text, including dependent screenshots. */
148export function readPastedComposerContext(
149149 clipboardData: Pick<DataTransfer, "getData">,
150 importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"],
151): string {
150): ComposerContextClipboardFragment | null {
152151 const pastedText = clipboardData.getData("text/plain");
153152 // Only records whose links are in the pasted text get imported; a fragment may carry
154153 // more (it was built for a larger copy) and must not start transfers for those.
155 const decodedFragment = importContextFragment
156 ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ??
157 decodeComposerContextClipboardHtml(clipboardData.getData("text/html")))
158 : null;
154 const decodedFragment =
155 decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ??
156 decodeComposerContextClipboardHtml(clipboardData.getData("text/html"));
157 if (decodedFragment === null) return null;
159158 const pastedIds = new Set<string>(
160159 collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId),
161160 );
162 if (decodedFragment) {
163 for (const record of decodedFragment.records) {
164 if (
165 record.kind === "preview-annotation" &&
166 !("payload" in record) &&
167 pastedIds.has(record.contextId) &&
168 record.screenshotContextId
169 ) {
170 pastedIds.add(record.screenshotContextId);
171 }
161 for (const record of decodedFragment.records) {
162 if (
163 record.kind === "preview-annotation" &&
164 !("payload" in record) &&
165 pastedIds.has(record.contextId) &&
166 record.screenshotContextId
167 ) {
168 pastedIds.add(record.screenshotContextId);
172169 }
173170 }
174 const fragment =
175 decodedFragment === null
176 ? null
177 : {
178 ...decodedFragment,
179 records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)),
180 };
171 return {
172 ...decodedFragment,
173 records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)),
174 };
175}
176
177/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */
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;
181184 const rewrittenIds =
182185 fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null;
183186 const text =
apps/web/src/components/contextChipParts.tsx+23-22
......@@ -119,29 +119,30 @@ export function PullRequestChip(props: {
119119 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
120120}) {
121121 return (
122 <ContextChipPopover
123 accessibleLabel={`${props.kindLabel} ${props.label}: ${props.metadata.title}`}
124 {...(props.copyMarkdown ? { copyMarkdown: props.copyMarkdown } : {})}
125 triggerClassName={props.className}
126 chip={
127 <>
128 <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />
129 <span className={props.labelClassName}>{props.label}</span>
130 </>
131 }
132 >
133 <div className="space-y-3 p-2">
122 <Tooltip>
123 <TooltipTrigger
124 render={
125 <Button
126 variant="chip"
127 className={cn(
128 props.className,
129 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
130 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
131 "cursor-pointer",
132 )}
133 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
134 data-markdown-copy={props.copyMarkdown}
135 onClick={(event) => props.onOpen(event, props.metadata.url)}
136 >
137 <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />
138 <span className={props.labelClassName}>{props.label}</span>
139 </Button>
140 }
141 />
142 <TooltipPopup side="top">
134143 <PullRequestContextDetails metadata={props.metadata} />
135 <p className="text-xs text-muted-foreground">Captured pull request context</p>
136 <Button
137 variant="outline"
138 size="sm"
139 onClick={(event) => props.onOpen(event, props.metadata.url)}
140 >
141 Open pull request
142 </Button>
143 </div>
144 </ContextChipPopover>
144 </TooltipPopup>
145 </Tooltip>
145146 );
146147}
147148
apps/web/src/components/files/AttachmentFilePreview.tsx+21
......@@ -12,6 +12,7 @@ import {
1212 Eye,
1313 Table2,
1414 Trash2Icon,
15 WrapTextIcon,
1516 XIcon,
1617} from "lucide-react";
1718import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
......@@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown";
2122import { ScrollArea } from "~/components/ui/scroll-area";
2223import { toastManager } from "~/components/ui/toast";
2324import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
25import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
2426import { cn } from "~/lib/utils";
2527
2628import { AudioPreview } from "./AudioPreview";
......@@ -180,6 +182,16 @@ export function AttachmentFilePreview(props: {
180182 return () => controller.abort();
181183 }, [url, needsText, revision, props.sizeBytes, props.file, refresh]);
182184 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
184196 const save = () => {
185197 setSaving(true);
......@@ -302,6 +314,15 @@ export function AttachmentFilePreview(props: {
302314 )}
303315 </FileSurfaceAction>
304316 ) : 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}
305326 {content ? (
306327 <FileSurfaceAction
307328 label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"}
apps/web/src/components/files/FilePreviewPanel.tsx+20-2
......@@ -21,7 +21,7 @@ import {
2121 squashAtomCommandFailure,
2222} from "@t3tools/client-runtime/state/runtime";
2323import { 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";
2525import * as Schema from "effect/Schema";
2626import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2727
......@@ -31,7 +31,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";
3131import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
3232import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
3333import { useRemoteOpenState } from "~/remoteOpen";
34import { useClientSettings } from "~/hooks/useSettings";
34import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
3535import { useTheme } from "~/hooks/useTheme";
3636import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
3737import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
......@@ -995,6 +995,15 @@ export default function FilePreviewPanel({
995995 ? ("html" as const)
996996 : null;
997997 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;
9981007 const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile;
9991008 const setRenderedPreferred = isMarkdown
10001009 ? setRenderMarkdownPreferred
......@@ -1122,6 +1131,15 @@ export default function FilePreviewPanel({
11221131 )}
11231132 </FileSurfaceAction>
11241133 ) : 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}
11251143 {canOpenInBrowser ? (
11261144 <FileSurfaceAction label="Open file in preview browser" onPress={handleOpenInBrowser}>
11271145 <Globe2 className="size-3.5" />
apps/web/src/composerDraftStore.test.ts+5-1
......@@ -436,7 +436,9 @@ describe("composerDraftStore file attachments", () => {
436436
437437 it("persists uploaded file references without including file contents", () => {
438438 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 ]);
440442 store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf");
441443
442444 const persistApi = useComposerDraftStore.persist as unknown as {
......@@ -459,6 +461,7 @@ describe("composerDraftStore file attachments", () => {
459461 sizeBytes: 6,
460462 attachmentId: "pending-report-pdf",
461463 environmentId: TEST_ENVIRONMENT_ID,
464 source: { _tag: "pasted-text" },
462465 },
463466 ],
464467 );
......@@ -474,6 +477,7 @@ describe("composerDraftStore file attachments", () => {
474477 file: null,
475478 uploadedAttachmentId: "pending-report-pdf",
476479 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
480 source: { _tag: "pasted-text" },
477481 },
478482 ]);
479483 });
apps/web/src/composerDraftStore.ts+5
......@@ -13,6 +13,7 @@ import {
1313 ProviderOptionSelection,
1414 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1515 PreviewAnnotationPayloadSchema,
16 PastedTextAttachmentSource,
1617 type PreviewAnnotationPayload,
1718 RuntimeMode,
1819 type ServerProvider,
......@@ -191,6 +192,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({
191192 sizeBytes: Schema.Number,
192193 attachmentId: Schema.String,
193194 environmentId: EnvironmentId,
195 source: Schema.optional(PastedTextAttachmentSource),
194196});
195197export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type;
196198
......@@ -207,6 +209,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({
207209 sizeBytes: Schema.Number,
208210 attachmentId: Schema.optionalKey(Schema.String),
209211 environmentId: Schema.optionalKey(EnvironmentId),
212 source: Schema.optional(PastedTextAttachmentSource),
210213});
211214export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;
212215const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment);
......@@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState(
21442147 name: file.name,
21452148 mimeType: file.mimeType,
21462149 sizeBytes: file.sizeBytes,
2150 ...(file.source ? { source: file.source } : {}),
21472151 ...(file.uploadedAttachmentId && file.uploadEnvironmentId
21482152 ? {
21492153 attachmentId: file.uploadedAttachmentId,
......@@ -2423,6 +2427,7 @@ function toHydratedThreadDraft(
24232427 mimeType: file.mimeType,
24242428 sizeBytes: file.sizeBytes,
24252429 file: null,
2430 ...(file.source ? { source: file.source } : {}),
24262431 // A marker without an attachment id hydrates as needs-reattach: no
24272432 // bytes, no server-side upload, only the metadata to tell the user
24282433 // what to attach again.
apps/web/src/lib/attachmentUploadQueue.test.ts+5-1
......@@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => {
238238 });
239239
240240 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 };
242245 startAttachmentUpload({ environmentId: firstEnvironment, image: file });
243246 await Promise.resolve();
244247
......@@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => {
268271 name: "report.pdf",
269272 mimeType: "application/pdf",
270273 sizeBytes: 3,
274 source: { _tag: "pasted-text" },
271275 },
272276 ]);
273277 });
apps/web/src/lib/attachmentUploadQueue.ts+1-1
......@@ -563,7 +563,7 @@ export function getUploadedAttachments(input: {
563563 name: image.name,
564564 mimeType: image.mimeType,
565565 sizeBytes: image.sizeBytes,
566 ...(image.type === "image" && image.source ? { source: image.source } : {}),
566 ...(image.source ? { source: image.source } : {}),
567567 });
568568 }
569569 return attachments;
apps/web/src/lib/composerContextReferences.test.ts+12
......@@ -6,6 +6,7 @@ import {
66 ensureInlineContextReferences,
77 formatInlineContextReference,
88 insertInlineContextReference,
9 inlineContextReferenceReplacement,
910 removeInlineContextReference,
1011 stripInlineContextReferences,
1112 toComposerContextId,
......@@ -18,6 +19,17 @@ const reviewLink = "[a.ts L4](t3-context://v1/review-comment/rc-1)";
1819const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)";
1920
2021describe("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 );
2133 it("formats, collects and strips references of any kind", () => {
2234 expect(formatInlineContextReference(review)).toBe(reviewLink);
2335 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 {
9393 return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r";
9494}
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
96112/** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */
97113export function insertInlineContextReference(
98114 prompt: string,
99115 cursorInput: number,
100116 reference: ComposerContextReference,
101117): { prompt: string; cursor: number } {
102 const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput)));
103 const needsLeadingSpace = !isBoundaryWhitespace(prompt[cursor - 1]);
104 const replacement = `${needsLeadingSpace ? " " : ""}${formatInlineContextReference(reference)} `;
105 const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor;
118 const edit = inlineContextReferenceReplacement(prompt, { start: cursorInput, end: cursorInput }, [
119 reference,
120 ]);
106121 return {
107 prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`,
108 cursor: cursor + replacement.length,
122 prompt: `${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`,
123 cursor: edit.start + edit.text.length,
109124 };
110125}
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";
33import { useComposerDraftStore } from "./composerDraftStore";
44import {
55 questionAttachmentDraftId,
6 countQuestionAttachments,
67 questionAttachmentDraftPrefix,
78 changeQuestionAttachmentPreparation,
89 clearQuestionAttachmentDraft,
......@@ -98,3 +99,43 @@ it("does not clear another environment whose id contains a question prefix", ()
9899 expect(store.getComposerDraft(ownKey)).toBeNull();
99100 expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer");
100101});
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,
2525 counts: {},
2626}));
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
2838export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void {
2939 useQuestionAttachmentPreparation.setState((state) =>
3040 delta < 0 && !(key in state.counts)
apps/web/src/routes/__root.tsx+2
......@@ -67,6 +67,7 @@ import {
6767} from "../components/KeybindingsUpdateToast.logic";
6868
6969import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot";
70import { installDesktopPasteAsText } from "../lib/desktopPasteAsText";
7071import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume";
7172
7273export const Route = createRootRoute({
......@@ -108,6 +109,7 @@ export const Route = createRootRoute({
108109});
109110
110111function RootRouteView() {
112 useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []);
111113 const pathname = useLocation({ select: (location) => location.pathname });
112114 const { authGateState } = Route.useRouteContext();
113115 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
docs/user/composer.md+6
......@@ -6,6 +6,12 @@ include a skill when the task needs more context.
66Messages can contain up to 120,000 characters. Longer drafts stay in the composer
77so 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
915## Attach files
1016
1117Attach 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 @@
103103 "types": "./src/state/attachments.ts",
104104 "default": "./src/state/attachments.ts"
105105 },
106 "./text-paste": {
107 "types": "./src/textPaste.ts",
108 "default": "./src/textPaste.ts"
109 },
106110 "./state/connections": {
107111 "types": "./src/state/connections.ts",
108112 "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 {
13081308 * builds lack it; callers fall back to VS Code only.
13091309 */
13101310 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
1311 /** Present when the desktop shell can perform an ordered plain-text paste. */
1312 pasteAsText?: () => Promise<void>;
13111313 onMenuAction: (listener: (action: string) => void) => () => void;
13121314 onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void;
13131315 /**
packages/contracts/src/orchestration.ts+7
......@@ -310,6 +310,9 @@ export const ChatImageAttachment = Schema.Struct({
310310});
311311export type ChatImageAttachment = typeof ChatImageAttachment.Type;
312312
313export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {});
314export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type;
315
313316export const ChatFileAttachment = Schema.Struct({
314317 type: Schema.Literal("file"),
315318 id: ChatAttachmentId,
......@@ -319,6 +322,10 @@ export const ChatFileAttachment = Schema.Struct({
319322 Schema.isGreaterThanOrEqualTo(1),
320323 Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES),
321324 ),
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),
322329});
323330export type ChatFileAttachment = typeof ChatFileAttachment.Type;
324331
packages/shared/src/composerContextLegacy.test.ts+16
......@@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => {
582582 ]);
583583 });
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
585601 it("handles the combined legacy send order: terminal, element, preview, review", () => {
586602 const text = [
587603 "See ",
packages/shared/src/composerContextLegacy.ts+13-6
......@@ -339,15 +339,22 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext
339339
340340 // Blocks were appended in send order (terminal, element, preview, review), so they peel
341341 // off the end in reverse. Each peel exposes the next block as trailing.
342 // Only reviews that trailed the original text were appended by the old send path; a
343 // review that sat before other blocks keeps its place.
342 // Peel reviews only when they hide another trailing context block. A review at the end
343 // of ordinary prose can still be inline; keep its original spacing and line breaks.
344344 const trailingReviewTokens: number[] = [];
345345 const tokens = trailingReviewTokenPattern.exec(rest);
346346 if (tokens && tokens[0].length > 0) {
347 trailingReviewTokens.push(
348 ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])),
349 );
350 rest = rest.slice(0, tokens.index).replace(/\n+$/, "");
347 const preceding = rest.slice(0, tokens.index);
348 if (
349 TRAILING_PREVIEW.test(preceding) ||
350 TRAILING_ELEMENT.test(preceding) ||
351 TRAILING_TERMINAL.test(preceding)
352 ) {
353 trailingReviewTokens.push(
354 ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])),
355 );
356 rest = preceding;
357 }
351358 }
352359 for (;;) {
353360 const preview = stripTrailing(rest, TRAILING_PREVIEW);
packages/shared/src/composerContextLegacySend.test.ts+25
......@@ -99,6 +99,31 @@ describe("serializeLegacyContextMessage", () => {
9999 });
100100 });
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
102127 it("retains picked-element details for preview annotations sent through an older server", () => {
103128 const text = `Update ${formatComposerContextReference(annotation)}`;
104129 const upgraded = upgradeLegacyContextMessage(