diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e84e6d5c730d..e6abaab03251 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( + function* (): Effect.fn.Return { + // Stop every backend in the pool with a timeout to guarantee the quit + // path makes progress even if a backend hangs during teardown. + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); + }, +); + const bootstrap = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const primaryBackend = yield* pool.primary; @@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped( const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. Stops run concurrently. - const instances = yield* pool.list; - yield* Effect.forEach(instances, (instance) => instance.stop(), { - concurrency: "unbounded", - }); - }).pipe(Effect.ensuring(shutdown.markComplete)), + // Stop every backend in the pool, not just the primary. The + // electronApp.quit() path can race ahead of the layer-scope + // cascade, so leaving the WSL instance for its parent scope + // finalizer means it gets hard-killed by the OS instead of + // receiving SIGTERM + grace. + stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 6781db932fa1..e2e0cd413bd9 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( profiles, credentials, remoteDpopTokens: [], + disabledEnvironmentIds: [], }; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 53ccf5a756eb..5a1ef70ad1c2 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopApp from "../app/DesktopApp.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => + Effect.scoped( + Effect.gen(function* () { + // Each backend's process-scope finalizer reports when it starts and + // when it finishes, keyed by instance name, so the test can prove + // both backends reached each milestone instead of inferring it from + // a shared flag or a clock advance. + const teardownStarted = yield* Queue.unbounded(); + const teardownFinished = yield* Queue.unbounded(); + const allowTeardown = yield* Deferred.make(); + + const makeInstance = (name: string) => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Scope.addFinalizer( + scope, + Queue.offer(teardownStarted, name).pipe( + Effect.andThen(Deferred.await(allowTeardown)), + Effect.andThen(Queue.offer(teardownFinished, name)), + Effect.asVoid, + ), + ); + return makeProcess({ exitCode: Effect.never }); + }), + ), + ), + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + const instance1 = yield* makeInstance("instance1"); + const instance2 = yield* makeInstance("instance2"); + + yield* instance1.start; + yield* instance2.start; + + const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([instance1, instance2]), + get: () => Effect.succeed(Option.none()), + primary: Effect.die(new Error("primary not implemented")), + register: () => Effect.die(new Error("register not implemented")), + unregister: () => Effect.die(new Error("unregister not implemented")), + }); + + // Mirror the quit path: register stopAllPoolInstances as a scope + // finalizer and let the scope close run it, rather than calling it + // as an ordinary interruptible effect. + const quitFiber = yield* Effect.scoped( + Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), + ).pipe(Effect.provide(mockPool), Effect.forkChild); + + const started = yield* Queue.takeN(teardownStarted, 2); + assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); + + // Both backends are now hung in teardown. Advancing past the 5s + // budget must let the quit finalizer return without them. + yield* TestClock.adjust(Duration.seconds(5)); + yield* Fiber.join(quitFiber); + assert.equal(yield* Queue.size(teardownFinished), 0); + + // The timed-out closes keep running in the background and finish + // once the backends unblock. + yield* Deferred.succeed(allowTeardown, undefined); + const finished = yield* Queue.takeN(teardownFinished, 2); + assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c6ca676fc467..c1eba805c2b6 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { openExternal, openSystemSettings, checkSystemPermission, + pasteAsText, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); + yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5489e56fea1c..ca6bbd30b3e4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..6fcf5e813749 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -6,6 +6,15 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ + focusedWebContents: vi.fn(), + ownerWindow: vi.fn(), +})); +vi.mock("electron", () => ({ + webContents: { getFocusedWebContents: focusedWebContents }, + BrowserWindow: { fromWebContents: ownerWindow }, +})); + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -13,6 +22,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, } from "./window.ts"; @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect( + "pastes into the focused guest only after the main renderer acknowledges the menu action", + () => { + const paste = vi.fn(); + const mainPaste = vi.fn(); + const window = { + webContents: { id: 42, paste: mainPaste }, + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false }); + ownerWindow.mockReturnValue(window); + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + assert.equal(mainPaste.mock.calls.length, 0); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(null); // Detached contents. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + ownerWindow.mockReturnValue(window); + focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true }); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + focusedWebContents.mockReturnValue(null); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(paste.mock.calls.length, 1); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + }), + ), + ); + }, + ); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 5e7c41514a67..284b62ad31ac 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const pasteAsText = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + const focused = Electron.webContents.getFocusedWebContents(); + if ( + focused && + !focused.isDestroyed() && + Electron.BrowserWindow.fromWebContents(focused) === window.value + ) { + focused.paste(); + } + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 63041db98c28..d4edb7818180 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea2a80010124..f6622049ab49 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,6 +34,7 @@ const clientSettings: ClientSettings = { contextWindowMeterEnabled: false, composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], + diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", environmentIdentificationMode: "artwork", diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index eeb0c86f031c..bf0c4c3eff6e 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("owns Paste as Text and routes it through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + assert.isDefined(pasteAsTextItem); + assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); + if (typeof pasteAsTextItem.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index bf7634981f86..a90b9ca63231 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function action: string, ): Effect.fn.Return { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); + yield* desktopWindow.dispatchMenuAction(action, { + reveal: action !== "paste-as-text", + }); }); const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const pasteAsTextClick = () => { + runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); + }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { + label: "Paste as Text", + accelerator: "CmdOrCtrl+Shift+V", + click: pasteAsTextClick, + }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(environment.platform === "darwin" + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], + }, + ] + : []), + ], + }, { label: "View", submenu: [ diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 489641ec6c6e..0a3e7ed7e17b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3composereditor' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -17,4 +21,12 @@ android { dependencies { implementation project(':expo-modules-core') implementation project(':t3tools-mobile-markdown-text') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 729fec480068..1703a7670202 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() { Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int -> + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int -> + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "onComposerPasteText", "onComposerContentSizeChange", ) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index e409c791a5e5..a4720a461327 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -1,7 +1,8 @@ package expo.modules.t3composereditor -import android.content.Context +import android.content.ClipData import android.content.ClipboardManager +import android.content.Context import android.graphics.Color import android.graphics.Canvas import android.graphics.Paint @@ -48,6 +49,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private val onComposerPasteImages by EventDispatcher() private val onComposerContextPress by EventDispatcher() private val onComposerPasteContext by EventDispatcher() + private val onComposerPasteText by EventDispatcher() private val onComposerContentSizeChange by EventDispatcher() private var applyingNativeValue = false private var desiredLineHeightPx = 0 @@ -86,7 +88,16 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.pasteImagesListener = { uris -> onComposerPasteImages(mapOf("uris" to uris)) } - editor.pasteContextListener = { payload -> onComposerPasteContext(payload) } + editor.pasteContextListener = { payload -> + nativeEventCount += 1 + onComposerPasteContext( + payload + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "selection" to currentSelectionPayload(), + ) + ) + } val contextGestures = GestureDetector( context, @@ -120,6 +131,17 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( contextGestures.onTouchEvent(event) false } + editor.pasteTextListener = { text, start, end -> + nativeEventCount += 1 + onComposerPasteText( + mapOf( + "value" to editor.text.toString(), + "eventCount" to nativeEventCount, + "text" to text, + "selection" to currentSelectionPayload(start, end), + ), + ) + } editor.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { onComposerFocus(emptyMap()) @@ -305,6 +327,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( updateInputFlags() } + fun setTextPasteThresholdBytes(threshold: Int) { + editor.textPasteThresholdBytes = threshold + } + + fun setMaxInputChars(maxInputChars: Int) { + editor.maxInputChars = maxInputChars + } + fun focusEditor() { editor.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager @@ -365,10 +395,13 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.highlightColor = defaultHighlightColor } - private fun currentSelectionPayload(): Map = + private fun currentSelectionPayload( + start: Int = editor.selectionStart, + end: Int = editor.selectionEnd + ): Map = mapOf( - "start" to editor.selectionStart.coerceAtLeast(0), - "end" to editor.selectionEnd.coerceAtLeast(0), + "start" to minOf(start, end).coerceAtLeast(0), + "end" to maxOf(start, end).coerceAtLeast(0), ) private fun emitSelectionChange(start: Int, end: Int) { @@ -379,7 +412,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( onComposerSelectionChange( mapOf( "value" to editor.text.toString(), - "selection" to mapOf("start" to start, "end" to end), + "selection" to currentSelectionPayload(start, end), "eventCount" to nativeEventCount, ), ) @@ -544,11 +577,14 @@ private fun parseTokens(value: String): List = try { emptyList() } -private class SelectionAwareEditText(context: Context) : EditText(context) { +internal class SelectionAwareEditText(context: Context) : EditText(context) { var readOnly = false var selectionListener: ((Int, Int) -> Unit)? = null var pasteImagesListener: ((List) -> Unit)? = null var pasteContextListener: ((Map) -> Unit)? = null + var pasteTextListener: ((String, Int, Int) -> Unit)? = null + var textPasteThresholdBytes = 0 + var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" private fun deleteChip(backwards: Boolean): Boolean { @@ -602,7 +638,6 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) } } - override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) selectionListener?.invoke(selStart, selEnd) @@ -615,6 +650,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } val handled = when { id == android.R.id.copy || id == android.R.id.cut -> copyContext(id == android.R.id.cut) + id == android.R.id.pasteAsPlainText -> { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + pasteInterceptedText(clipboard?.primaryClip, foldLargeText = false) + } pasting -> pasteContextOrImages() else -> false } @@ -636,6 +675,10 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { pasteContextListener?.invoke(payload) return true } + return pasteImagesOrInterceptedText() + } + + private fun pasteImagesOrInterceptedText(): Boolean { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager val clip = clipboard?.primaryClip val imageUris = buildList { @@ -648,7 +691,48 @@ private class SelectionAwareEditText(context: Context) : EditText(context) { } } } - if (imageUris.isNotEmpty()) pasteImagesListener?.invoke(imageUris) - return imageUris.isNotEmpty() + return when { + imageUris.isNotEmpty() -> { + pasteImagesListener?.invoke(imageUris) + true + } + else -> pasteInterceptedText(clip) + } + } + + private fun pasteInterceptedText(clip: ClipData?, foldLargeText: Boolean = true): Boolean { + val text = if (textPasteThresholdBytes > 0) clip?.plainText() else null + if (text.isNullOrEmpty()) return false + val start = minOf(selectionStart, selectionEnd).coerceIn(0, length()) + val end = maxOf(selectionStart, selectionEnd).coerceIn(start, length()) + val exceedsInputLimit = length().toLong() - (end - start) + text.length > maxInputChars + val shouldFold = foldLargeText && ( + text.length >= textPasteThresholdBytes || + text.toByteArray(Charsets.UTF_8).size >= textPasteThresholdBytes + ) + val shouldIntercept = exceedsInputLimit || shouldFold + if (shouldIntercept) { + pasteTextListener?.invoke(text, start, end) + } + // Let EditText perform ordinary pastes, retaining its native undo history. + return shouldIntercept + } + + // coerceToText opens content: URIs synchronously. Leave URI-backed + // clipboard items to Android's normal paste path so the UI thread never + // reads an arbitrary provider just to measure a text paste. + private fun ClipData.plainText(): String? = + takeIf { itemCount > 0 } + ?.getItemAt(0) + ?.takeIf { it.uri == null } + ?.coerceToText(context) + ?.toString() + ?.takeIf(String::isNotEmpty) + + override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { + return onTextContextMenuItem(android.R.id.pasteAsPlainText) + } + return super.onKeyShortcut(keyCode, event) } } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt new file mode 100644 index 000000000000..9838c6314680 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -0,0 +1,123 @@ +package expo.modules.t3composereditor + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class ComposerPasteTest { + private val context = RuntimeEnvironment.getApplication() + private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val editor = SelectionAwareEditText(context).apply { + textPasteThresholdBytes = 32 * 1024 + maxInputChars = 120_000 + } + + private fun pasteAsText(): Boolean = editor.onKeyShortcut( + KeyEvent.KEYCODE_V, + KeyEvent( + 0, + 0, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_V, + 0, + KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON + ) + ) + + @Test + fun shortcutKeepsLargeTextInlineAndUndoable() { + val pasted = "x".repeat(32 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("before old after") + editor.setSelection(10, 7) + editor.pasteTextListener = { _, _, _ -> error("Inline paste must stay native") } + + assertTrue(pasteAsText()) + assertEquals("before $pasted after", editor.text.toString()) + assertTrue(editor.onTextContextMenuItem(android.R.id.undo)) + assertEquals("before old after", editor.text.toString()) + } + + @Test + fun shortcutInterceptsInputLimitOverflowWithoutChangingTheSelection() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(119_999)) + editor.setSelection(10, 7) + var intercepted: Triple? = null + editor.pasteTextListener = { text, start, end -> intercepted = Triple(text, start, end) } + + assertTrue(pasteAsText()) + assertEquals(Triple("hello", 7, 10), intercepted) + assertEquals(119_999, editor.length()) + assertEquals(10, editor.selectionStart) + assertEquals(7, editor.selectionEnd) + } + + @Test + fun shortcutAllowsReplacementAtTheInputLimit() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "hello")) + editor.setText("x".repeat(120_000)) + editor.setSelection(12, 7) + editor.pasteTextListener = { _, _, _ -> error("Replacement fits the input limit") } + + assertTrue(pasteAsText()) + assertEquals("hello", editor.text.substring(7, 12)) + assertEquals(120_000, editor.length()) + } + + @Test + fun regularPasteStillFoldsAtTheUtf8Threshold() { + val pasted = "é".repeat(16 * 1024) + clipboard.setPrimaryClip(ClipData.newPlainText("test", pasted)) + editor.setText("old") + editor.setSelection(0, 3) + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(pasted, intercepted) + assertEquals("old", editor.text.toString()) + } + + @Test + fun shortcutPastesStructuredClipboardAsText() { + clipboard.setPrimaryClip( + ClipData.newHtmlText( + "test", + "plain text", + "
plain text
" + ) + ) + editor.setSelection(0) + editor.pasteContextListener = { error("Paste as Text must not import structured context") } + + assertTrue(pasteAsText()) + assertEquals("plain text", editor.text.toString()) + } + + @Test + fun readOnlyShortcutDoesNotPasteOrEmitAnEvent() { + clipboard.setPrimaryClip(ClipData.newPlainText("test", "x".repeat(120_001))) + editor.setText("unchanged") + editor.setSelection(editor.length()) + editor.readOnly = true + var intercepted: String? = null + editor.pasteTextListener = { text, _, _ -> intercepted = text } + + assertFalse(pasteAsText()) + assertEquals("unchanged", editor.text.toString()) + assertNull(intercepted) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 4f0ead66e5c7..523f0d61e0b6 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -87,6 +87,12 @@ public class T3ComposerEditorModule: Module { Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in view.setSpellCheck(spellCheck) } + Prop("textPasteThresholdBytes") { (view: T3ComposerEditorView, threshold: Int) in + view.setTextPasteThresholdBytes(threshold) + } + Prop("maxInputChars") { (view: T3ComposerEditorView, maxInputChars: Int) in + view.setMaxInputChars(maxInputChars) + } Events( "onComposerChange", @@ -97,6 +103,7 @@ public class T3ComposerEditorModule: Module { "onComposerPasteImages", "onComposerContextPress", "onComposerPasteContext", + "onComposerPasteText", "onComposerContentSizeChange" ) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 11c62f36f226..6258c81c8f97 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -86,10 +86,14 @@ private final class ComposerTextView: UITextView { var onPasteImages: (([String]) -> Void)? var onPasteContext: (([String: String]) -> Void)? + var onPasteText: ((String, NSRange) -> Void)? var clipboardFragment = "" var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? var isReadOnly = false + var textPasteThresholdBytes = 0 + var maxInputChars = Int.max + private var bypassTextPasteInterception = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -101,6 +105,16 @@ private final class ComposerTextView: UITextView { submit.discoverabilityTitle = "Send Message" submit.wantsPriorityOverSystemBehavior = true commands.append(submit) + if textPasteThresholdBytes > 0 { + let pasteAsText = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteInline(_:)) + ) + pasteAsText.discoverabilityTitle = "Paste as Text" + pasteAsText.wantsPriorityOverSystemBehavior = true + commands.append(pasteAsText) + } return commands } @@ -108,6 +122,15 @@ private final class ComposerTextView: UITextView { onSubmit?() } + @objc private func pasteInline(_ sender: UIKeyCommand) { + guard !isReadOnly else { + return + } + bypassTextPasteInterception = true + defer { bypassTextPasteInterception = false } + paste(sender) + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { return false @@ -150,9 +173,28 @@ private final class ComposerTextView: UITextView { return } } + if !bypassTextPasteInterception, + let text = pasteboard.string, shouldInterceptTextPaste(text) { + onPasteText?(text, selectedRange) + return + } super.paste(sender) } + private func shouldInterceptTextPaste(_ text: String) -> Bool { + guard textPasteThresholdBytes > 0, !text.isEmpty else { return false } + let pastedLength = (text as NSString).length + if pastedLength >= textPasteThresholdBytes || text.utf8.count >= textPasteThresholdBytes { + return true + } + // Chips occupy one display character but expand to their source in the + // submitted message. Measure that source, including the replaced selection. + let sourceLength = sourceOffset(forDisplayOffset: attributedText.length) + let selectedLength = sourceOffset(forDisplayOffset: NSMaxRange(selectedRange)) - + sourceOffset(forDisplayOffset: selectedRange.location) + return sourceLength - selectedLength + pastedLength > maxInputChars + } + override func deleteBackward() { guard !isReadOnly else { return @@ -368,6 +410,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro let onComposerPasteImages = EventDispatcher() let onComposerContextPress = EventDispatcher() let onComposerPasteContext = EventDispatcher() + let onComposerPasteText = EventDispatcher() let onComposerContentSizeChange = EventDispatcher() public required init(appContext: AppContext? = nil) { @@ -387,7 +430,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro self?.onComposerPasteImages(["uris": urls]) } textView.onPasteContext = { [weak self] context in - self?.onComposerPasteContext(context) + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + var payload: [String: Any] = context + payload["value"] = self.textView.serializedText() + payload["eventCount"] = self.nativeEventCount + payload["selection"] = ["start": selection.start, "end": selection.end] + self.onComposerPasteContext(payload) + } + textView.onPasteText = { [weak self] text, _ in + guard let self else { return } + let selection = self.sourceSelection() + self.nativeEventCount += 1 + self.onComposerPasteText([ + "value": self.textView.serializedText(), + "eventCount": self.nativeEventCount, + "text": text, + "selection": ["start": selection.start, "end": selection.end], + ]) } textView.onAttributedMutation = { [weak self] in self?.emitTextChange() @@ -596,6 +657,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.spellCheckingType = spellCheck ? .yes : .no } + func setTextPasteThresholdBytes(_ threshold: Int) { + textView.textPasteThresholdBytes = threshold + } + + func setMaxInputChars(_ maxInputChars: Int) { + textView.maxInputChars = maxInputChars + } + func focusEditor() { textView.becomeFirstResponder() } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 7430e28a633e..533c13108865 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -33,6 +33,15 @@ @interface T3ContextCopyTextView : UITextView @end @implementation T3ContextCopyTextView +// Read-only text still supports selecting the entire document after selecting a word. +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (action == @selector(selectAll:)) { + return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; + } + return [super canPerformAction:action withSender:sender]; +} + - (void)copy:(id)sender { NSRange selected = self.selectedRange; diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index caf8f676f07b..04382839c909 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -226,13 +226,15 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { // The document picker types every pick as a plain file, so a picture arrives here as one. // What it *is* decides how it presents, the same way videos are already recognised below. if (attachment.type === "image" || imageMimeType(attachment) !== null) { + // A pasted-text marker does not fit the snapshot source a picture carries. + const { source: _droppedSource, ...rest } = attachment; return ( ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 4a5cb017c236..725b3b84389b 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -9,17 +9,16 @@ import type { ComposerEditorProps as NativeComposerEditorProps } from "../native import { appendComposerDraftAttachments, createComposerDraftContextHistory, + getComposerDraftAfterSelection, getComposerDraftSnapshot, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftContext, setComposerContextImporting, useComposerDraft, } from "../state/use-composer-drafts"; -import { - importComposerContextClipboard, - type NativeContextClipboard, -} from "../lib/composerContextClipboard"; +import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; import { @@ -34,6 +33,14 @@ export type ComposerEditorProps = NativeComposerEditorProps & { readonly onOpenMention?: (path: string) => void; /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; + /** + * A resting composer is a target to type in, not a document to navigate. Its chips go inert + * so a draft full of them can still be tapped anywhere to start writing; the caller focuses + * the editor instead. Chips become live again once the composer is open. + */ + readonly chipsInert?: boolean; + /** Called instead of opening a chip while `chipsInert` is set. */ + readonly onInertChipPress?: () => void; }; export function ComposerEditor({ @@ -41,6 +48,8 @@ export function ComposerEditor({ environmentId, onOpenMention, onOpenAttachment, + chipsInert, + onInertChipPress, ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); @@ -68,40 +77,35 @@ export function ComposerEditor({ }, [draftKey], ); - const pasteContext = async (clipboard: NativeContextClipboard) => { + const pasteContext = async ( + clipboard: Parameters>[0], + ) => { if (!draftKey || importRef.current || props.readOnly || props.editable === false) return; + const insertion = { text: clipboard.value, ...clipboard.selection }; const controller = new AbortController(); importRef.current = controller; setImporting(true); setComposerContextImporting(draftKey, true); try { + const retained = getComposerDraftAfterSelection(draftKey, insertion); const result = await importComposerContextClipboard( clipboard, - getComposerDraftSnapshot(draftKey).attachments.length, + retained.attachments.length, controller.signal, - getComposerDraftSnapshot(draftKey).context?.records.length ?? 0, + retained.context?.records.length ?? 0, ); if (!result) { - insertComposerDraftContext(draftKey, { - text: clipboard.text, - context: { version: 1, records: [] }, - }); + insertComposerDraftText(draftKey, clipboard.text, insertion); return; } - const rejected = appendComposerDraftAttachments(draftKey, result.attachments); - const ids = new Set( - getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), - ); - insertComposerDraftContext(draftKey, { - text: result.text, - context: { - version: 1, - records: result.context.records.filter( - (record) => !("attachmentId" in record) || ids.has(record.attachmentId), - ), - }, - }); - if (result.failures.length > 0 || rejected > 0) + if (!insertComposerDraftContext(draftKey, result, insertion)) { + Alert.alert( + "Could not paste context", + "Remove some attachments or context items from the draft, then paste again.", + ); + return; + } + if (result.failures.length > 0) Alert.alert( "Some attachments could not be copied", "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", @@ -161,6 +165,10 @@ export function ComposerEditor({ onPasteContext={(clipboard) => void pasteContext(clipboard)} context={draft.context} onContextPress={(selection) => { + if (chipsInert) { + onInertChipPress?.(); + return; + } const path = composerMentionPath(selection.source, draft.context); if (path && onOpenMention) { onOpenMention(path); @@ -174,7 +182,12 @@ export function ComposerEditor({ setSelected(selection); }} onSelectionChange={(selection) => { - if (draftKey) rememberComposerDraftSelection(draftKey, props.value, selection); + if (draftKey) + rememberComposerDraftSelection( + draftKey, + getComposerDraftSnapshot(draftKey).text, + selection, + ); props.onSelectionChange?.(selection); }} /> @@ -229,4 +242,8 @@ export function ComposerEditor({ ); } -export type { ComposerEditorHandle, ComposerEditorSelection } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorSelection, + ComposerTextPaste, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index ab361b8331a6..053946b2d10f 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -5,6 +5,7 @@ import { putRemoteDpopTokenInCatalog, registerConnectionInCatalog, removeConnectionFromCatalog, + setConnectionEnabledInCatalog, removeCatalogValue, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; @@ -21,7 +22,12 @@ import * as Option from "effect/Option"; import * as CatalogStore from "./catalog-store"; function targetPersistenceError( - operation: "list-targets" | "register-connection" | "remove-connection", + operation: + | "list-targets" + | "list-disabled-targets" + | "register-connection" + | "remove-connection" + | "set-connection-enabled", error: ConnectionTransientError, ) { return new ConnectionPersistenceError({ @@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext( Effect.map((document) => document.targets), Effect.mapError((error) => targetPersistenceError("list-targets", error)), ), + listDisabled: catalog.read.pipe( + Effect.map((document) => document.disabledEnvironmentIds), + Effect.mapError((error) => targetPersistenceError("list-disabled-targets", error)), + ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => @@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext( catalog .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + setEnabled: (environmentId, enabled) => + catalog + .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) + .pipe( + Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 952d1c388417..8138d288957c 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); - const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); + const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } = + useRemoteConnections(); const { refreshRelayEnvironments } = useConnectionController(); const { connectedCloudEnvironments } = splitEnvironmentSections({ connectedEnvironments, @@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() { {isSignedIn ? ( ) : ( diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 806499c2273b..b243580e5fb7 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; - readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; + /** Long-press on a saved row. The callback owns the confirm. */ + readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; readonly showcaseAvailableEnvironments?: ReadonlyArray; readonly showcaseSignedIn?: boolean; /** @@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent( [controller], ); - const handleDisconnectCloudEnvironment = useCallback( - (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), - [controller], - ); - const handleToggleCloudError = useCallback((environmentId: string) => { setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); }, []); @@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent( key={environment.environmentId} environment={environment} borderTop={index !== 0} - onConnect={() => props.onReconnectEnvironment(environment.environmentId)} - onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} + onSetEnabled={(enabled) => + props.onSetEnvironmentEnabled(environment.environmentId, enabled) + } + onRemove={() => props.onRemoveEnvironment(environment.environmentId)} errorExpanded={expandedErrorId === environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environmentId)} /> @@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent( ); } +/** + * A saved T3 Connect environment. The switch turns it on or off; off keeps the + * registration and cache but drops the connection and hides its errors. + * Long-press removes it from this device. + */ function ConnectedCloudEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; readonly borderTop: boolean; readonly errorExpanded: boolean; - readonly onConnect: () => void; - readonly onDisconnect: () => void; + readonly onSetEnabled: (enabled: boolean) => void; + readonly onRemove: () => void; readonly onToggleError: () => void; }) { const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); + const enabled = props.environment.isEnabled; return ( - + { - if (enabled) { - props.onConnect(); - return; - } - props.onDisconnect(); - }} + onValueChange={props.onSetEnabled} onToggleError={props.onToggleError} - value={props.environment.connectionState !== "available"} + {...(enabled ? {} : { statusText: "Off" })} + value={enabled} /> - + ); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 5555548ff799..c8eaa8cff04e 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { + if (!environment.isEnabled) { + return "Off"; + } return connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, @@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: { readonly onToggle: () => void; readonly onReconnect: (environmentId: EnvironmentId) => void; readonly onRemove: (environmentId: EnvironmentId) => void; + readonly onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; readonly onUpdate: ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, @@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: { const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); + const enabled = props.environment.isEnabled; const statusLabel = connectionStatusLabel(props.environment); - const statusTraceId = props.environment.connectionErrorTraceId; - const hasConnectionFailure = props.environment.connectionError !== null; + const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; + const hasConnectionFailure = enabled && props.environment.connectionError !== null; const isRetrying = - props.environment.connectionState === "connecting" || - props.environment.connectionState === "reconnecting"; + enabled && + (props.environment.connectionState === "connecting" || + props.environment.connectionState === "reconnecting"); const handleSave = useCallback(async () => { const result = await props.onUpdate(props.environment.environmentId, { label: label.trim(), @@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: { onPress={props.onToggle} > @@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: { ) : null} + props.onSetEnabled(props.environment.environmentId, next)} + value={enabled} + /> props.onReconnect(props.environment.environmentId)} > handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} + onSetEnabled={onSetEnvironmentEnabled} onUpdate={onUpdateEnvironment} /> diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 6d07f40a52dd..3eff54259a03 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -15,6 +15,7 @@ function connectedEnvironment( environmentLabel: input.environmentLabel ?? input.environmentId, displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, isRelayManaged: input.isRelayManaged, + isEnabled: input.isEnabled ?? true, connectionState: input.connectionState ?? "connected", connectionError: input.connectionError ?? null, connectionErrorTraceId: input.connectionErrorTraceId ?? null, diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index faa34477569d..a509ac60a4a6 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -40,6 +40,10 @@ export function useConnectionController() { const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const setEnvironmentEnabledMutation = useAtomCommand( + environmentCatalog.setEnabled, + "environment toggle", + ); const refreshRelayEnvironments = useAtomCommand( relayEnvironmentDiscovery.refresh, "relay environment refresh", @@ -93,6 +97,11 @@ export function useConnectionController() { (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), [retryEnvironmentMutation], ); + const setEnvironmentEnabled = useCallback( + (environmentId: EnvironmentId, enabled: boolean) => + setEnvironmentEnabledMutation({ environmentId, enabled }), + [setEnvironmentEnabledMutation], + ); const updateEnvironment = useCallback( ( environmentId: EnvironmentId, @@ -120,6 +129,7 @@ export function useConnectionController() { connectRelayEnvironment, removeEnvironment, retryEnvironment, + setEnvironmentEnabled, updateEnvironment, refreshRelayEnvironments, }; diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 46365630b4d2..a1870c00462a 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -20,6 +20,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; @@ -99,7 +100,7 @@ function AttachmentDocumentBody(props: { ) : null} - {table && document.rendered ? ( + {table && document.activeMode === "table" ? ( {table.truncated ? ( @@ -137,7 +138,7 @@ function AttachmentDocumentBody(props: { - ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( + ) : document.activeMode === "markdown" && props.environmentId ? ( ) : ( - + )} ); @@ -175,6 +176,7 @@ function AttachmentDocumentBody(props: { export function AttachmentFileScreen(props: AttachmentFileScreenProps) { const navigation = useNavigation(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -249,7 +251,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { handleBack(); }, [draftKey, handleBack, params.attachmentId]); - const { content, renderedMode, rendered, setRendered, share, sharing } = document; + const { content, renderedMode, activeMode, setRendered, share, sharing } = document; const menuActions = useMemo( () => [ @@ -271,6 +273,15 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { onPress: () => setRendered(false), } as const) : null, + content && activeMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, content ? ({ id: "copy", @@ -312,19 +323,31 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { } as const) : null, ].filter((action) => action !== null), - [content, draftKey, removeFromDraft, renderedMode, setRendered, share, sharing, uri], + [ + appearance.codeWordBreak, + setCodeWordBreak, + content, + draftKey, + removeFromDraft, + activeMode, + renderedMode, + setRendered, + share, + sharing, + uri, + ], ); - const activeMode = rendered ? "preview" : "source"; + const selectedAction = activeMode === "source" ? "source" : "preview"; const androidMenuActions = useMemo( () => menuActions.map((action) => ({ id: action.id, title: action.title, image: action.icon, - state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined, + state: action.inline ? (action.id === selectedAction ? "on" : "off") : undefined, ...("destructive" in action ? { attributes: { destructive: true } } : {}), })), - [activeMode, menuActions], + [selectedAction, menuActions], ); const handleAndroidMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -372,7 +395,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { {action.title} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 2eabce998e8e..82d69b28e841 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,7 +2,14 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; +import { + FlatList, + RefreshControl, + ScrollView, + Text as NativeText, + useWindowDimensions, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -23,13 +30,17 @@ import { NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; + +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly initialLine?: number | null; + /** Keep the entire document in one native text-selection scope. */ + readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ readonly onRefresh?: () => Promise | void; } @@ -129,7 +140,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { ? "ready" : "highlighting"; - return { lines, rowsJson, status, targetIndex, theme, tokens }; + return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { @@ -146,17 +157,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat return null; } -function NativeSourceFileSurface( - props: SourceFileSurfaceProps & { - readonly NativeView: ComponentType; - }, -) { - const { NativeView, onRefresh } = props; - const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); - const { themeAppearance, themeId } = useAppearancePreferences(); - const appTheme = useUniwindTheme(); - const { width: viewportWidth } = useWindowDimensions(); - const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); +function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { if (!onRefresh) { @@ -169,6 +170,21 @@ function NativeSourceFileSurface( setIsPullRefreshing(false); } }, [onRefresh]); + return { isPullRefreshing, handlePullToRefresh }; +} + +function NativeSourceFileSurface( + props: SourceFileSurfaceProps & { + readonly NativeView: ComponentType; + }, +) { + const { NativeView, onRefresh } = props; + const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { themeAppearance, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); + const { width: viewportWidth } = useWindowDimensions(); + const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), @@ -212,9 +228,18 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); - const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); + const selectableTokens = useMemo( + () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null), + [props.selectable, tokens], + ); const listRef = useRef>(null); + const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); + const refreshControl = props.onRefresh ? ( + void handlePullToRefresh()} /> + ) : undefined; useEffect(() => { if (targetIndex === null) { @@ -240,9 +265,59 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { [codeSurface, codeWordBreak, targetIndex, tokens], ); + // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, + // which selects across every line, wraps, and lays out long documents through TextKit; on + // Android the primitive is an RN `Text`, which selects across its nested children. Either + // way "select all" takes the file rather than a line, which a `FlatList` row can never do + // because each row is its own selection scope. + const selectableBlock = props.selectable ? ( + + {selectableTokens + ? lines.map((line, index) => { + const lineTokens = selectableTokens[index] ?? null; + const body = + lineTokens && lineTokens.length > 0 + ? lineTokens.map((token, tokenIndex) => ( + + {token.content} + + )) + : line; + return ( + + {body} + {index < lines.length - 1 ? "\n" : ""} + + ); + }) + : normalizedContents} + + ) : null; + const list = ( String(index)} initialNumToRender={80} @@ -266,14 +341,40 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { /> ); + // Workspace files retain their numbered, virtualized rows, with or without a line target. + // Attachments opt into one selection scope for the entire document. + const usesLineList = !props.selectable; + const padded = ( + + {selectableBlock} + + ); + return ( - {codeWordBreak ? ( - list + {usesLineList ? ( + codeWordBreak ? ( + list + ) : ( + + {list} + + ) + ) : codeWordBreak ? ( + padded ) : ( + // Without wrapping the text keeps its natural width and the reader pans to it. - {list} + {padded} )} @@ -282,7 +383,11 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); - return NativeView ? ( + const { codeWordBreak } = useAppearanceCodeSurface(); + // The native canvas draws source lines without text selection or wrapping. Attachments + // need one selectable text view in either wrap mode; workspace line navigation can still + // use the canvas when wrapping is disabled. + return NativeView && !codeWordBreak && !props.selectable ? ( ) : ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 0e58b26bdaf5..a8a0f860cfa8 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -583,6 +583,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -763,6 +764,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, + // Only the source body wraps; a rendered preview lays itself out. + resolvedActiveMode === "source" + ? ({ + id: "word-wrap", + title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap", + icon: "text.alignleft", + inline: false, + onPress: () => setCodeWordBreak(!appearance.codeWordBreak), + } as const) + : null, ...(mediaSource ? mediaActions.actions .filter(({ id }) => id !== "open-file") @@ -783,6 +794,17 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => copyTextWithHaptic(relativePath), } as const, ]), + // Selecting a long file by hand is painful on a phone, so copying the whole thing is + // the action most readers actually want. The attachment screen already offers it. + fileData?.contents != null + ? ({ + id: "copy-contents", + title: fileData.truncated ? "Copy preview" : "Copy contents", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(fileData.contents), + } as const) + : null, isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -821,6 +843,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : null, ].filter((action) => action !== null); }, [ + appearance.codeWordBreak, + setCodeWordBreak, assetPreviewUri, assetPreview.refresh, previewUri, @@ -833,6 +857,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { resolvedActiveMode, mediaSource, mediaActions.actions, + fileData?.contents, + fileData?.truncated, ]); const androidFileMenuActions = useMemo( diff --git a/apps/mobile/src/features/files/source-file-document.test.ts b/apps/mobile/src/features/files/source-file-document.test.ts index 04b3046994ad..a490a77da9dc 100644 --- a/apps/mobile/src/features/files/source-file-document.test.ts +++ b/apps/mobile/src/features/files/source-file-document.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { prepareSourceFileDocument } from "./source-file-document"; +import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; describe("prepareSourceFileDocument", () => { it("normalizes and serializes source rows once for repeated consumers", () => { @@ -14,3 +14,14 @@ describe("prepareSourceFileDocument", () => { expect(second).toBe(first); }); }); + +it("bounds selectable highlighting without allocating spans for huge files", () => { + const token = { content: "text", color: "#fff", fontStyle: null }; + const small = [[token]]; + expect(boundedSelectableSourceTokens(small)).toBe(small); + expect(boundedSelectableSourceTokens(null)).toBeNull(); + expect(boundedSelectableSourceTokens(Array.from({ length: 20_000 }, () => [token]))).toBeNull(); + expect(boundedSelectableSourceTokens([Array.from({ length: 2_000 }, () => token)])).toBeNull(); + const longPlainText = "full contents\n".repeat(50_000); + expect(prepareSourceFileDocument(longPlainText).contents).toBe(longPlainText); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts index d78ead3288fc..12f1fce099cf 100644 --- a/apps/mobile/src/features/files/source-file-document.ts +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -1,3 +1,4 @@ +import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; const MAX_CACHED_DOCUMENTS = 8; @@ -52,3 +53,17 @@ export function prepareSourceFileDocument(contents: string): SourceFileDocument return document; } + +// A selectable document cannot virtualize its rows. Cap React spans instead; large +// attachments remain fully selectable as one plain string, including every newline. +export function boundedSelectableSourceTokens( + tokens: ReadonlyArray> | null, +): typeof tokens { + if (!tokens) return null; + let spans = tokens.length; + for (const line of tokens) { + spans += line.length; + if (spans > 2_000) return null; + } + return tokens; +} diff --git a/apps/mobile/src/features/home/workspace-connection-status.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts index f1af93316a6b..3d56719d3fe5 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -45,6 +45,7 @@ describe("workspace connection status", () => { environmentLabel: "Julius’s Mac mini", displayUrl: "", isRelayManaged: false, + isEnabled: true, connectionState: "reconnecting", connectionError: null, connectionErrorTraceId: null, diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 793d26511553..9b8363c235db 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() { connectedEnvironments, onReconnectEnvironment, onRemoveEnvironmentPress, + onSetEnvironmentEnabled, onUpdateEnvironment, } = useRemoteConnections(); const navigation = useNavigation(); @@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() { onToggle={() => handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} + onSetEnabled={onSetEnvironmentEnabled} onUpdate={handleUpdateEnvironment} /> @@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() { user is signed out — the component gates discovery itself. */} ()); const activeShareImportTokenRef = useRef(null); const shareImportMountedRef = useRef(true); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); + const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set }>({ + draftKey: null, + names: new Set(), + }); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; const isImportingShare = importingShareKey !== null; const alertedUnavailableIncomingShareIdRef = useRef(null); + // The share this screen already moved into its draft. Sending clears the + // draft (and its importedShareIds receipt) a frame before the screen leaves, + // and the inbox entry is long gone by then; without this the re-render in + // between reads as "shared content vanished" and alerts on every send. + const consumedIncomingShareIdRef = useRef(null); const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; const requestedInitialProjectAvailable = Boolean( props.initialProjectRef?.environmentId && @@ -344,16 +369,8 @@ export function NewTaskDraftScreen(props: { : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; // Media needs its thumbnail; every other file already reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - flow.attachments, - new Set( - collectComposerContextReferences(flow.prompt).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [flow.attachments, flow.prompt], + () => composerStripAttachments(flow.attachments), + [flow.attachments], ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, @@ -404,8 +421,9 @@ export function NewTaskDraftScreen(props: { }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && - flow.draftKey && - getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), + (consumedIncomingShareIdRef.current === props.incomingShareId || + (flow.draftKey && + getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId))), ); const isIncomingShareUnavailable = Boolean( props.incomingShareId && @@ -735,6 +753,7 @@ export function NewTaskDraftScreen(props: { } await consumeShare(shareId); didConsumeShare = true; + consumedIncomingShareIdRef.current = shareId; // The consumed inbox draft was the last owner of files that never made // it into the composer draft (unsupported server, oversize, limit // skips). Release them before any early return: an unmount or a @@ -903,15 +922,19 @@ export function NewTaskDraftScreen(props: { return; } const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerMedia({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes : undefined, }); const rejectedCount = - result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0; + result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0; const problems = [ ...(result.error ? [result.error] : []), ...(rejectedCount > 0 @@ -933,11 +956,16 @@ export function NewTaskDraftScreen(props: { Alert.alert("File attachments are not available on this server."); return; } + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const result = await pickComposerFiles({ - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, maxBytes, }); - const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; + const rejectedCount = + result.files.length > 0 ? flow.appendAttachments(result.files, insertion) : 0; // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. const problems = [ @@ -954,12 +982,16 @@ export function NewTaskDraftScreen(props: { const handleNativePasteImages = useCallback( async (uris: ReadonlyArray) => { try { + const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined; const images = await convertPastedImagesToAttachments({ uris, - existingCount: flow.attachments.length, + existingCount: + flow.draftKey && insertion + ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion) + : flow.attachments.length, }); if (images.length > 0) { - flow.appendAttachments(images); + flow.appendAttachments(images, insertion); } } catch (error) { console.error("[native paste] error converting images", error); @@ -968,8 +1000,106 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + const draftKey = flow.draftKey; + if (!draftKey) return; + const insertion = { text: paste.value, ...paste.selection }; + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + flow.setPrompt(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(draftKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach && maxBytes !== null) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + try { + if (pastedTextFileNamesRef.current.draftKey !== draftKey) { + pastedTextFileNamesRef.current = { draftKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const attachment of flow.attachments) reservedNames.add(attachment.name); + const name = nextPastedTextFileName([...reservedNames]); + reservedNames.add(name); + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name, + maxBytes, + }); + if (latestDraftKeyRef.current !== draftKey) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + return; + } + if (flow.appendAttachments([attachment], insertion) > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + Alert.alert( + "Could not attach pasted text", + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + Alert.alert( + "Could not attach pasted text", + error instanceof Error ? error.message : "Try again.", + ); + } finally { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current); + } + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + + insertPaste(); + }, + [composerMenu, flow, selectedEnvironmentServerConfig], + ); + async function handleStart(): Promise { - if (voiceInput.blocksSubmission) return; + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey) { @@ -1139,6 +1269,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + pendingPastedTextAttachmentCount === 0 && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { @@ -1195,6 +1326,7 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onPasteText={(paste) => void handleNativePasteText(paste)} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -1486,13 +1618,15 @@ export function NewTaskDraftScreen(props: { 0 + ? "Attaching pasted text" + : flow.submitting + ? "Starting task" + : attachmentsUploading + ? "Queue task, sends when uploads finish" + : environmentConnected + ? "Start task" + : "Queue task") } disabled={!canStart} icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e195510074e..680c9cf0babe 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,14 +1,19 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - MessageId, - ModelSelection, - OrchestrationThreadShell, - ProviderInteractionMode, - RuntimeMode, - ServerConfig as T3ServerConfig, - UsageLimitsReport, +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type EnvironmentId, + type MessageId, + type ModelSelection, + type OrchestrationThreadShell, + type ProviderInteractionMode, + type RuntimeMode, + type ServerConfig as T3ServerConfig, + type UsageLimitsReport, } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -46,7 +51,10 @@ import Animated, { import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { composerContextImportsAtom } from "../../state/use-composer-drafts"; +import { + composerContextImportsAtom, + countComposerDraftAttachmentsAfterSelection, +} from "../../state/use-composer-drafts"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject } from "../../state/entities"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -72,7 +80,6 @@ import { type DraftComposerAttachment, type DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { buildModelOptions, groupByProvider, @@ -135,6 +142,7 @@ export interface ThreadComposerProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -278,6 +286,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const pendingPastedTextAttachmentCountRef = useRef(0); + const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, @@ -291,19 +301,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewFile, setPreviewFile] = useState(null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Attachment context ids are the attachment id, so the prompt alone says which attachments - // already read as an inline chip and need no strip tile. + // Only media belongs above the composer; every other file reads as its inline chip. const stripAttachments = useMemo( - () => - composerStripAttachments( - props.draftAttachments, - new Set( - collectComposerContextReferences(props.draftMessage).map( - (occurrence) => occurrence.contextId as string, - ), - ), - ), - [props.draftAttachments, props.draftMessage], + () => composerStripAttachments(props.draftAttachments), + [props.draftAttachments], ); const showStopAction = !hasContent && @@ -422,7 +423,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer states: uploadStates, }); const contextImports = useAtomValue(composerContextImportsAtom); - const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; + const sendBlockedReason = + props.sendBlockedReason ?? + (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? + attachmentBlockReason; const canSend = hasContent && !contextImports[composerOwnerKey] && @@ -478,6 +482,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { + if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( @@ -488,7 +493,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (openUsageLimits()) onChangeDraftMessage(""); return; } - if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -736,6 +740,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); }} onOpenAttachment={openDraftDocument} + // A rested composer full of chips left almost nowhere to tap to start typing: + // every chip opened its file instead. Collapsed, they focus the editor. + chipsInert={!isExpanded} + onInertChipPress={() => inputRef.current?.focus()} ref={inputRef} multiline value={props.draftMessage} @@ -745,6 +753,76 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={composerMenu.onSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} + onPasteText={(paste) => { + const insertPaste = () => { + const insertion = replaceTextSelection({ + value: paste.value, + selection: paste.selection, + text: paste.text, + }); + const selection = { start: insertion.cursor, end: insertion.cursor }; + props.onChangeDraftMessage(insertion.value); + composerMenu.onSelectionChange(selection); + }; + const capabilities = props.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined + ? null + : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + paste.value.length - + Math.max(0, paste.selection.end - paste.selection.start) + + paste.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(composerOwnerKey, { + text: paste.value, + ...paste.selection, + }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(paste.text).byteLength <= maxBytes; + if ( + pastedTextDisposition({ + text: paste.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment" + ) { + if (canAttach) { + pendingPastedTextAttachmentCountRef.current += 1; + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + const finishAttachment = () => { + pendingPastedTextAttachmentCountRef.current = Math.max( + 0, + pendingPastedTextAttachmentCountRef.current - 1, + ); + setPendingPastedTextAttachmentCount( + pendingPastedTextAttachmentCountRef.current, + ); + }; + void props.onNativePasteText(paste).then(finishAttachment, finishAttachment); + } else if (!wouldExceedInputLimit) { + insertPaste(); + } else { + Alert.alert( + wouldExceedInputLimit + ? "Pasted text is too large for this message" + : "Could not attach pasted text", + wouldExceedInputLimit + ? "Remove some text or an attachment, then paste again." + : "Remove an attachment or use a smaller paste, then try again.", + ); + } + return; + } + insertPaste(); + }} placeholder={props.placeholder} onFocus={handleFocus} onBlur={handleBlur} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 55cd9e9a6c83..2f4ee67a187e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { appendCodexArtifactTemplateUsePrompt, @@ -155,6 +156,7 @@ export interface ThreadDetailScreenProps { readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; @@ -1049,6 +1051,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onPickDraftMedia={props.onPickDraftMedia} onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages} + onNativePasteText={props.onNativePasteText} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} onSendMessage={handleSendMessage} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index bb78e189bf43..f22540e768d9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -891,6 +891,7 @@ function ThreadRouteContent( onPickDraftMedia={composer.onPickDraftMedia} onPickDraftFiles={composer.onPickDraftFiles} onNativePasteImages={composer.onNativePasteImages} + onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f6ad273d9037..5c74e52cfac1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -44,6 +44,7 @@ import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, + type ComposerDraftInsertion, clearComposerDraft, composerDraftsAtom, createNewTaskDraft, @@ -203,7 +204,10 @@ type NewTaskFlowContextValue = { readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ - readonly appendAttachments: (attachments: ReadonlyArray) => number; + readonly appendAttachments: ( + attachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ) => number; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; @@ -600,12 +604,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Returns how many attachments the live cap rejected so the caller can // tell the user (a concurrent add can fill the draft mid-pick). const appendAttachments = useCallback( - (nextAttachments: ReadonlyArray): number => { + ( + nextAttachments: ReadonlyArray, + insertion?: ComposerDraftInsertion, + ): number => { if (!selectedProjectDraftKey) { return 0; } return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments, { appendReference: true, + insertion, }); }, [selectedProjectDraftKey], diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 7c20f802477d..23d9ac3b9a04 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -212,12 +212,7 @@ export function UsageLimitsSection({ const colors = useProviderColors(); return ( - {failedLabels.length ? ( - - {failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - ) : null} - {pools.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." @@ -243,11 +238,32 @@ export function UsageLimitsSection({ ))} ))} - {notices.map((notice) => ( - - {notice} - - ))} + {notices.length > 0 || failedLabels.length > 0 ? ( + + + + {notices.map((notice) => ( + + {notice} + + ))} + {failedLabels.length > 0 ? ( + + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + ) : null} + + + ) : null} ); } diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 417a66138d95..1986a3266fa9 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -90,6 +90,13 @@ describe("appearancePreferences", () => { expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); + it("preserves the no-wrap default unless wrapping is explicitly enabled", () => { + expect(resolveAppearancePreferences(undefined).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({}).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: null }).codeWordBreak).toBe(false); + expect(resolveAppearancePreferences({ codeWordBreak: false }).codeWordBreak).toBe(false); + }); + it("returns the authored text scale at the 16pt default", () => { expect(DEFAULT_BASE_FONT_SIZE).toBe(16); diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index b7b8397cc6ec..b371523a89c2 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -11,6 +11,7 @@ import type { FileBackedComposerAttachment } from "./composerImages"; import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; import { useRefreshAssetUrl } from "../state/assets"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; const isLocalUri = (uri: string) => /^(file|content):/.test(uri); @@ -32,8 +33,6 @@ export function useAttachmentDocument(input: { }) { const kind = filePreviewKind(input); const delimiter = filePreviewDelimiter(input); - const renderedMode = - kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; const shareController = useRef(null); useEffect(() => () => shareController.current?.abort(), []); const resource = useMemo( @@ -61,6 +60,12 @@ export function useAttachmentDocument(input: { const [contentError, setContentError] = useState(null); const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); const [rendered, setRendered] = useState(true); + const presentation = attachmentDocumentPresentation({ + kind, + hasTable: table !== null, + hasEnvironment: input.environmentId !== null, + rendered, + }); const [revision, setRevision] = useState(0); const [sharing, setSharing] = useState(false); const uri = input.attachment ? localUri : remoteUri; @@ -185,7 +190,7 @@ export function useAttachmentDocument(input: { }; return { kind, - renderedMode, + ...presentation, uri, /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ resource, diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts new file mode 100644 index 000000000000..9f63567cfcd2 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "vite-plus/test"; +import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; + +it.each([ + { + kind: "markdown", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: "markdown", + activeMode: "markdown", + }, + { + kind: "markdown", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "markdown", + activeMode: "source", + }, + { + kind: "text", + hasTable: false, + hasEnvironment: true, + rendered: true, + renderedMode: null, + activeMode: "source", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: false, + rendered: true, + renderedMode: "table", + activeMode: "table", + }, + { + kind: "text", + hasTable: true, + hasEnvironment: true, + rendered: false, + renderedMode: "table", + activeMode: "source", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: false, + rendered: true, + renderedMode: "html", + activeMode: "html", + }, + { + kind: "html", + hasTable: false, + hasEnvironment: true, + rendered: false, + renderedMode: "html", + activeMode: "source", + }, +] as const)("matches the available preview for %j", ({ renderedMode, activeMode, ...input }) => { + expect(attachmentDocumentPresentation(input)).toEqual({ renderedMode, activeMode }); +}); diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.ts new file mode 100644 index 000000000000..0d8136262ae9 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocumentPresentation.ts @@ -0,0 +1,21 @@ +import type { FilePreviewKind } from "@t3tools/shared/filePreview"; + +/** The available preview and selected body must agree, including source-only draft files. */ +export function attachmentDocumentPresentation(input: { + kind: FilePreviewKind; + hasTable: boolean; + hasEnvironment: boolean; + rendered: boolean; +}) { + const renderedMode = input.hasTable + ? "table" + : input.kind === "markdown" && input.hasEnvironment + ? "markdown" + : input.kind === "html" + ? "html" + : null; + return { + renderedMode, + activeMode: input.rendered && renderedMode !== null ? renderedMode : "source", + } as const; +} diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index ea56d3f50808..2c6b27864432 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -360,7 +360,14 @@ describe("prepareTurnAttachments", () => { }); it("uploads generic file bytes directly and keeps mixed attachment order", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + const pastedFile = { + ...file, + source: { _tag: "pasted-text" as const }, + }; + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [pastedFile, image], + }); expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", @@ -379,11 +386,12 @@ describe("prepareTurnAttachments", () => { name: "report.pdf", mimeType: "application/pdf", sizeBytes: 42, + source: { _tag: "pasted-text" }, }); expect(prepared.attachments[1]?.type).toBe("image"); expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); expect(prepared.draftAttachments[0]).toEqual({ - ...file, + ...pastedFile, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId, }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 815fc9826b34..10f4fdb7c6c7 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -198,7 +198,11 @@ function uploadedReference( // chat view with nothing to show a thumbnail from, on every client. return isComposerImageAttachment(attachment) ? { type: "image", ...fields } - : { type: "file", ...fields }; + : { + type: "file", + ...fields, + ...(attachment.source ? { source: attachment.source } : {}), + }; } function attachmentUploadInput(attachment: DraftComposerAttachment) { diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 41aced8c98e5..bf87e1e000da 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, PastedTextAttachmentSource } from "@t3tools/contracts"; export const DraftComposerImageAttachmentSchema = Schema.Struct({ id: Schema.String, @@ -29,6 +29,7 @@ export const DraftComposerFileAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, fileUri: Schema.String, + source: Schema.optional(PastedTextAttachmentSource), uploadedAttachmentId: Schema.optional(Schema.String), uploadEnvironmentId: Schema.optional(EnvironmentId), }); diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 1eab26588286..3e05c578f3f3 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,14 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; -const files = new Map(); +const files = new Map(); + +const clipboard = vi.hoisted(() => ({ + hasImageAsync: vi.fn(), + getImageAsync: vi.fn(), + hasStringAsync: vi.fn(), + getStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => clipboard); vi.mock("expo-file-system", () => ({ File: class { readonly uri: string; + readonly name: string; + readonly parentDirectory: { readonly uri: string }; - constructor(uri: string) { - this.uri = uri; + constructor(parent: string | { readonly uri: string }, name?: string) { + const parentUri = typeof parent === "string" ? parent : parent.uri; + this.uri = name ? `${parentUri}/${name}` : parentUri; + this.name = name ?? this.uri.split("/").at(-1) ?? "file"; + this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) }; } get exists(): boolean { @@ -29,14 +43,90 @@ vi.mock("expo-file-system", () => ({ entry.deleted = true; } } + + create(): void { + files.set(this.uri, { base64: "", deleted: false }); + } + + write(text: string): void { + files.set(this.uri, { base64: "", deleted: false, text }); + } + + moveSync(destination: { readonly uri: string }): void { + const entry = files.get(this.uri); + if (!entry) throw new Error("missing staged file"); + files.set(destination.uri, entry); + files.delete(this.uri); + } }, + Directory: class { + readonly uri: string; + + constructor(parent: string, name: string) { + this.uri = `${parent}/${name}`; + } + + create(): void {} + }, + Paths: { document: "file:///documents" }, })); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + createPastedTextComposerAttachment, + isOwnedPastedImageUri, + pasteComposerClipboard, +} from "./composerImages"; + +describe("composer clipboard paste", () => { + beforeEach(() => { + vi.clearAllMocks(); + clipboard.hasImageAsync.mockResolvedValue(false); + clipboard.hasStringAsync.mockResolvedValue(true); + clipboard.getStringAsync.mockResolvedValue("clipboard text"); + clipboard.getImageAsync.mockResolvedValue({ data: "data:image/png;base64,aGVsbG8=" }); + }); + + it("returns only the image when the clipboard contains both image and text", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + const result = await pasteComposerClipboard({ existingCount: 0 }); + expect(result).toEqual({ + images: [expect.objectContaining({ type: "image", name: "pasted-image.png" })], + text: null, + error: null, + }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("does not paste alternate text when the image cannot fit", async () => { + clipboard.hasImageAsync.mockResolvedValue(true); + expect( + await pasteComposerClipboard({ existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }), + ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") }); + expect(clipboard.getStringAsync).not.toHaveBeenCalled(); + }); + + it("returns plain text without image chips", async () => { + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: "clipboard text", + error: null, + }); + }); + + it("reports an empty text clipboard", async () => { + clipboard.getStringAsync.mockResolvedValue(""); + expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ + images: [], + text: null, + error: "Clipboard is empty.", + }); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { @@ -91,6 +181,26 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); + + it("persists folded text unchanged in the app-owned attachment directory", async () => { + const text = "first line\nUnicode: 🙂\n"; + const attachment = await createPastedTextComposerAttachment({ + text, + name: "pasted-text.txt", + maxBytes: 1024, + }); + + expect(attachment).toEqual({ + id: "attachment-id", + type: "file", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: new TextEncoder().encode(text).byteLength, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt", + source: { _tag: "pasted-text" }, + }); + expect(files.get(attachment.fileUri)?.text).toBe(text); + }); }); describe("composerStripAttachments", () => { @@ -119,21 +229,39 @@ describe("composerStripAttachments", () => { fileUri: "file:///notes.txt", }; - it("keeps media even when it already has an inline chip", async () => { + it("keeps media, because a thumbnail is the only way to see it", async () => { const { composerStripAttachments } = await import("./composerImages"); - const kept = composerStripAttachments([image, video] as never, new Set(["img-1", "vid-1"])); - // A thumbnail is the only way to see media, so it stays regardless of the chip. + const kept = composerStripAttachments([image, video] as never); expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); }); - it("drops a plain file once its inline chip represents it", async () => { + it("never shows a non-media file above the composer", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set(["doc-1"]))).toEqual([]); + // A document reads as its inline chip. A tile with a generic glyph says less than the + // chip does, so it is not a fallback worth having, chip present or not. + expect(composerStripAttachments([doc] as never)).toEqual([]); + }); + + it("keeps media beside a document rather than dropping the whole strip", async () => { + const { composerStripAttachments } = await import("./composerImages"); + expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([ + "img-1", + "vid-1", + ]); }); - it("keeps a plain file that has no inline chip", async () => { + it("treats a picture picked through the document picker as media", async () => { const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never, new Set()).map((a) => a.id)).toEqual(["doc-1"]); + // The document picker types every pick as a plain file; what it *is* decides the strip. + const pickedImage = { + id: "pick-1", + type: "file" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 30, + fileUri: "file:///photo.png", + }; + expect(composerStripAttachments([pickedImage] as never).map((a) => a.id)).toEqual(["pick-1"]); }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 86658db8dc3b..391bf633e557 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -8,6 +8,7 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type EnvironmentId, + type PastedTextAttachmentSource, type UploadChatImageAttachment, } from "@t3tools/contracts"; import type { DocumentPickerResult } from "expo-document-picker"; @@ -21,6 +22,7 @@ import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; +import { writeFileAtomically } from "./atomic-file"; export interface DraftComposerImageAttachment extends Omit { readonly id: string; @@ -40,27 +42,59 @@ export interface DraftComposerFileAttachment { readonly mimeType: string; readonly sizeBytes: number; readonly fileUri: string; + readonly source?: PastedTextAttachmentSource; readonly uploadedAttachmentId?: string; readonly uploadEnvironmentId?: EnvironmentId; } +export async function createPastedTextComposerAttachment(input: { + readonly text: string; + readonly name: string; + readonly maxBytes: number; +}): Promise { + const bytes = new TextEncoder().encode(input.text).byteLength; + if (bytes <= 0) { + throw new Error("Clipboard is empty."); + } + if (bytes > input.maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); + } + + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, `${uuidv4()}-${input.name}`); + await writeFileAtomically(file, input.text); + return { + id: uuidv4(), + type: "file", + name: input.name, + mimeType: "text/plain;charset=utf-8", + sizeBytes: bytes, + fileUri: file.uri, + source: { _tag: "pasted-text" }, + }; +} + export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; /** - * What the strip above the composer shows. Media previews there because a thumbnail is the - * only way to see it; every other file is already legible as its inline chip, so it only - * falls back to the strip when the prompt carries no reference to it. Mirrors web's - * `composerOtherFilesForPresentation`. + * What the strip above the composer shows: media, and nothing else. A thumbnail is the only + * way to see a picture or a video, so those always preview there. Everything else reads as + * its inline chip, which carries the name, the type and the size in the line of prose the + * file belongs to — a square tile showing a generic document glyph says strictly less. + * + * The chip is not optional for a non-media file. Every path that attaches one also writes + * its reference, so a file with no chip means the draft lost it rather than that the strip + * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces + * whose attachments never get chips (a question answer) pass them to the strip directly + * instead of through this filter. */ export function composerStripAttachments( attachments: ReadonlyArray, - inlineAttachmentIds: ReadonlySet, ): ReadonlyArray { return attachments.filter( - (attachment) => - isComposerImageAttachment(attachment) || - videoMimeType(attachment) !== null || - !inlineAttachmentIds.has(attachment.id), + (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null, ); } @@ -539,11 +573,19 @@ export async function pickComposerMedia(input: { }; } -export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ - readonly images: ReadonlyArray; - readonly text: string | null; - readonly error: string | null; -}> { +/** Clipboard images take priority over their alternate text representation. */ +export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise< + | { + readonly images: ReadonlyArray; + readonly text: null; + readonly error: string | null; + } + | { + readonly images: readonly []; + readonly text: string; + readonly error: null; + } +> { let clipboard: Awaited>; try { clipboard = await loadClipboard(); @@ -605,8 +647,7 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const text = await clipboard.getStringAsync(); return { images: [], - text: text.length > 0 ? text : null, - error: text.length > 0 ? null : "Clipboard is empty.", + ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }), }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index fb85ce1e1cc3..4e301288c94f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", () ).toBeNull(); }); -it("makes attachment-only question answers expandable in the mobile feed", () => { +it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => { + const turnId = TurnId.make("turn-answer"); const answer = { requestId: ApprovalRequestId.make("question-request"), answers: { q: "" }, @@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () => id: ThreadId.make("thread-answer"), projectId: ProjectId.make("project-answer"), title: "Answer history", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-09-08T00:00:00.000Z", + startedAt: "2026-09-08T00:00:00.000Z", + completedAt: "2026-09-08T00:00:04.000Z", + assistantMessageId: null, + }, activities: [ + makeActivity({ + id: EventId.make("tool-before-answer"), + createdAt: "2026-09-08T00:00:01.000Z", + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + payload: { itemType: "command_execution", status: "completed" }, + }), makeActivity({ id: EventId.make("answer-submitted"), - createdAt: "2026-09-08T00:00:00.000Z", + createdAt: "2026-09-08T00:00:02.000Z", kind: "user-input.answer-submitted", summary: "Answered questions", + turnId, payload: answer, }), + makeActivity({ + id: EventId.make("tool-after-answer"), + createdAt: "2026-09-08T00:00:03.000Z", + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + payload: { itemType: "command_execution", status: "completed" }, + }), ], }); - const [group] = buildThreadFeed(thread); + const feed = buildThreadFeed(thread); + expect(feed).toHaveLength(3); + const group = feed[1]; expect(group?.type).toBe("activity-group"); if (group?.type !== "activity-group") return; expect(group.activities[0]).toMatchObject({ @@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () => workEntry: { questionAnswer: answer }, }); expect(group.activities[0]?.getFullDetail()).toBeNull(); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]); + expect(collapsed[1]).toBe(group); + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.type)).toEqual([ + "turn-fold", + "work-toggle", + "activity-group", + "work-toggle", + ]); + expect(expanded[2]).toBe(group); + const running = deriveThreadFeedPresentation( + feed, + { ...thread.latestTurn!, state: "running", completedAt: null }, + new Set(), + new Set(), + "2026-09-08T00:00:00.000Z", + ); + expect(running[0]?.type).toBe("work-toggle"); + expect(running[1]).toBe(group); + expect(running[2]?.type).toBe("work-toggle"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b7d5018edd98..f1550042eae8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup( ); } +function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { + return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); +} + function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; @@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th continue; } - const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction"; - if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) { + const isStandalone = + entry.activity.workEntry.sourceActivityKind === "context-compaction" || + entry.activity.workEntry.questionAnswer !== undefined; + if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) { flushGroup(); } firstActivityEntry ??= entry; openGroupActivities.push(entry.activity); - if (isCompaction) { + if (isStandalone) { flushGroup(); } } @@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds( entries .filter( (entry) => - entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + entry.id !== firstAssistantMessageId && + entry.id !== terminalAssistantMessageId && + !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), ) .map((entry) => entry.id), ); @@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry( result.push(entry); return; } - if (isContextCompactionActivityGroup(entry)) { + if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) { result.push(entry); return; } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index a9b3111aa67a..eecae6660ab9 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -52,6 +54,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -81,8 +90,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: () => void; @@ -108,6 +120,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, onSubmit, @@ -282,6 +295,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} + maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={style as StyleProp} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -315,7 +330,36 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + onComposerPasteText={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} onComposerFocus={onFocus} onComposerBlur={onBlur} onComposerSubmit={onSubmit} @@ -327,4 +371,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9aa7e9e72814..92a4e1e2c180 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,3 +1,5 @@ +import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { composerContextEditorTokens } from "../lib/composerContext"; import { requireNativeView } from "expo"; @@ -55,6 +57,13 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ readonly uris: ReadonlyArray; }>; +type NativePasteTextEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}>; + interface NativeComposerEditorRef { focus: () => Promise; blur: () => Promise; @@ -85,8 +94,11 @@ interface NativeComposerEditorProps extends ViewProps { event: NativeSyntheticEvent<{ source: string; start: number; end: number }>, ) => void; readonly onComposerPasteContext?: ( - event: NativeSyntheticEvent<{ text: string; fragment: string; html: string }>, + event: NativePasteTextEvent & NativeSyntheticEvent<{ fragment: string; html: string }>, ) => void; + readonly textPasteThresholdBytes: number; + readonly maxInputChars: number; + readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; } @@ -111,6 +123,7 @@ export function ComposerEditor({ onChangeText, onSelectionChange, onPasteImages, + onPasteText, onFocus, onBlur, contentInsetVertical = 0, @@ -291,6 +304,8 @@ export function ComposerEditor({ autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} + textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} + maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={{ flex: 1, minHeight: 0 }} onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( @@ -325,7 +340,36 @@ export function ComposerEditor({ }} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} - onComposerPasteContext={(event) => props.onPasteContext?.(event.nativeEvent)} + onComposerPasteContext={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + props.onPasteContext?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} + onComposerPasteText={(event) => { + const paste = event.nativeEvent; + const acknowledgedEventCount = acceptNativeEvent( + paste.eventCount, + paste.value, + paste.selection, + ); + if (acknowledgedEventCount === false) return; + // Synchronize the draft before an async paste captures its insertion target. + // React props can still precede the last native keystroke. + onChangeText(paste.value); + onSelectionChange?.(paste.selection); + onPasteText?.(paste); + setMostRecentEventCount(acknowledgedEventCount); + forceNativeEventRender((sequence) => sequence + 1); + }} onComposerFocus={onFocus} onComposerBlur={onBlur} /> @@ -337,4 +381,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 07a409c9a48f..9ff7f41a6eba 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -12,6 +12,7 @@ export function ComposerEditor({ skills: _skills, selection, onPasteImages, + onPasteText: _onPasteText, style, textStyle, contentInsetVertical = 0, @@ -65,4 +66,5 @@ export type { ComposerEditorHandle, ComposerEditorProps, ComposerEditorSelection, + ComposerTextPaste, } from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index dc1448e87d76..8985add81925 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -7,6 +7,13 @@ export type ComposerEditorSelection = { readonly end: number; }; +export type ComposerTextPaste = { + readonly value: string; + readonly eventCount: number; + readonly text: string; + readonly selection: ComposerEditorSelection; +}; + export interface ComposerEditorHandle { focus: () => void; blur: () => void; @@ -18,11 +25,12 @@ export interface ComposerEditorProps { readonly value: string; readonly context?: OrchestrationMessageContext; readonly clipboardFragment?: string; - readonly onPasteContext?: (clipboard: { - readonly text: string; - readonly fragment: string; - readonly html: string; - }) => void; + readonly onPasteContext?: ( + clipboard: ComposerTextPaste & { + readonly fragment: string; + readonly html: string; + }, + ) => void; readonly skills?: ReadonlyArray< Pick & Partial> @@ -50,6 +58,7 @@ export interface ComposerEditorProps { readonly start: number; readonly end: number; }) => void; + readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; /** Invoked by the native editor when Command-Return is pressed on a hardware keyboard. */ diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index ccc2214e24c2..30f3ba23eef8 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -192,3 +192,25 @@ describe("assumeComposerControlledState", () => { ); }); }); + +describe("typing immediately before an intercepted paste", () => { + it("keeps the pre-paste React value behind the native paste revision", () => { + const snapshots = [ + { eventCount: 0, value: "", selection: { start: 0, end: 0 } }, + { eventCount: 1, value: "typed", selection: { start: 5, end: 5 } }, + { eventCount: 2, value: "typed", selection: { start: 0, end: 5 } }, + ]; + // Both native platforms stamp the paste with its current value and selection. + // A render still carrying the typing caret cannot overwrite that selection. + expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 2, snapshots)).toBe( + 1, + ); + expect(isComposerNativeEcho("typed", { start: 0, end: 5 }, 2, snapshots)).toBe(true); + // The replacement is a parent edit at the acknowledged paste revision. + expect(resolveComposerControlledEventCount("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe( + 2, + ); + expect(isComposerNativeEcho("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(false); + expect(acknowledgeComposerNativeEvent(2, 1)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/remote-environment-projections.test.ts b/apps/mobile/src/state/remote-environment-projections.test.ts index c0877c2d1942..c2540a97aaa0 100644 --- a/apps/mobile/src/state/remote-environment-projections.test.ts +++ b/apps/mobile/src/state/remote-environment-projections.test.ts @@ -29,7 +29,7 @@ function presentation( serverConfig: ServerConfig | null = null, ): EnvironmentPresentation { return { - entry: { target: target(environmentId, endpoint), profile: Option.none() }, + entry: { target: target(environmentId, endpoint), profile: Option.none(), enabled: true }, connection: { phase: "connected", error: null, traceId: null }, serverConfig, }; diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 89abd3c222e2..16a9d6bd5ccb 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary { readonly environmentLabel: string; readonly displayUrl: string; readonly isRelayManaged: boolean; + /** False when the user switched the environment off in Settings. */ + readonly isEnabled: boolean; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a97d84d256c4..6da72860009e 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -148,10 +148,14 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ })); import type { DraftComposerAttachment } from "../lib/composerImages"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + getComposerDraftAfterSelection, archiveCloudComposerDrafts, clearComposerDraftContent, clearComposerDraftContentState, @@ -181,6 +185,7 @@ import { retargetNewTaskDraft, setComposerDraftText, insertComposerDraftContext, + insertComposerDraftText, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -233,6 +238,81 @@ function contextDraft(start: number, count: number): ComposerDraft { } describe("mobile composer drafts", () => { + it.each([false, true])( + "restores visible file chips from legacy drafts (archived: %s)", + async (archived) => { + const file = { + type: "file" as const, + id: "legacy-file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const image = { ...file, id: "photo", name: "photo.png", mimeType: "image/png" }; + const video = { ...file, id: "video", name: "clip.mp4", mimeType: "video/mp4" }; + const legacy = { text: "Review these", attachments: [file, image, video] }; + const document = { + schemaVersion: 1, + drafts: archived ? {} : { thread: legacy }, + ...(archived + ? { signedOutDrafts: { account: { drafts: { thread: legacy }, queuedMessages: [] } } } + : {}), + }; + const decoded = decodePersistedComposerState(document); + const restored = archived + ? decoded.cloudDrafts.signedOut.account?.drafts.thread + : decoded.drafts.thread; + expect(restored?.text).toBe("Review these [notes.txt](t3-context://v1/file/legacy-file) "); + expect(restored?.attachments).toEqual(legacy.attachments); + expect(restored?.context?.records).toEqual([ + expect.objectContaining({ kind: "file", attachmentId: file.id }), + ]); + expect( + decodePersistedComposerState({ schemaVersion: 1, drafts: { thread: restored } }).drafts + .thread, + ).toEqual(restored); + appAtomRegistry.set(composerDraftsAtom, { thread: restored! }); + setComposerDraftText("thread", "Review these"); + expect(getComposerDraftSnapshot("thread").attachments).toEqual([image, video]); + await releaseUnusedComposerAttachmentFiles([file]); + }, + ); + + it("restores a missing file reference without duplicating its record or replacing another record's id", () => { + const file = { + type: "file" as const, + id: "file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 10, + fileUri: "file:///notes.txt", + }; + const existing = { + version: 1, + contextId: "original", + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }; + const skill = { version: 1, contextId: "file", kind: "skill", label: "Skill", name: "skill" }; + for (const records of [[existing, skill], [skill]]) { + const restored = decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + thread: { text: "", attachments: [file], context: { version: 1, records } }, + }, + }).drafts.thread; + expect(restored?.context?.records).toHaveLength(2); + expect(restored?.context?.records).toContainEqual(skill); + expect(restored?.text).toBe( + `[notes.txt](t3-context://v1/file/${records.length === 2 ? "original" : "file_2"}) `, + ); + } + }); it.each([false, true])( "restores deleted file chips and releases undo history (uploaded: %s)", async (uploaded) => { @@ -303,6 +383,30 @@ describe("mobile composer drafts", () => { expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); }); + it("gives a folded paste a chip that survives the send", () => { + const key = "environment-1:thread-1"; + // What `createPastedTextComposerAttachment` produces for a long paste. + const pasted = { + type: "file" as const, + id: "pasted-1", + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///pasted-text.txt", + }; + appendComposerDraftAttachments(key, [pasted], { appendReference: true }); + + const draft = getComposerDraftSnapshot(key); + // Visible in the composer before sending, not only once the message lands. + expect(draft.text).toContain("pasted-text.txt"); + expect(draft.context?.records).toMatchObject([ + { kind: "file", attachmentId: pasted.id, name: pasted.name }, + ]); + // The reference points at the record, so the chip stays a chip in the sent message. + const [record] = draft.context?.records ?? []; + expect(draft.text).toContain(String(record?.contextId)); + }); + it("drops chips and records for attachments a replace no longer keeps", () => { const key = "new-task:draft-1"; const kept = { @@ -426,41 +530,57 @@ describe("mobile composer drafts", () => { expect(reloaded.cloudDrafts.signedOut).toEqual({}); }); - it("removes a file only after its last reference is deleted, while retaining images", async () => { - const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => outboxLoad.mockRestore()); - const cleanup = Promise.withResolvers(); - composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { - cleanup.resolve(); - }); - const key = "environment-1:remove-context-files"; - const file = { - id: "file-1", - type: "file" as const, - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 4, - fileUri: "file:///notes.txt", - }; - const image = { - ...file, - id: "image-1", - type: "image" as const, - name: "image.png", - mimeType: "image/png", - fileUri: "file:///image.png", - previewUri: "file:///image.png", - }; - appendComposerDraftAttachments(key, [file, image], { appendReference: true }); - const fileLink = "[notes.txt](t3-context://v1/file/file-1)"; - setComposerDraftText(key, `${fileLink} ${fileLink}`); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, fileLink); - expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); - setComposerDraftText(key, "plain text"); - expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); - await cleanup.promise; - }); + it.each([ + { name: "notes.txt", mimeType: "text/plain" }, + { name: "document-photo.png", mimeType: "image/png" }, + { name: "recording.mp4", mimeType: "video/mp4" }, + ])( + "removes $name after its last reference is deleted, while retaining native images", + async ({ name, mimeType }) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + }); + const key = "environment-1:remove-context-files"; + const file = { + id: "file-1", + type: "file" as const, + name, + mimeType, + sizeBytes: 4, + fileUri: `file:///${name}`, + }; + const image = { + ...file, + id: "image-1", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + fileUri: "file:///image.png", + previewUri: "file:///image.png", + }; + appendComposerDraftAttachments(key, [file, image], { appendReference: true }); + const fileLink = formatComposerContextReference( + getComposerDraftSnapshot(key).context!.records[0]!, + ); + setComposerDraftText(key, `${fileLink} ${fileLink}`); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + setComposerDraftText(key, fileLink); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file, image]); + expect( + countComposerDraftAttachmentsAfterSelection(key, { + text: fileLink, + start: 0, + end: fileLink.length, + }), + ).toBe(1); + setComposerDraftText(key, "plain text"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([image]); + await cleanup.promise; + }, + ); it("rejects attachments atomically when no context slots remain", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); @@ -506,6 +626,190 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key)).toEqual(before); await cleanup.promise; }); + it.each(["environment-1:thread", "environment-1:new-task:draft"])( + "preserves the captured paste selection across async writes in %s", + async (key) => { + const file = { + id: "paste", + type: "file" as const, + name: "pasted-text.txt", + mimeType: "text/plain", + sizeBytes: 40_000, + fileUri: "file:///paste.txt", + }; + setComposerDraftText(key, "before selected after"); + const insertion = captureComposerDraftInsertion(key, { start: 7, end: 15 }); + const write = Promise.withResolvers(); + const pending = write.promise.then((attachment) => + appendComposerDraftAttachments(key, [attachment], { appendReference: true, insertion }), + ); + rememberComposerDraftSelection(key, insertion.text, { start: 0, end: 6 }); + rememberComposerDraftSelection("another-draft", "unrelated", { start: 0, end: 9 }); + write.resolve(file); + expect(await pending).toBe(0); + const draft = getComposerDraftSnapshot(key); + expect(draft.text).toBe("before [pasted-text.txt](t3-context://v1/file/paste) after"); + expect(draft.attachments).toEqual([file]); + }, + ); + + it("preserves edits made while a paste is pending instead of deleting stale offsets", async () => { + const key = "environment-1:typing-during-paste"; + setComposerDraftText(key, "old selection"); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: 13 }); + const write = Promise.withResolvers(); + const pending = write.promise.then(() => insertComposerDraftText(key, " pasted", insertion)); + setComposerDraftText(key, "keep newly typed text"); + write.resolve(); + await pending; + expect(getComposerDraftSnapshot(key).text).toBe("keep newly typed text pasted"); + }); + + it.each(["attachment", "context", "imported context"])( + "releases a selected file when replaced by %s", + async (kind) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:replace-file"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `file-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; + if (kind === "attachment") { + expect( + appendComposerDraftAttachments(key, [replacement], { appendReference: true, insertion }), + ).toBe(0); + } else if (kind === "imported context") { + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("replacement"), + attachmentId: replacement.id, + label: replacement.name, + name: replacement.name, + mimeType: replacement.mimeType, + sizeBytes: replacement.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [replacement], + }, + insertion, + ), + ).toBe(true); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); + expect(getComposerDraftSnapshot(key).text).toBe( + `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, + ); + } else { + insertComposerDraftContext( + key, + { text: "replacement", context: { version: 1, records: [] } }, + insertion, + ); + } + const draft = getComposerDraftSnapshot(key); + expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); + expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); + }, + ); + + it("rejects an imported file atomically when edits during import use up its replacement slot", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + return undefined; + }); + const key = "environment-1:concurrent-import"; + const files = Array.from({ length: 8 }, (_, index) => ({ + id: `existing-${index}`, + type: "file" as const, + name: `notes-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + fileUri: `file:///notes-${index}.txt`, + })); + appendComposerDraftAttachments(key, files, { appendReference: true }); + const firstLink = "[notes-0.txt](t3-context://v1/file/existing-0)"; + const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); + setComposerDraftText(key, `New edit ${insertion.text}`); + const edited = getComposerDraftSnapshot(key); + const imported = { ...files[0]!, id: "imported", fileUri: "file:///imported.txt" }; + const record = { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("imported"), + attachmentId: imported.id, + label: imported.name, + name: imported.name, + mimeType: imported.mimeType, + sizeBytes: imported.sizeBytes, + }; + expect( + insertComposerDraftContext( + key, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + attachments: [imported], + }, + insertion, + ), + ).toBe(false); + expect(getComposerDraftSnapshot(key)).toEqual(edited); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(imported.fileUri); + }); + + it("retains a file when replacing only one of its repeated references", () => { + const key = "environment-1:repeat-reference"; + const file = { + id: "repeat", + type: "file" as const, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4, + fileUri: "file:///repeat.txt", + }; + appendComposerDraftAttachments(key, [file], { appendReference: true }); + const link = getComposerDraftSnapshot(key).text; + setComposerDraftText(key, `${link} ${link}`); + const insertion = captureComposerDraftInsertion(key, { start: 0, end: link.length }); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(1); + insertComposerDraftContext( + key, + { text: "replaced", context: { version: 1, records: [] } }, + insertion, + ); + expect(getComposerDraftSnapshot(key).attachments).toEqual([file]); + expect(getComposerDraftSnapshot(key).text).toBe(`replaced ${link}`); + }); + it("inserts context at the saved caret and retains its payload through persistence and restore", () => { const draftKey = "context-environment:context-thread"; const record = { @@ -564,7 +868,25 @@ describe("mobile composer drafts", () => { }, }).drafts, ).toEqual({ - "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + "environment-1:thread-1": { + text: "Review this file [report.pdf](t3-context://v1/file/file-1) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); }); @@ -882,8 +1204,34 @@ describe("mobile composer drafts", () => { const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); onTestFinished(() => enqueue.mockRestore()); await restoreCloudComposerDrafts("account-a"); - expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); - expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(getComposerDraftSnapshot(key)).toEqual( + type === "image" + ? { text: "Unsent notes", attachments: [file] } + : { + text: "Unsent notes [notes.pdf](t3-context://v1/file/local-notes) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, + ); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe( + type === "image" + ? "Edited queued task" + : "Edited queued task [notes.pdf](t3-context://v1/file/local-notes) ", + ); expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); const persisted = decodePersistedComposerState( @@ -2314,7 +2662,25 @@ describe("mobile composer drafts", () => { await fresh.releaseUnusedComposerAttachmentFiles([file]); expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ - "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, + "environment-1:thread-1": { + text: "Persisted draft [report.pdf](t3-context://v1/file/file-cold-start) ", + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + contextId: file.id, + kind: "file", + label: file.name, + attachmentId: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + ], + }, + }, }); expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index b8ec4276b3cf..734199dcef5e 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -24,11 +24,13 @@ import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext"; import { + collectComposerContextReferences, formatComposerContextReference, sanitizeComposerContextLabel, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, @@ -95,6 +97,89 @@ export function readComposerDraftSelection( return { start: lastComposerSelection.start, end: lastComposerSelection.end }; } +export interface ComposerDraftInsertion { + readonly text: string; + readonly start: number; + readonly end: number; +} + +/** Capture the paste target before any clipboard reads, downloads, or file writes. */ +export function captureComposerDraftInsertion( + draftKey: string, + selection?: { start: number; end: number }, +): ComposerDraftInsertion { + const { text } = getComposerDraftSnapshot(draftKey); + return { + text, + ...(selection ?? + readComposerDraftSelection(draftKey, text) ?? { start: text.length, end: text.length }), + }; +} + +function contextInsertionRange( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const captured = + target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null); + // Edits made during a file write must not be replaced using stale offsets. A changed + // draft receives the file at its end; moving only the caret preserves the captured range. + const selection = captured?.text === draft.text ? captured : null; + const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); + return { start, end: Math.max(start, Math.min(selection?.end ?? start, draft.text.length)) }; +} + +function draftWithoutInsertionSelection( + draftKey: string, + draft: ComposerDraft, + target?: ComposerDraftInsertion, +) { + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = `${draft.text.slice(0, start)} ${draft.text.slice(end)}`; + return withReferencedContextFiles(draft, text, referencedComposerContext(text, draft.context)); +} + +export function countComposerDraftAttachmentsAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): number { + return getComposerDraftAfterSelection(draftKey, target).attachments.length; +} + +export function getComposerDraftAfterSelection( + draftKey: string, + target: ComposerDraftInsertion, +): ComposerDraft { + return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target); +} + +function withReferencedContextFiles( + draft: ComposerDraft, + text: string, + context: OrchestrationMessageContext | undefined, +): ComposerDraft { + const previousIds = new Set( + draft.context?.records.flatMap((record) => + "attachmentId" in record ? [record.attachmentId] : [], + ), + ); + const retainedIds = new Set( + context?.records.flatMap((record) => ("attachmentId" in record ? [record.attachmentId] : [])), + ); + return { + ...draft, + text, + context, + attachments: draft.attachments.filter( + (attachment) => + attachment.type === "image" || + !previousIds.has(attachment.id) || + retainedIds.has(attachment.id), + ), + }; +} + /** Retains file bytes while native text undo can restore their references. */ export function createComposerDraftContextHistory() { const restoreContext = createComposerContextHistory(); @@ -162,16 +247,36 @@ export function setComposerDraftContext( export function insertComposerDraftContext( draftKey: string, - content: { text: string; context: OrchestrationMessageContext }, + content: { + text: string; + context: OrchestrationMessageContext; + attachments?: ReadonlyArray; + }, + target?: ComposerDraftInsertion, ): boolean { let inserted = false; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const draft = normalizeDraft(current[draftKey]); - const nextDraft = draftWithInsertedContext(draftKey, draft, content); + const attachments = content.attachments ?? []; + const retained = draftWithoutInsertionSelection(draftKey, draft, target); + if ( + attachments.length > 0 && + retained.attachments.length + attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) + return current; + const nextDraft = draftWithInsertedContext( + draftKey, + { ...draft, attachments: [...draft.attachments, ...attachments] }, + content, + target, + ); if (!nextDraft) return current; inserted = true; + removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment)); return { ...current, [draftKey]: nextDraft }; }); + scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? [])); return inserted; } @@ -179,13 +284,9 @@ function draftWithInsertedContext( draftKey: string, draft: ComposerDraft, content: { text: string; context: OrchestrationMessageContext }, + target?: ComposerDraftInsertion, ): ComposerDraft | null { - const selection = - lastComposerSelection?.draftKey === draftKey && lastComposerSelection.text === draft.text - ? lastComposerSelection - : null; - const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length)); - const end = Math.max(start, Math.min(selection?.end ?? start, draft.text.length)); + const { start, end } = contextInsertionRange(draftKey, draft, target); const before = draft.text.slice(0, start); const after = draft.text.slice(end); const insertion = `${before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content.text) ? " " : ""}${content.text}${!/\s$/.test(content.text) && (after.length === 0 || !/^\s/.test(after)) ? " " : ""}`; @@ -200,7 +301,7 @@ function draftWithInsertedContext( start: start + insertion.length, end: start + insertion.length, }; - return { ...draft, text, context }; + return withReferencedContextFiles(draft, text, context); } export class ComposerDraftPersistenceError extends Schema.TaggedError()( @@ -352,6 +453,68 @@ export function resetComposerDraftsLoadState(): void { persistRetryNeeded = false; } +function attachmentContextRecord( + attachment: DraftComposerAttachment, + contextId = ComposerContextId.make(attachment.id), +) { + const common = { + version: 1 as const, + contextId, + label: sanitizeComposerContextLabel(attachment.name, attachment.type), + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + // A picture picked through the document picker is typed as a plain file, but the + // record has to say what it is or no client will offer to open it as an image. + return attachment.type === "image" || imageMimeType(attachment) !== null + ? { ...common, kind: "image" as const } + : { ...common, kind: "file" as const }; +} + +/** Older drafts stored documents only in the attachment strip. Restore their missing chips. */ +function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft { + const records = [...(draft.context?.records ?? [])]; + const usedIds = new Set(records.map((record) => record.contextId)); + const referenced = new Set( + collectComposerContextReferences(draft.text).map( + (reference) => `${reference.kind}:${reference.contextId}`, + ), + ); + let text = draft.text; + let changed = false; + for (const attachment of draft.attachments) { + if ( + attachment.type === "image" || + imageMimeType(attachment) !== null || + videoMimeType(attachment) !== null + ) + continue; + let record = records.find( + (candidate) => + candidate.kind === "file" && + "attachmentId" in candidate && + candidate.attachmentId === attachment.id, + ); + if (!record) { + const baseId = attachment.id.replace(/[^a-z0-9_-]/gi, "_").slice(0, 110) || "file"; + let contextId = baseId; + for (let suffix = 2; usedIds.has(contextId); suffix += 1) contextId = `${baseId}_${suffix}`; + usedIds.add(contextId); + record = attachmentContextRecord(attachment, ComposerContextId.make(contextId)); + records.push(record); + changed = true; + } + const key = `${record.kind}:${record.contextId}`; + if (referenced.has(key)) continue; + text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatComposerContextReference(record)} `; + referenced.add(key); + changed = true; + } + return changed ? { ...draft, text, context: { version: 1, records } } : draft; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -421,14 +584,15 @@ export function migrateLegacyNewTaskDraft( draft: ComposerDraft, now: string, ): readonly [key: string, draft: ComposerDraft] { + const restored = restoreMissingComposerFileReferences(draft); const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; if (legacy === null) { - return [key, draft]; + return [key, restored]; } return [ newTaskDraftKey(newDraftId()), { - ...draft, + ...restored, project: { environmentId: EnvironmentIdSchema.make(legacy.environmentId), projectId: ProjectIdSchema.make(legacy.projectId), @@ -1089,28 +1253,28 @@ export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const context = referencedComposerContext(value, existing.context); - const retainedIds = new Set(context?.records.map((record) => record.contextId)); - const removedFileIds = new Set( - existing.context?.records.flatMap((record) => - record.kind === "file" && "attachmentId" in record && !retainedIds.has(record.contextId) - ? [record.attachmentId] - : [], - ), - ); - removed = existing.attachments.filter( - (attachment) => attachment.type !== "image" && removedFileIds.has(attachment.id), - ); - const draft = { - ...existing, - text: value, - context, - attachments: existing.attachments.filter((attachment) => !removed.includes(attachment)), - }; + const draft = withReferencedContextFiles(existing, value, context); + removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment)); return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup(removed); } +export function insertComposerDraftText( + draftKey: string, + value: string, + target: ComposerDraftInsertion, +): void { + const draft = getComposerDraftSnapshot(draftKey); + const { start, end } = contextInsertionRange(draftKey, draft, target); + const text = draft.text.slice(0, start) + value + draft.text.slice(end); + setComposerDraftText(draftKey, text); + rememberComposerDraftSelection(draftKey, text, { + start: start + value.length, + end: start + value.length, + }); +} + export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); @@ -1137,6 +1301,7 @@ export function appendComposerDraftAttachments( options?: { readonly allowOverflow?: boolean; readonly appendReference?: boolean; + readonly insertion?: ComposerDraftInsertion; readonly maxAttachments?: number; }, ): number { @@ -1144,8 +1309,12 @@ export function appendComposerDraftAttachments( return 0; } let rejected: ReadonlyArray = []; + let removed: ReadonlyArray = []; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); + const retained = options?.appendReference + ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) + : existing; const remaining = options?.allowOverflow ? attachments.length : Math.max( @@ -1153,10 +1322,10 @@ export function appendComposerDraftAttachments( Math.min( PROVIDER_SEND_TURN_MAX_ATTACHMENTS, options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ) - existing.attachments.length, + ) - retained.attachments.length, ); const contextCapacity = options?.appendReference - ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (existing.context?.records.length ?? 0)) + ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (retained.context?.records.length ?? 0)) : attachments.length; const accepted = attachments.slice(0, Math.min(remaining, contextCapacity)); rejected = attachments.slice(accepted.length); @@ -1165,38 +1334,31 @@ export function appendComposerDraftAttachments( } let draft = { ...existing, attachments: [...existing.attachments, ...accepted] }; if (options?.appendReference) { - const records = accepted.map((attachment) => { - const common = { - version: 1 as const, - contextId: ComposerContextId.make(attachment.id), - label: sanitizeComposerContextLabel(attachment.name, attachment.type), - attachmentId: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }; - // A picture picked through the document picker is typed as a plain file, but the - // record has to say what it is or no client will offer to open it as an image. - return attachment.type === "image" || imageMimeType(attachment) !== null - ? { ...common, kind: "image" as const } - : { ...common, kind: "file" as const }; - }); - const inserted = draftWithInsertedContext(draftKey, draft, { - text: records.map(formatComposerContextReference).join(" "), - context: { version: 1, records }, - }); + const records = accepted.map((attachment) => attachmentContextRecord(attachment)); + const inserted = draftWithInsertedContext( + draftKey, + draft, + { + text: records.map(formatComposerContextReference).join(" "), + context: { version: 1, records }, + }, + options?.insertion, + ); if (!inserted) { rejected = attachments; return current; } draft = { ...inserted, attachments: [...inserted.attachments] }; + removed = existing.attachments.filter( + (attachment) => !draft.attachments.includes(attachment), + ); } return { ...current, [draftKey]: draft, }; }); - scheduleUnusedComposerAttachmentCleanup(rejected); + scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); return rejected.length; } diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4f5f455522bc..16bea31999b3 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() { environmentLabel: environment.environmentLabel, displayUrl: environment.displayUrl, isRelayManaged: environment.isRelayManaged, + isEnabled: environment.isEnabled, connectionState: environment.connectionState, connectionError: environment.connectionError, connectionErrorTraceId: environment.connectionErrorTraceId, @@ -140,6 +141,11 @@ export function useRemoteConnections() { (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), [controller], ); + const onSetEnvironmentEnabled = useCallback( + (environmentId: EnvironmentId, enabled: boolean) => + controller.setEnvironmentEnabled(environmentId, enabled), + [controller], + ); const onUpdateEnvironment = useCallback( ( environmentId: EnvironmentId, @@ -157,8 +163,8 @@ export function useRemoteConnections() { return; } Alert.alert( - "Remove environment?", - `Disconnect and forget ${environment.environmentLabel} on this device.`, + "Remove from this device?", + `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, [ { text: "Cancel", style: "cancel" }, { @@ -184,6 +190,7 @@ export function useRemoteConnections() { onChangeConnectionPairingUrl, onConnectPress, onReconnectEnvironment, + onSetEnvironmentEnabled, onUpdateEnvironment, onRemoveEnvironmentPress, }; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index caa976beef90..d21af7d79cc3 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,6 @@ +import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import { @@ -7,6 +8,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -14,6 +16,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { nextPastedTextFileName, pastedTextDisposition } from "@t3tools/client-runtime/text-paste"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -29,9 +33,11 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, + createPastedTextComposerAttachment, pasteComposerClipboard, pickComposerFiles, pickComposerMedia, + removePersistedComposerAttachmentFile, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -42,7 +48,9 @@ import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; import { appendComposerDraftAttachments, - appendComposerDraftText, + captureComposerDraftInsertion, + countComposerDraftAttachmentsAfterSelection, + insertComposerDraftText, insertComposerDraftContext, clearComposerDraftContent, composerDraftsAtom, @@ -132,6 +140,23 @@ export function useThreadComposerState() { const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const pastedTextFileNamesRef = useRef<{ threadKey: string | null; names: Set }>({ + threadKey: null, + names: new Set(), + }); + const reservePastedTextFileName = useCallback( + (threadKey: string, existingNames: ReadonlyArray) => { + if (pastedTextFileNamesRef.current.threadKey !== threadKey) { + pastedTextFileNamesRef.current = { threadKey, names: new Set() }; + } + const names = pastedTextFileNamesRef.current.names; + for (const name of existingNames) names.add(name); + const nextName = nextPastedTextFileName([...names]); + names.add(nextName); + return nextName; + }, + [], + ); useEffect(() => { ensureComposerDraftsLoaded(); @@ -479,9 +504,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; const result = await pickComposerMedia({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxVideoBytes: capabilities?.attachmentUploads === true ? capabilities.fileAttachments?.maxUploadBytes @@ -489,6 +515,7 @@ export function useThreadComposerState() { }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments, { appendReference: true, + insertion, }); const problems = [ ...(result.error ? [result.error] : []), @@ -514,13 +541,15 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); // pickComposerFiles clamps the advertised limit to the contract maximum. const result = await pickComposerFiles({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), maxBytes, }); const rejectedCount = appendComposerDraftAttachments(threadKey, result.files, { appendReference: true, + insertion, }); // The picker error and the live-cap rejection can both happen in one // pick; report both in a single alert. @@ -541,14 +570,81 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); const result = await pasteComposerClipboard({ - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images, { appendReference: true, + insertion, }); if (result.text) { - appendComposerDraftText(threadKey, result.text); + const currentDraft = getComposerDraftSnapshot(threadKey); + const currentAttachments = currentDraft.attachments; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + const maxBytes = + advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax); + const wouldExceedInputLimit = + currentDraft.text.length - + (currentDraft.text === insertion.text + ? Math.max(0, insertion.end - insertion.start) + : 0) + + result.text.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + const shouldFold = + pastedTextDisposition({ + text: result.text, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + const canAttach = + maxBytes !== null && + countComposerDraftAttachmentsAfterSelection(threadKey, insertion) < + PROVIDER_SEND_TURN_MAX_ATTACHMENTS && + new TextEncoder().encode(result.text).byteLength <= maxBytes; + if (shouldFold && canAttach && maxBytes !== null) { + try { + const attachment = await createPastedTextComposerAttachment({ + text: result.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes, + }); + // Same reference the pasted images above get: a folded paste is only visible + // as its chip until the message is sent. + if ( + appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }) > 0 + ) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + } else if (shouldFold && !wouldExceedInputLimit) { + insertComposerDraftText(threadKey, result.text, insertion); + } else if (shouldFold) { + setPendingConnectionError( + wouldExceedInputLimit + ? "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + : "Could not attach pasted text. Remove an attachment or use a smaller paste, then try again.", + ); + } else { + insertComposerDraftText(threadKey, result.text, insertion); + } } if (result.error) { setPendingConnectionError(result.error); @@ -557,7 +653,12 @@ export function useThreadComposerState() { `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, ); } - }, [composerDrafts, selectedThreadShell]); + }, [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray) => { @@ -566,13 +667,14 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = captureComposerDraftInsertion(threadKey); try { const images = await convertPastedImagesToAttachments({ uris, - existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + existingCount: countComposerDraftAttachmentsAfterSelection(threadKey, insertion), }); if (images.length > 0) { - appendComposerDraftAttachments(threadKey, images, { appendReference: true }); + appendComposerDraftAttachments(threadKey, images, { appendReference: true, insertion }); } } catch (error) { console.error("[native paste] error converting images", { @@ -586,6 +688,55 @@ export function useThreadComposerState() { [composerDrafts, selectedThreadShell], ); + const onNativePasteText = useCallback( + async (paste: ComposerTextPaste) => { + if (!selectedThreadShell) return; + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const advertisedMax = + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined; + if (advertisedMax === undefined) return; + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const insertion = { text: paste.value, ...paste.selection }; + const currentAttachments = getComposerDraftSnapshot(threadKey).attachments; + try { + const attachment = await createPastedTextComposerAttachment({ + text: paste.text, + name: reservePastedTextFileName( + threadKey, + currentAttachments.map((item) => item.name), + ), + maxBytes: clampFileAttachmentUploadBytes(advertisedMax), + }); + // The chip is how a folded paste stays visible: without it the attachment is in the + // draft but nothing in the composer says so until the message is sent. Web folds + // through its ordinary attach path, which always writes a reference; match that. + const rejectedCount = appendComposerDraftAttachments(threadKey, [attachment], { + appendReference: true, + insertion, + }); + if (rejectedCount > 0) { + await removePersistedComposerAttachmentFile(attachment.fileUri); + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); + } + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Could not attach pasted text.", + ); + } + }, + [ + composerDrafts, + reservePastedTextFileName, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadShell, + ], + ); + const onRemoveDraftImage = useCallback( (imageId: string) => { if (!selectedThreadShell) { @@ -663,6 +814,7 @@ export function useThreadComposerState() { onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, + onNativePasteText, onRemoveDraftImage, onSendMessage, onUpdateModelSelection, diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts index e51273d57de3..3070e8ae6b28 100644 --- a/apps/mobile/src/state/workspaceModel.test.ts +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -36,6 +36,7 @@ function environment( wsBaseUrl: "wss://environment.example.test", }), ), + enabled: true, }, connection: { phase, diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 44c43d6c880f..334f6a1643f9 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -10,6 +10,7 @@ export interface WorkspaceEnvironment { readonly environmentLabel: string; readonly displayUrl: string; readonly isRelayManaged: boolean; + readonly isEnabled: boolean; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; @@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment( environmentLabel: environment.label, displayUrl: environment.displayUrl ?? "", isRelayManaged: environment.relayManaged, + isEnabled: environment.entry.enabled, connectionState: environment.connection.phase, connectionError: environment.connection.error, connectionErrorTraceId: environment.connection.traceId, @@ -78,7 +80,10 @@ export function projectWorkspaceState(input: { readonly environments: ReadonlyArray; readonly shellSummary: EnvironmentShellSummary; }): WorkspaceState { - const connectingEnvironments = input.environments.filter( + // Switched-off environments still count as saved connections, but they do + // not drive the overall connection state or surface their last error. + const activeEnvironments = input.environments.filter((environment) => environment.isEnabled); + const connectingEnvironments = activeEnvironments.filter( (environment) => environment.connectionState === "connecting" || environment.connectionState === "reconnecting", @@ -91,12 +96,12 @@ export function projectWorkspaceState(input: { hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, hasReadyEnvironment: input.networkStatus !== "offline" && - input.environments.some((environment) => environment.connectionState === "connected"), + activeEnvironments.some((environment) => environment.connectionState === "connected"), hasConnectingEnvironment: connectingEnvironments.length > 0, connectingEnvironments, - connectionState: overallConnectionState(input.environments, input.networkStatus), + connectionState: overallConnectionState(activeEnvironments, input.networkStatus), connectionError: - input.environments.find((environment) => environment.connectionError !== null) + activeEnvironments.find((environment) => environment.connectionError !== null) ?.connectionError ?? null, shellSnapshotError: input.shellSummary.firstError, latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b68f8f219954..927d2a7d4b6f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -2228,6 +2228,25 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + const pastedTextAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "Investigate this crash", + attachments: [pastedTextAttachment], + }); + const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); + assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -4683,6 +4702,33 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects input that leaves no room for pasted-text attachment context", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + validation.codex.sendTurn.mockClear(); + + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [attachment], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 17ece3767792..cdac979c4dfd 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1593,30 +1593,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Every attachment gets an on-disk path in the prompt so the model's tools // can dereference the actual file. All attachments then go to the adapter, - // and each adapter decides what its provider ingests natively: OpenCode - // sends generic files as file parts, the others send images only and rely - // on the path line for everything else. Unresolvable ids are skipped here - // and surface as adapter errors when the file is read. + // and each adapter decides what its provider ingests natively. Folded + // clipboard text remains path-only everywhere: eagerly embedding it would + // spend the same context the client deliberately preserved by folding it. + // Unresolvable ids are skipped here and surface as adapter errors when the + // file is read. let inputTextWithAttachmentContext = inputTextWithCitations; const appendAttachmentContext = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputTextWithAttachmentContext ? `${inputTextWithAttachmentContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputTextWithAttachmentContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); - appendAttachmentContext( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = appendAttachmentContext( attachmentPath === null ? undefined - : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + : isPastedText + ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` + : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); + if (isPastedText && !appended) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + ); + } } for (const attachment of attachments) { const source = diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b95..aee10782a613 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect("keeps folded clipboard text out of native context", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + yield* fixture.write(pastedText, "A very large crash report"); + + const prompt = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); + }), + ); + + it.effect("rejects missing folded clipboard text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + + const error = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'pasted-text.txt'.", + }); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 17b73e552006..f2f370068181 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -264,6 +264,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi let totalBytes = 0; for (const attachment of input.attachments ?? []) { + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + // ProviderService has already put the file path in the text block. Keep a + // folded clipboard paste lazy so the agent can search or sample it rather + // than paying to embed the entire resource in context immediately. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -296,6 +303,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); + if (isPastedText) { + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); + } + continue; + } const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f0..c66a2f36a207 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 6d7187792a99..a79eff843cc7 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -462,6 +462,13 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } if (!isOpenCodeNativeFilePart(attachment)) { continue; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..27c3a3ff3efe 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,7 +7,12 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + UsageDay, + type UsageSummaryInput, +} from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -16,6 +21,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", @@ -71,6 +79,7 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly environment?: NodeJS.ProcessEnv; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -89,7 +98,10 @@ const serviceLayers = (input: { ), ), Layer.provideMerge( - Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + Layer.succeed(HostProcessEnvironment, { + GROK_HOME: NodePath.join(input.home, "grok"), + ...input.environment, + }), ), ); @@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("reads configured and disabled accounts once across shared and aliased homes", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const codexHome = NodePath.join(home, "codex-account"); + const alias = NodePath.join(home, "codex-alias"); + const claudeHome = NodePath.join(home, "claude-account"); + const grokHome = NodePath.join(home, "grok-account"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + await NodeFSP.mkdir(NodePath.join(claudeHome, "projects"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(claudeHome, "projects", "session.jsonl"), + claudeLine(2, 7), + ); + await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true }); + await NodeFSP.symlink(codexHome, alias, "junction"); + await NodeFSP.writeFile( + NodePath.join(codexHome, "sessions", "rollout.jsonl"), + [ + { type: "session_meta", payload: { id: "codex-account-session" } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + await NodeFSP.mkdir(NodePath.join(grokHome, "sessions", "session"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(grokHome, "sessions", "session", "updates.jsonl"), + encodeUnknownJsonString({ + timestamp: Date.parse("2026-08-01T10:00:00Z") / 1000, + method: "_x.ai/session/update", + params: { + sessionId: "grok-account-session", + update: { + sessionUpdate: "turn_completed", + prompt_id: "prompt-1", + usage: { inputTokens: 10, outputTokens: 13 }, + }, + }, + }) + "\n", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-accounts-test", + home, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + environment: [{ name: "CLAUDE_CONFIG_DIR", value: claudeHome, sensitive: false }], + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], + }, + [ProviderInstanceId.make("codex-alias")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: alias }, + }, + [ProviderInstanceId.make("codex-shadow")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome, shadowHomePath: NodePath.join(home, "shadow") }, + environment: [ + { name: "CODEX_HOME", value: NodePath.join(home, "ignored"), sensitive: false }, + ], + }, + [ProviderInstanceId.make("grok-work")]: { + driver: ProviderDriverKind.make("grok"), + environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], + }, + }, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(summary), 36); + const sources = summary.sources.filter((source) => source.status === "ok"); + assert.strictEqual(sources.length, 4); + assert.strictEqual( + sources.reduce((sum, source) => sum + source.scannedFiles, 0), + 4, + ); + assert.strictEqual( + sources.filter((source) => source.fingerprint.provider === "codex").length, + 1, + ); + }).pipe(Effect.scoped), + ); + + it.live( + "uses explicit account settings before environment and legacy homes, then refreshes them", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const configured = NodePath.join(home, "configured"); + const environmentHome = NodePath.join(home, "environment"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 100)); + for (const [index, root] of [configured, environmentHome].entries()) { + await NodeFSP.mkdir(NodePath.join(root, "projects"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, "projects", "session.jsonl"), + claudeLine(index + 2, index + 7), + ); + } + await NodeFSP.mkdir(NodePath.join(configured, ".claude", "projects"), { + recursive: true, + }); + await NodeFSP.writeFile( + NodePath.join(configured, ".claude", "projects", "wrong.jsonl"), + claudeLine(4, 1000), + ); + }); + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 7); + assert.include( + first.sources.map((source) => source.fingerprint.resolvedHomePath), + NodePath.join(configured, "projects"), + ); + yield* settingsService.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: "" }, + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false }, + ], + }, + }, + }); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 8); + assert.include( + second.sources.map((source) => source.fingerprint.resolvedHomePath), + NodePath.join(environmentHome, "projects"), + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-home-refresh-test", + home, + environment: { CLAUDE_CONFIG_DIR: NodePath.join(home, "host-ignored") }, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: configured }, + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false }, + ], + }, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live( + "uses inherited home variables when explicit default accounts have no home settings", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-inherited-homes-test", + home, + environment: { + CODEX_HOME: NodePath.join(home, "inherited-codex"), + CLAUDE_CONFIG_DIR: NodePath.join(home, "claude"), + }, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: {}, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: {}, + }, + }, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(summary), 5); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "codex")?.fingerprint + .resolvedHomePath, + NodePath.join(home, "inherited-codex", "sessions"), + ); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "grok")?.fingerprint + .resolvedHomePath, + NodePath.join(home, "grok", "sessions"), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -177,8 +411,7 @@ describe("UsageService", () => { exists: (path) => fileSystem.exists(path).pipe( Effect.tap(() => { - if (path !== NodePath.join(home, "claude", ".claude", "projects")) - return Effect.void; + if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; homeProbes += 1; return Deferred.succeed( homeProbes === 1 ? firstScanStarted : secondScanStarted, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..7c942499996e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -15,6 +15,9 @@ import * as NodeOS from "node:os"; import { + ClaudeSettings, + CodexSettings, + type ProviderInstanceConfig, USAGE_CONTRACT_VERSION, type ServerSettings as ServerSettingsValue, type UsageProviderKind, @@ -42,8 +45,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -79,6 +82,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +const decodeCodexSettings = Schema.decodeOption(CodexSettings); +const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); + /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -220,19 +226,6 @@ export const make = Effect.gen(function* () { Effect.withSpan("UsageService.refreshRates"), ); - /** - * Claude's config dir is the home itself when overridden, but a default - * install nests transcripts under `~/.claude/projects`. Probe both. - */ - const resolveClaudeTranscriptDir = (homePath: string) => - Effect.gen(function* () { - const nested = path.join(homePath, ".claude", "projects"); - const nestedExists = yield* fileSystem - .exists(nested) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - return nestedExists ? nested : path.join(homePath, "projects"); - }); - // A settings failure must not silently discard custom rates or transcript homes. const readSettings = settingsService.getSettings.pipe( Effect.catchCause( @@ -249,26 +242,55 @@ export const make = Effect.gen(function* () { const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, ) { - const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); - const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); - const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. - // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. - const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; - const grokHome = - grokHomeEnv.length > 0 - ? path.resolve(expandHomePath(grokHomeEnv)) - : path.join(NodeOS.homedir(), ".grok"); - - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, - { - provider: "grok" as const, - dir: path.join(grokHome, "sessions"), - fileName: "updates.jsonl", - }, - ]; + const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; + const seen = new Set(); + for (const driver of ["claudeAgent", "codex", "grok"] as const) { + // Disabled accounts still have history. Explicit default slots replace + // the legacy settings, just as they do in the provider registry. + const instances: Array> = + Object.values(settings.providerInstances).filter((instance) => instance.driver === driver); + if (!Object.hasOwn(settings.providerInstances, driver)) { + instances.push({ config: settings.providers[driver] }); + } + for (const instance of instances) { + const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); + const provider = driver === "claudeAgent" ? "claude" : driver; + let home: string; + if (driver === "codex") { + const decoded = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(decoded)) continue; + const config = decoded.value; + const environmentHome = environment.CODEX_HOME?.trim(); + const layout = yield* resolveCodexHomeLayout( + !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome + ? { ...config, homePath: environmentHome } + : config, + ); + home = layout.sharedHomePath; + } else if (driver === "claudeAgent") { + const decoded = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(decoded)) continue; + const configured = decoded.value.homePath.trim(); + home = configured + ? expandHomePath(configured) + : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); + } else { + home = expandHomePath( + environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), + ); + } + const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); + // Account aliases and Codex auth overlays can share the same history. + const dir = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => directory)); + const key = `${provider}\0${dir}`; + if (seen.has(key)) continue; + seen.add(key); + dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); + } + } + return dirs; }); /** diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a86177b48eb3..17e531918ab1 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -114,6 +114,21 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("diffWordWrap"); }); + it("keeps the default diff file state across reloads and defaults it to expanded", async () => { + const testWindow = getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); + }); + it("keeps the diff layout across reloads and defaults it to stacked", async () => { const testWindow = getTestWindow(); const { readBrowserClientSettings, writeBrowserClientSettings } = diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 459506efc78c..43f1fa123f38 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -1,7 +1,6 @@ /** - * Agents right-panel surface: the fleet view over the native subagent fold, - * and the ONLY place the roster renders (the chat carries one CTA row per - * spawn batch). + * Agents right-panel surface: the fleet view over the native subagent fold. + * The chat carries one expandable row per spawn batch and links here. * * Visualization rules (from live-test feedback): * - Spawn order is stable. Activity and completion update rows in place. diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index ce2b0f8efa53..4e22e68ca902 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -694,6 +694,28 @@ describe("ChatMarkdown artifact-template cards", () => { }); }); +describe("ChatMarkdown heading levels", () => { + it("exposes headings below the host heading without changing their tags", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('

Top

'); + expect(html).toContain('

Section

'); + expect(html).toContain('
Fine print
'); + }); + + it("leaves heading levels alone when the markdown is not nested", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("

Top

"); + }); +}); + describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { it("uses the browser when it is the only available primary action", () => { expect( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4f113f6ed4be..3aeaaa5b8443 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -71,7 +71,11 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; +import type { + Components, + ExtraProps as ReactMarkdownExtraProps, + Options as ReactMarkdownOptions, +} from "react-markdown"; import ReactMarkdown from "react-markdown"; import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; @@ -216,6 +220,10 @@ interface ChatMarkdownProps { extraRemarkPlugins?: NonNullable; /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */ renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; + /** Levels added to each markdown heading in the accessibility tree so the + text nests under the heading that introduces it, such as a chat message's + author. Rendered tags and their styling are unchanged. */ + headingLevelOffset?: number | undefined; } export interface ChatMarkdownContextReference { @@ -2217,6 +2225,7 @@ function useChatMarkdownState({ imageBaseDir, onImageExpand, renderContextReference, + headingLevelOffset = 0, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2615,6 +2624,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, renderContextReference, + headingLevelOffset, imageBaseDir, inlineCodeFileLinkMetaByText, isStreaming, @@ -2643,6 +2653,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, renderContextReference, + headingLevelOffset, imageBaseDir, inlineCodeFileLinkMetaByText, isStreaming, @@ -2679,8 +2690,33 @@ const ChatMarkdownRendererContext = React.createContext< ReturnType["componentState"] >(null!); +// Screen readers take a heading's level from its tag, which would let a `#` in a +// message outrank the heading placed above it. Override only the exposed level: +// the tag keeps driving the stylesheet and copy-as-markdown. +function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { + const Tag = `h${level}` as const; + return function MarkdownHeading({ + node: _node, + ...props + }: ComponentProps & ReactMarkdownExtraProps) { + const { headingLevelOffset } = use(ChatMarkdownRendererContext); + return ( + 0 ? Math.min(level + headingLevelOffset, 6) : undefined} + /> + ); + }; +} + // Keep component types stable when streaming changes the message state. const CHAT_MARKDOWN_COMPONENTS = { + h1: markdownHeadingRenderer(1), + h2: markdownHeadingRenderer(2), + h3: markdownHeadingRenderer(3), + h4: markdownHeadingRenderer(4), + h5: markdownHeadingRenderer(5), + h6: markdownHeadingRenderer(6), div: function MarkdownDiv({ node, children, ...props }) { const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext); const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2b9a18cb90fb..be279f220e7e 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2180,6 +2180,10 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false); }); + it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => { + expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false); + }); + it("leaves a focused terminal alone in the drawer and the right panel", () => { expect( shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2707050070e9..ed1422a71109 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1350,6 +1350,8 @@ export function shouldRefocusComposerOnWindowFocus( activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || + activeElement.tagName === "IFRAME" || + activeElement.tagName === "WEBVIEW" || activeElement.isContentEditable === true || activeElement.getAttribute("role") === "textbox" ) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c67639ac8e2d..4e8b370d382c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,6 +46,8 @@ import { } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { readPastedComposerContext } from "./composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -95,6 +97,7 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -1608,6 +1611,7 @@ export default function ChatView(props: ChatViewProps) { const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -6519,24 +6523,38 @@ export default function ChatView(props: ChatViewProps) { // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. useEffect(() => { + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + } + }; const handler = (event: ClipboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) return; if (getTerminalFocusOwner() !== null) return; if (composerRef.current?.isModelPickerOpen()) return; const text = pasteTextToFocusComposer(event); - if (text === null) return; + const clipboardData = event.clipboardData; + if (text === null || clipboardData === null) return; + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; if ( - composerRef.current?.insertTextAtEnd( - text, - event.clipboardData ? { clipboardData: event.clipboardData } : undefined, - ) + ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 && + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || + composerRef.current?.insertTextAtEnd(text, { clipboardData }) ) { event.preventDefault(); event.stopPropagation(); } }; + window.addEventListener("keydown", keyHandler, true); window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; }, [activeThreadId, composerRef]); const [pendingRevert, setPendingRevert] = useState<{ @@ -7257,6 +7275,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0cf866c20696..9d46174d174f 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -18,6 +18,7 @@ import { import { importPastedComposerText, + readPastedComposerContext, registerComposerInlineTokenPaste, } from "./composerInlineTokenPaste"; import { @@ -498,92 +499,103 @@ describe("registerComposerInlineTokenPaste", () => { }); describe("context reference paste", () => { - it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - const importFragment = ( - fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, - ) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map([["img-old", "img-new"]]); - }; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: importFragment, - }); - const event = new TestClipboardEvent( - "![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)", - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: "img-old", - kind: "image", - label: "shot", - attachmentId: "a", - name: "shot.png", - mimeType: "image/png", - sizeBytes: 1, - }, - { - version: 1, - contextId: "ctx-t", - kind: "terminal", - label: "T", - terminalId: "t", - terminalLabel: "T", - lineStart: 1, - lineEnd: 1, - text: "x", - }, - { - version: 1, - contextId: "img-unrelated", - kind: "image", - label: "other", - attachmentId: "b", - name: "other.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - }, - ); - if (focus === "blurred") { - expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( - "![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)", + it.each([ + { focus: "focused", prefix: "" }, + { focus: "blurred", prefix: "" }, + { focus: "focused", prefix: "log ".repeat(10_000) }, + { focus: "blurred", prefix: "log ".repeat(10_000) }, + ])( + "imports structured paste when $focus with $prefix.length extra characters", + ({ focus, prefix }) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor({ nodes: [ComposerCitationNode] }); + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + const imported: string[] = []; + const importFragment = ( + fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, + ) => { + imported.push(...fragment.records.map((record) => record.contextId)); + return new Map([["img-old", "img-new"]]); + }; + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + createCitationNode: $createComposerCitationNode, + createContextReferenceNode: (reference) => + $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + importContextFragment: importFragment, + }); + const event = new TestClipboardEvent( + `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`, + { + "web application/x-t3-context-fragment+json": JSON.stringify({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + { + version: 1, + contextId: "img-old", + kind: "image", + label: "shot", + attachmentId: "a", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1, + }, + { + version: 1, + contextId: "ctx-t", + kind: "terminal", + label: "T", + terminalId: "t", + terminalLabel: "T", + lineStart: 1, + lineEnd: 1, + text: "x", + }, + { + version: 1, + contextId: "img-unrelated", + kind: "image", + label: "other", + attachmentId: "b", + name: "other.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + }, + ); + expect( + readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), + ).toEqual(["img-old", "ctx-t"]); + if (focus === "blurred") { + expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( + `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`, + ); + expect(imported).toEqual(["img-old", "ctx-t"]); + return; + } + editor.update( + () => { + editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, ); expect(imported).toEqual(["img-old", "ctx-t"]); - return; - } - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " and ", - ); - }); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + `${prefix} and `, + ); + }, + ); it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { const copied = upgradeLegacyContextMessage( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index fbd839629182..3ba086e0e981 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -838,6 +838,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1854,6 +1855,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2021,7 +2026,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d764b9c6a9e2..ca0bdaa7b4d5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -226,10 +226,6 @@ export default function DiffPanel({ ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const collapsedDiffFileKeys = - collapsedDiffFiles.scopeKey === collapseScopeKey - ? collapsedDiffFiles.fileKeys - : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" @@ -425,6 +421,17 @@ export default function DiffPanel({ })), [renderableFiles], ); + const defaultCollapsedDiffFileKeys = useMemo( + () => + settings.diffFilesCollapsed + ? new Set(renderableFileEntries.map((file) => file.fileKey)) + : EMPTY_COLLAPSED_DIFF_FILE_KEYS, + [renderableFileEntries, settings.diffFilesCollapsed], + ); + const collapsedDiffFileKeys = + collapsedDiffFiles.scopeKey === collapseScopeKey + ? collapsedDiffFiles.fileKeys + : defaultCollapsedDiffFileKeys; const codeViewFiles = useMemo( () => renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => { @@ -462,14 +469,16 @@ export default function DiffPanel({ if (!file) return; if (file.collapsed) { setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); next.delete(file.fileKey); return { scopeKey: collapseScopeKey, fileKeys: next }; }); } requestTreeReveal(file.fileKey); }, - [codeViewFiles, collapseScopeKey, requestTreeReveal], + [codeViewFiles, collapseScopeKey, defaultCollapsedDiffFileKeys, requestTreeReveal], ); const openDiffFile = useCallback( @@ -503,7 +512,9 @@ export default function DiffPanel({ const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); if (next.has(fileKey)) { next.delete(fileKey); } else { @@ -512,21 +523,21 @@ export default function DiffPanel({ return { scopeKey: collapseScopeKey, fileKeys: next }; }); }, - [collapseScopeKey], + [collapseScopeKey, defaultCollapsedDiffFileKeys], ); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { const currentKeys = - current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys; return { scopeKey: collapseScopeKey, fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), }; }); - }, [collapseScopeKey, diffFileKeys]); + }, [collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]); const selectTurn = (turnId: TurnId) => { if (!routeThreadRef) return; diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 76617a6c1296..288078729d5d 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -4,6 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { CircleArrowUpIcon } from "lucide-react"; import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; @@ -44,8 +45,10 @@ export interface ServerUpdateTarget { readonly continueThreadsAfterServerUpdate?: boolean; } -type UpdateButtonProps = Pick, "variant" | "size"> & { +type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { readonly label?: string; + /** "icon" renders a compact icon button with the label in a tooltip. */ + readonly appearance?: "button" | "icon"; }; function useServerUpdate() { @@ -94,6 +97,7 @@ export function ServerUpdatesAction({ label = "Update all", variant = "outline", size = "xs", + className, }: UpdateButtonProps & { readonly targets: ReadonlyArray; }) { @@ -133,6 +137,7 @@ export function ServerUpdatesAction({ + + + } + > + + + {actionLabel} + ); } return ( - ); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f9a62c40a511..e06176dfc48e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -395,6 +395,18 @@ describe("shouldRecedeSidebarThread", () => { expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false); expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false); }); + + it.each([false, true])("keeps input-required threads prominent with unread=%s", (isUnread) => { + expect( + shouldRecedeSidebarThread({ + status: "input", + isUnread, + isWoke: false, + isActive: false, + isSelected: false, + }), + ).toBe(false); + }); }); describe("createThreadJumpHintVisibilityController", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 50650ed389dc..abb67e65a24e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -803,9 +803,9 @@ export function shouldRecedeSidebarThread(input: { isActive: boolean; isSelected: boolean; }): boolean { - if (input.isActive || input.isSelected) return false; + if (input.isActive || input.isSelected || input.status === "input") return false; if (input.status === "working" || input.status === "monitoring") return true; - if (input.status === "ready" || input.status === "approval" || input.status === "input") { + if (input.status === "ready" || input.status === "approval") { return !input.isUnread && !input.isWoke; } return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 445ae533c323..96f059fb23ea 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,12 +45,15 @@ import { CircleCheckIcon, CircleDashedIcon, ClockIcon, + EyeIcon, FolderIcon, GitBranchIcon, + MessageCircleQuestionIcon, PinIcon, PinOffIcon, PlusIcon, SettingsIcon, + ShieldQuestionIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -1088,8 +1091,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); - const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: @@ -1123,35 +1124,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: "working" as const, // No shimmer: a label that animates forever is noise in a sidebar // full of them (and repaints every vsync on high-refresh displays). - // Working is a background state, so it rests at the dim end of what - // the old pulse cycled through; only the thread you have open gets - // the label at full strength. - className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), + className: "text-sky-600 dark:text-sky-400", } : status === "monitoring" ? { // Monitoring is calm background presence, not active progress // (monitoring-pill D6), so it keeps the label at full strength. label: "Monitoring", - icon: null, - className: "text-sky-600 dark:text-sky-400", + icon: "monitoring" as const, + className: "text-foreground dark:text-white", } : status === "approval" ? { label: "Approval", - icon: null, + icon: "approval" as const, className: "text-amber-700 dark:text-amber-300", } : status === "input" ? { label: "Input", - icon: null, + icon: "input" as const, className: "text-indigo-600 dark:text-indigo-300", } : status === "failed" ? { label: "Failed", - icon: null, + icon: "failed" as const, className: "text-red-700 dark:text-red-300", } : isWoke @@ -1396,10 +1394,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", isFileDragOver && "ring-1 ring-inset ring-primary/70", // The hover tint must not clobber an active/selected row's own surface. isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", @@ -1462,7 +1456,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { "truncate", shouldRecede ? "text-secondary-label" - : isUnread || isWoke + : isUnread || isWoke || status === "input" ? "text-foreground" : status === "failed" ? "text-foreground/95" @@ -1472,7 +1466,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", shouldRecede ? "text-secondary-label/70" - : props.isActive || isWoke + : props.isActive || isWoke || status === "input" ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -1814,6 +1808,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { > {topStatus.icon === "working" ? ( + ) : topStatus.icon === "input" ? ( + + ) : topStatus.icon === "approval" ? ( + + ) : topStatus.icon === "failed" ? ( + + ) : topStatus.icon === "monitoring" ? ( + ) : topStatus.icon === "done" ? ( ) : null} @@ -4323,7 +4325,7 @@ export default function Sidebar() { fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. - + 0} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cb2143ab65aa..e3ae13911036 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,13 +1,15 @@ +import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; -import { importPastedComposerText } from "../composerInlineTokenPaste"; +import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { questionAttachmentDraftId, + countQuestionAttachments, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; @@ -34,8 +36,15 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { + isPasteAsTextShortcut, + nextPastedTextFileName, + pastedTextDisposition, + wouldTextPasteExceedLimit, +} from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -194,6 +203,7 @@ import { ensureInlineContextReferences, formatInlineContextReference, insertInlineContextReference, + inlineContextReferenceReplacement, toKindScopedComposerContextId, } from "~/lib/composerContextReferences"; import { @@ -284,7 +294,7 @@ import { } from "../../lib/snapShotAnimation"; import { resizeSnapShotSource } from "../../lib/snapShotSource"; import { basenameOfPath } from "../../pierre-icons"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, @@ -1212,6 +1222,8 @@ export interface ChatComposerHandle { text: string, options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, ) => boolean; + /** Apply large-paste folding for text redirected from a blurred composer. */ + pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; citeAssistantText: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -2064,6 +2076,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); + const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set }>({ + targetKey: "", + names: new Set(), + }); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerFooterControlsRef = useRef(null); @@ -2099,6 +2116,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); isRevertingCheckpointRef.current = isRevertingCheckpoint; + useEffect(() => { + const armPasteAsTextShortcut = () => { + // Electron can deliver its native menu action just before the paste + // event, while browsers normally deliver keydown first. A short deadline + // bridges both event paths without leaving later pastes in bypass mode. + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Node && + composerFormRef.current?.contains(event.target) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + armPasteAsTextShortcut(); + } + }; + const onBlur = () => { + pasteAsTextShortcutUntilRef.current = 0; + }; + const onDesktopPasteAsText = () => { + const activeElement = document.activeElement; + const blocksPasteToFocus = + activeElement instanceof Element && + activeElement.closest( + 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', + ) !== null; + if ( + (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || + !blocksPasteToFocus + ) { + armPasteAsTextShortcut(); + } + }; + window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", onBlur); + }; + }, []); + // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ @@ -4041,6 +4101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // An expired upload carries no ids, so it hydrates as a // needs-reattach row and the "Attach again" flow takes over. ...(expired @@ -4319,6 +4380,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sizeBytes: file.sizeBytes, attachmentId: upload.attachmentId, environmentId, + ...(file.source ? { source: file.source } : {}), }); } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it @@ -5032,8 +5094,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: attachments // ------------------------------------------------------------------ + const countReservedAttachments = () => { + const questionRequest = pendingUserInputs[0]; + const otherQuestionKeys = + questionAttachmentTarget && questionRequest && activeThreadId + ? questionRequest.questions + .map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId, + questionRequest.requestId, + question.id, + ), + ) + .filter((key) => key !== questionAttachmentTarget) + : []; + return ( + composerImagesRef.current.length + + composerFilesRef.current.length + + (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) + + countQuestionAttachments(otherQuestionKeys) + ); + }; /** Resolves true when at least one chip was inserted for the accepted attachments. */ - const addComposerAttachments = async (files: File[]): Promise => { + const addComposerAttachments = async ( + files: File[], + options?: { + readonly source?: ChatFileAttachment["source"]; + readonly selection?: { start: number; end: number }; + }, + ): Promise => { if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false; if ( pendingUserInputs.length > 0 && @@ -5056,30 +5146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // accepted files reserve their attachment slots (via the pending counter) // before the first await, keeping the total under the limit. const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0; - const otherQuestionAttachments = - questionAttachmentTarget && pendingUserInputs[0] - ? pendingUserInputs[0].questions.reduce((count, question) => { - const target = questionAttachmentDraftId( - environmentId, - threadId, - pendingUserInputs[0]!.requestId, - question.id, - ); - if (target === questionAttachmentTarget) return count; - const draft = getComposerDraft(target); - return ( - count + - (draft?.images.length ?? 0) + - (draft?.files.length ?? 0) + - (useQuestionAttachmentPreparation.getState().counts[target] ?? 0) - ); - }, 0) - : 0; - let reservedCount = - composerImagesRef.current.length + - composerFilesRef.current.length + - pendingCount + - otherQuestionAttachments; + let reservedCount = countReservedAttachments(); // A pick that matches a needs-reattach marker replaces it in the draft, so // it must not consume a slot; a draft full of markers would otherwise hit // the capacity error before the replacement path could run. @@ -5146,6 +5213,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: fileMimeType, sizeBytes: attachmentFile.size, file: attachmentFile, + ...(options?.source ? { source: options.source } : {}), }); } if (!matchingReattachMarker) { @@ -5160,7 +5228,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); if (storedFiles.length > 0) { - insertedAny = insertAttachmentReferences(storedFiles.map(fileContextReference)); + insertedAny = insertAttachmentReferences( + storedFiles.map(fileContextReference), + options?.selection, + ); + } + if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) { + const attached = storedFiles[0]!; + toastManager.add({ + type: "info", + title: `Large paste attached as ${attached.name}`, + description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${ + isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V" + } to keep a large paste inline.`, + data: { hideCopyButton: true }, + }); } } if (acceptedImages.length === 0) return insertedAny; @@ -5246,11 +5328,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) */ const insertAttachmentReferences = ( references: ReadonlyArray, + selection?: { start: number; end: number }, ): boolean => { if (references.length === 0) return false; // Question answers carry attachments beside the answer, never as chips. Falling back to // the thread prompt here would hide the file behind a reference the question never shows. if (questionAttachmentTarget) return false; + if (selection) { + const edit = inlineContextReferenceReplacement(promptRef.current, selection, references); + return applyPromptReplacement(edit.start, edit.end, edit.text); + } const text = references.map(formatInlineContextReference).join(" "); const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); if (!inserted) { @@ -5284,24 +5371,110 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ + const foldPastedText = ( + plainText: string, + bypassAutoAttachment: boolean, + selectionOverride?: { start: number; end: number }, + ): boolean => { + const questionCanAttach = + pendingUserInputs.length === 0 || + (supportsQuestionAttachments && + activePendingProgress?.activeQuestion?.allowCustomAnswer !== false && + !activePendingIsResponding); + const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; + const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange(); + const wouldExceedInputLimit = wouldTextPasteExceedLimit({ + valueLength: promptRef.current.length, + selection: selection ?? { start: 0, end: 0 }, + textLength: plainText.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }); + const shouldFold = + pastedTextDisposition({ + text: plainText, + bypassAutoAttachment, + wouldExceedInputLimit, + canAttach: true, + }) === "attachment"; + if (!shouldFold) { + return false; + } + + const canStageAttachment = + Boolean(activeThreadId) && + !isRevertingCheckpointRef.current && + questionCanAttach && + hasAttachmentSlot; + if (!canStageAttachment || fileStagingLimit === null) { + if (!wouldExceedInputLimit) { + return false; + } + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Remove some text or an attachment, then paste again.", + data: { hideCopyButton: true }, + }); + return true; + } + + if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) { + pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() }; + } + const reservedNames = pastedTextFileNamesRef.current.names; + for (const file of composerFilesRef.current) reservedNames.add(file.name); + const foldedFileName = nextPastedTextFileName([...reservedNames]); + reservedNames.add(foldedFileName); + const foldedFile = new File([plainText], foldedFileName, { + type: "text/plain;charset=utf-8", + }); + if (foldedFile.size > fileStagingLimit) { + reservedNames.delete(foldedFileName); + if (!wouldExceedInputLimit) return false; + toastManager.add({ + type: "error", + title: "Pasted text is too large to attach", + description: "Reduce the clipboard contents or save a smaller excerpt as a file.", + data: { hideCopyButton: true }, + }); + return true; + } + + void addComposerAttachments([foldedFile], { + source: { _tag: "pasted-text" }, + ...(selection ? { selection } : {}), + }); + return true; + }; + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); + const plainText = event.clipboardData.getData("text/plain"); + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; // Claimable pastes go through even when agent questions are pending or the // composer is at its attachment limit: `addComposerAttachments` surfaces // those as a toast and a thread error. An early return here would swallow // the paste with no feedback. if ( - files.length === 0 || - !activeThreadId || - !shouldHandleComposerAttachmentPaste({ - files, - plainText: event.clipboardData.getData("text/plain"), - }) + files.length > 0 && + activeThreadId && + shouldHandleComposerAttachmentPaste({ files, plainText }) ) { + event.preventDefault(); + event.stopPropagation(); + void addComposerAttachments(files); return; } + + // Copied T3 chips need the structured importer to bring their records and files along. + if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return; + if (!foldPastedText(plainText, bypassAutoAttachment)) { + return; + } + event.preventDefault(); - void addComposerAttachments(files); + event.stopPropagation(); }; const insertComposerText = useCallback( @@ -5526,6 +5699,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, insertTextAtEnd: insertComposerTextAtEnd, + pasteTextAtEnd: (text: string, options) => { + const bypassAutoAttachment = + options?.bypassAutoAttachment === true || + Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + const promptLength = promptRef.current.length; + if ( + !foldPastedText(text, bypassAutoAttachment, { + start: promptLength, + end: promptLength, + }) + ) { + return false; + } + focusComposer(); + return true; + }, citeAssistantText: (citation, sourceAnchor) => insertComposerText( formatAssistantCitationForComposer(citation, citation.comment), @@ -5626,6 +5816,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + foldPastedText, composerDraftTarget, composerCursor, composerTerminalContexts, diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index f98bbe3982ba..3c4f521b7ccc 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -1,4 +1,12 @@ -import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, + type KeyboardEvent, +} from "react"; import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; @@ -24,7 +32,7 @@ interface ExpandedImageDialogProps { } const EXPANDED_MEDIA_STATE_CLASS_NAME = - "flex aspect-auto h-48 min-h-0 w-[min(92vw,32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl"; + "flex aspect-auto h-48 min-h-0 w-[min(var(--media-width),32rem)] flex-col items-center justify-center gap-3 rounded-lg border border-border/70 bg-black p-6 text-center text-sm text-white shadow-2xl"; function ExpandedMediaFailure({ children }: { children: ReactNode }) { return ( @@ -51,8 +59,8 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { originalUrl={item.originalUrl} preload="metadata" autoPlay={item.autoPlay ?? true} - className="block max-h-[86vh] max-w-[92vw] text-center" - videoClassName="aspect-auto max-h-[86vh] w-auto max-w-[92vw] rounded-lg border border-border/70 shadow-2xl" + className="block max-h-[var(--media-height)] max-w-[var(--media-width)] text-center" + videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl" stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} onRetry={asset ? refreshAssetUrl : undefined} /> @@ -111,29 +119,26 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ }; }, []); - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || isContextMenuOpen()) return; - if (zoomableImageRef.current?.pan(event.key)) { - event.preventDefault(); - event.stopPropagation(); - return; - } - if (preview.images.length <= 1) return; - if (event.key === "ArrowLeft") { - event.preventDefault(); - event.stopPropagation(); - navigateImage(-1); - return; - } - if (event.key !== "ArrowRight") return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement) + return; + if (zoomableImageRef.current?.pan(event.key)) { event.preventDefault(); event.stopPropagation(); - navigateImage(1); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateImage, preview.images.length]); + return; + } + if (preview.images.length <= 1) return; + if (event.key === "ArrowLeft") { + event.preventDefault(); + event.stopPropagation(); + navigateImage(-1); + return; + } + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + navigateImage(1); + }; useEffect(() => { const onEscape = (event: globalThis.KeyboardEvent) => { @@ -176,9 +181,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ bottomStickOnMobile={false} backdropClassName="z-[60]" viewportClassName="z-[60] grid-rows-1 place-items-center px-4 py-6 [-webkit-app-region:no-drag]" - className="row-start-1 max-h-[92vh] w-auto max-w-[92vw] overflow-visible" + className="row-start-1 max-h-[92vh] w-[92vw] max-w-[92vw] items-center overflow-visible [--media-width:92vw] [--media-height:min(86vh,calc(100vh-160px))] sm:[--media-width:calc(92vw-96px)]" + onKeyDown={onKeyDown} initialFocus={closeButtonRef} finalFocus={() => returnFocusTarget} + onClick={(event) => { + if (event.target === event.currentTarget) onClose(); + }} > Expanded {mediaLabel} preview {preview.images.length > 1 && ( @@ -186,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ type="button" size="icon" variant="media-navigation" - className="left-2 sm:left-6" + className="left-0 top-auto -bottom-12 translate-y-0 rounded-full bg-white/10 sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2" aria-label="Previous media" onClick={() => navigateImage(-1)} > @@ -194,13 +203,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ )} -
+
+ {expanded ? ( +
+ {agents.map((agent) => ( + + ))} + +
+ ) : null} +
+ ); +}); + +const AGENT_MEMBER_STATUS_LABEL: Record = { + pending: "Working", + running: "Working", + waiting: "Working", + idle: "Idle", + completed: "Completed", + failed: "Failed", + cancelled: "Stopped", + interrupted: "Stopped", +}; - const dotClass = { - working: "bg-info", - failed: "bg-destructive", - completed: "bg-success", - inactive: "bg-muted-foreground/50", - }[summary.tone]; - const status = - live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; +function AgentSpawnMemberRow({ + agent, + onToggleEntry, +}: { + agent: RuntimeSubagent; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; +}) { + const [open, setOpen] = useState(false); + const activeStatus = isActiveSubagentStatus(agent.status); + const activity = activeStatus + ? (agent.progress ?? (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)) + : (agent.error ?? agent.result ?? agent.progress ?? null); + const durationMs = + agent.startedAt && agent.completedAt + ? Date.parse(agent.completedAt) - Date.parse(agent.startedAt) + : null; + const meta = [ + durationMs !== null && durationMs >= 0 ? formatDuration(durationMs) : null, + agent.usage && agent.usage.totalTokens > 0 + ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` + : null, + ] + .filter(Boolean) + .join(" · "); + // Settled members show their metrics; anything other than success keeps + // the status word so the outcome remains explicit. + const statusLabel = + activeStatus || !meta + ? AGENT_MEMBER_STATUS_LABEL[agent.status] + : agent.status === "completed" + ? meta + : `${AGENT_MEMBER_STATUS_LABEL[agent.status]} · ${meta}`; + const role = + agent.role && agent.role.trim().toLowerCase() !== agent.title.trim().toLowerCase() + ? agent.role + : null; + const firstLine = activity?.split("\n").find((line) => line.trim().length > 0) ?? null; + const body = [activity?.trim() || null, formatSubagentModelLabel(agent.model, agent.effort)] + .filter(Boolean) + .join("\n\n"); + const canExpand = body.length > 0; + const toggleOpen = () => { + onToggleEntry?.(open); + setOpen((value) => !value); + }; return ( - +
+

+ + {agent.title} + + {role ? ( + + {role} + + ) : null} +

+ + {statusLabel} + +
+ {!open && firstLine ? ( +

{firstLine}

+ ) : null} + {open ? ( +
+
{body}
+
+ ) : null} +
); -}); +} const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; @@ -3741,9 +3930,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; - // Before any hooks: spawn CTA rows render their own component. + // Before any hooks: spawn rows render their own component. if (workEntry.agentSpawn) { - return ; + return ( + + ); } return (
Plan -

{title}

+ {/* Same heading level as the message author headings in the timeline, + so a plan's own headings nest beneath it in the outline. */} +

{title}

) : ( )} {canCollapse && !expanded ? ( diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index 944c5204ed4d..982b6e67b363 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -44,10 +44,10 @@ export function ZoomableImage({ } | null>(null); const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); - const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); + const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 160)); const fit = Math.min( 1, - (windowSize.width * 0.92) / (naturalSize.width || 1), + (windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1), maxHeight / (naturalSize.height || 1), ); const width = naturalSize.width * fit * zoom; @@ -59,6 +59,12 @@ export function ZoomableImage({ pan(key) { const viewport = viewportRef.current; if (!viewport || zoomRef.current <= 1) return false; + // A vertical scrollbar alone must not swallow gallery navigation. + if ( + (key === "ArrowLeft" || key === "ArrowRight") && + viewport.scrollWidth <= viewport.offsetWidth + ) + return false; switch (key) { case "ArrowLeft": viewport.scrollLeft -= 40; @@ -144,7 +150,7 @@ export function ZoomableImage({ aria-label={`${name}, zoomable image`} aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." tabIndex={0} - className="max-w-[92vw] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="max-w-[var(--media-width)] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring" style={{ width: width || undefined, height: height || undefined, @@ -221,7 +227,9 @@ export function ZoomableImage({ alt={name} draggable={false} className="block max-w-none select-none" - style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }} + style={ + naturalSize.width ? { width, height } : { maxWidth: "var(--media-width)", maxHeight } + } onLoad={(event) => { setNaturalSize({ width: event.currentTarget.naturalWidth, diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index a6e1a25cf967..33eac5d58aa3 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -144,40 +144,43 @@ export function registerComposerInlineTokenPaste( ); } -/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ -export function importPastedComposerText( +/** Clipboard records referenced by the copied text, including dependent screenshots. */ +export function readPastedComposerContext( clipboardData: Pick, - importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], -): string { +): ComposerContextClipboardFragment | null { const pastedText = clipboardData.getData("text/plain"); // Only records whose links are in the pasted text get imported; a fragment may carry // more (it was built for a larger copy) and must not start transfers for those. - const decodedFragment = importContextFragment - ? (decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? - decodeComposerContextClipboardHtml(clipboardData.getData("text/html"))) - : null; + const decodedFragment = + decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? + decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); + if (decodedFragment === null) return null; const pastedIds = new Set( collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), ); - if (decodedFragment) { - for (const record of decodedFragment.records) { - if ( - record.kind === "preview-annotation" && - !("payload" in record) && - pastedIds.has(record.contextId) && - record.screenshotContextId - ) { - pastedIds.add(record.screenshotContextId); - } + for (const record of decodedFragment.records) { + if ( + record.kind === "preview-annotation" && + !("payload" in record) && + pastedIds.has(record.contextId) && + record.screenshotContextId + ) { + pastedIds.add(record.screenshotContextId); } } - const fragment = - decodedFragment === null - ? null - : { - ...decodedFragment, - records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), - }; + return { + ...decodedFragment, + records: decodedFragment.records.filter((record) => pastedIds.has(record.contextId)), + }; +} + +/** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ +export function importPastedComposerText( + clipboardData: Pick, + importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], +): string { + const pastedText = clipboardData.getData("text/plain"); + const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; const text = diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 55d66063b13e..125a537bc272 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -1,6 +1,12 @@ import type { PullRequestContextMetadata } from "@t3tools/contracts"; import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react"; -import type { ComponentProps, MouseEvent, ReactNode } from "react"; +import { + useState, + type ComponentProps, + type CSSProperties, + type MouseEvent, + type ReactNode, +} from "react"; import { cn } from "~/lib/utils"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -113,32 +119,61 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { return ( - - - {props.label} - - } - > -
+ + props.onOpen(event, props.metadata.url)} + > + + {props.label} + + } + /> + -

Captured pull request context

- -
-
+ + ); } +/** Sample the loaded thumbnail once; transparent pixels should not darken its accent. */ +function averageImageColor(image: HTMLImageElement): string | undefined { + try { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 16; + const context = canvas.getContext("2d"); + if (!context) return; + context.drawImage(image, 0, 0, 16, 16); + const { data } = context.getImageData(0, 0, 16, 16); + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + for (let index = 0; index < data.length; index += 4) { + const weight = data[index + 3]!; + red += data[index]! * weight; + green += data[index + 1]! * weight; + blue += data[index + 2]! * weight; + alpha += weight; + } + if (alpha === 0) return; + return `rgb(${Math.round(red / alpha)} ${Math.round(green / alpha)} ${Math.round(blue / alpha)})`; + } catch { + // Cross-origin or unavailable pixels keep the default image tone and preview action. + return; + } +} + export function ImageChipButton({ name, previewUrl, @@ -146,6 +181,7 @@ export function ImageChipButton({ labelClassName, size, suffix, + style, ...props }: ComponentProps<"button"> & { name: string; @@ -155,6 +191,9 @@ export function ImageChipButton({ size: string; suffix?: string | null; }) { + const [sample, setSample] = useState<{ url: string; color: string | undefined }>(); + const [corsFailedUrl, setCorsFailedUrl] = useState(); + const accent = sample?.url === previewUrl ? sample?.color : undefined; return ( - ) : null} - + +
+ + {errorTraceId ? ( + copyTraceId(errorTraceId)}>Copy trace ID + ) : null} + onRemove(environment)}> + {isRemoving ? "Removing…" : "Remove from this device…"} + + +
)} @@ -1781,7 +1817,9 @@ export function ConnectionsSettings() { reportFailure: false, }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge @@ -1797,6 +1835,59 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); + // Machines "Update all" can reach: switched on, connected, behind the client + // version, remotely updatable, and not already mid-update. The button only + // renders when this list is non-empty. + const savedServerUpdateStatesAtom = useMemo( + () => + Atom.make((get) => + savedEnvironments.map((environment) => ({ + environment, + updateStatus: get(serverEnvironment.updateStateAtom(environment.environmentId)).status, + })), + ), + [savedEnvironments], + ); + const savedServerUpdateStates = useAtomValue(savedServerUpdateStatesAtom); + const savedServerUpdateTargets = useMemo( + () => + savedServerUpdateStates.flatMap(({ environment, updateStatus }): ServerUpdateTarget[] => { + const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); + const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); + if ( + !mismatch || + updateStatus === "running" || + !environment.entry.enabled || + environment.connection.phase !== "connected" || + isDesktopLocalConnectionTarget(environment.entry.target) || + // Manual-update machines only offer a copy command on their row. + selfUpdate === null || + (selfUpdate === "desktop-managed" && !desktopAppUpdate) + ) { + return []; + } + return [ + { + environmentId: environment.environmentId, + serverLabel: environment.label, + selfUpdate, + desktopAppUpdate, + threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), + continueThreadsAfterServerUpdate: + environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, + targetVersion: mismatch.clientVersion, + }, + ]; + }), + [savedServerUpdateStates], + ); + // Switched-off machines never receive threads, so they stay out of the + // load balancing list. + const loadBalancingEnvironments = useMemo( + () => environments.filter((environment) => environment.entry.enabled), + [environments], + ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); for (const environment of savedEnvironments) { @@ -2367,28 +2458,42 @@ export function ConnectionsSettings() { ], ); - const handleConnectSavedBackend = useCallback( - async (environmentId: EnvironmentId) => { + const handleSetSavedBackendEnabled = useCallback( + async (environmentId: EnvironmentId, enabled: boolean) => { setSavedBackendError(null); - const result = await retryEnvironment(environmentId); + const result = await setEnvironmentEnabled({ environmentId, enabled }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); - const message = error instanceof Error ? error.message : "Failed to connect backend."; + const message = + error instanceof Error + ? error.message + : `Failed to switch the backend ${enabled ? "on" : "off"}.`; setSavedBackendError(message); toastManager.add( stackedThreadToast({ type: "error", - title: "Could not connect backend", + title: `Could not switch backend ${enabled ? "on" : "off"}`, description: message, }), ); } }, - [retryEnvironment], + [setEnvironmentEnabled], ); + // Removing forgets the pairing, credentials, and cached threads on this + // device. Switching off is the reversible path, so removal always confirms. const handleRemoveSavedBackend = useCallback( - async (environmentId: EnvironmentId) => { + async (environment: EnvironmentPresentation) => { + // Fail closed: no mounted confirm host means no removal. + const confirmed = await requestConfirmDialog( + `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`, + { variant: "destructive" }, + ); + if (confirmed !== true) { + return; + } + const environmentId = environment.environmentId; setRemovingSavedEnvironmentId(environmentId); setSavedBackendError(null); const result = await removeEnvironment(environmentId); @@ -3524,65 +3629,75 @@ export function ConnectionsSettings() { { - setAddBackendDialogOpen(open); - if (!open) { - setSavedBackendError(null); - } - }} - > - - - - Add environment - - } - /> - } +
+ {savedServerUpdateTargets.length > 0 ? ( + - Add environment - - - - Add Environment - Pair another environment to this client. - - -
-
- {renderConnectionModeCard({ - mode: "remote", - title: "Remote link", - description: "Enter a backend host and pairing code.", - icon: , - })} - {desktopBridge - ? renderConnectionModeCard({ - mode: "ssh", - title: "SSH", - description: "Use local SSH config, agent, and tunnels for the backend.", - icon: , - }) - : null} + ) : null} + { + setAddBackendDialogOpen(open); + if (!open) { + setSavedBackendError(null); + } + }} + > + + + + Add environment + + } + /> + } + /> + Add environment + + + + Add Environment + Pair another environment to this client. + + +
+
+ {renderConnectionModeCard({ + mode: "remote", + title: "Remote link", + description: "Enter a backend host and pairing code.", + icon: , + })} + {desktopBridge + ? renderConnectionModeCard({ + mode: "ssh", + title: "SSH", + description: + "Use local SSH config, agent, and tunnels for the backend.", + icon: , + }) + : null} +
+ + {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} +
- - {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} - -
- - - + + + +
} > {savedEnvironments.map((environment) => ( @@ -3590,7 +3705,7 @@ export function ConnectionsSettings() { key={environment.environmentId} environment={environment} removingEnvironmentId={removingSavedEnvironmentId} - onConnect={handleConnectSavedBackend} + onSetEnabled={handleSetSavedBackendEnabled} onRemove={handleRemoveSavedBackend} /> ))} @@ -3599,7 +3714,7 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> - + ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8a78d4138c6b..98684f50721b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -533,6 +533,9 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), + ...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed + ? ["Default diff file state"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -608,6 +611,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.diffFilesCollapsed, settings.diffIgnoreWhitespace, settings.diffLayout, settings.proactivePanelsEnabled, @@ -706,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, @@ -2304,6 +2309,48 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, + }) + } + /> + ) : null + } + control={ + + } + /> - + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 60ef9760af93..45354c25220c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["diff ignore spaces edits default"], }, + { + id: "default-diff-file-state", + title: "Default diff file state", + to: "/settings/general", + searchTerms: ["collapsed expanded collapse expand files pull request pr code tab"], + }, { id: "diff-layout", title: "Diff layout", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4f115a751422..afbbf7671dfc 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -223,7 +223,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - + diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 6dba220311d6..a7abcb3bb550 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -703,7 +703,7 @@ function SidebarContent({ hideScrollbars scrollFade scrollFadePadding={false} - className="h-auto min-h-0 flex-1" + className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]" >
- {pools.length === 0 ? ( + {pools.length === 0 && notices.length === 0 ? (

No provider on the selected environments reports subscription limits.

@@ -562,10 +563,13 @@ export function UsageLimitsPooled({ function LimitNotices({ notices }: { readonly notices: readonly string[] }) { if (notices.length === 0) return null; return ( -
    + + {notices.map((notice) => ( -
  • {notice}
  • + + {notice} + ))} -
+ ); } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 8aa576767175..7b44b1b71128 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -436,7 +436,9 @@ describe("composerDraftStore file attachments", () => { it("persists uploaded file references without including file contents", () => { const store = useComposerDraftStore.getState(); - store.addFiles(threadRef, [makeFile("file-1")]); + store.addFiles(threadRef, [ + { ...makeFile("file-1"), source: { _tag: "pasted-text" as const } }, + ]); store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); const persistApi = useComposerDraftStore.persist as unknown as { @@ -459,6 +461,7 @@ describe("composerDraftStore file attachments", () => { sizeBytes: 6, attachmentId: "pending-report-pdf", environmentId: TEST_ENVIRONMENT_ID, + source: { _tag: "pasted-text" }, }, ], ); @@ -474,6 +477,7 @@ describe("composerDraftStore file attachments", () => { file: null, uploadedAttachmentId: "pending-report-pdf", uploadEnvironmentId: TEST_ENVIRONMENT_ID, + source: { _tag: "pasted-text" }, }, ]); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3c81676455b3..e8c60911caa5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -13,6 +13,7 @@ import { ProviderOptionSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, + PastedTextAttachmentSource, type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, @@ -191,6 +192,7 @@ export const PersistedComposerFileAttachment = Schema.Struct({ sizeBytes: Schema.Number, attachmentId: Schema.String, environmentId: EnvironmentId, + source: Schema.optional(PastedTextAttachmentSource), }); export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; @@ -207,6 +209,7 @@ export const PersistedComposerDraftFileAttachment = Schema.Struct({ sizeBytes: Schema.Number, attachmentId: Schema.optionalKey(Schema.String), environmentId: Schema.optionalKey(EnvironmentId), + source: Schema.optional(PastedTextAttachmentSource), }); export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); @@ -2144,6 +2147,7 @@ export function partializeComposerDraftStoreState( name: file.name, mimeType: file.mimeType, sizeBytes: file.sizeBytes, + ...(file.source ? { source: file.source } : {}), ...(file.uploadedAttachmentId && file.uploadEnvironmentId ? { attachmentId: file.uploadedAttachmentId, @@ -2423,6 +2427,7 @@ function toHydratedThreadDraft( mimeType: file.mimeType, sizeBytes: file.sizeBytes, file: null, + ...(file.source ? { source: file.source } : {}), // A marker without an attachment id hydrates as needs-reattach: no // bytes, no server-side upload, only the metadata to tell the user // what to attach again. diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 6d503387bb64..71b3815dcbae 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -13,6 +13,7 @@ const emptyCatalog = { profiles: [], credentials: [], remoteDpopTokens: [], + disabledEnvironmentIds: [], } as const; const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 8ec2b16add76..a1653d50c026 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -10,6 +10,7 @@ import { registerConnectionInCatalog, removeCatalogValue, removeConnectionFromCatalog, + setConnectionEnabledInCatalog, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; @@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) { function persistenceError( operation: | "list-targets" + | "list-disabled-targets" | "register-connection" | "remove-connection" + | "set-connection-enabled" | "load-shell" | "save-shell" | "load-thread" @@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext( Effect.map((document) => document.targets), Effect.mapError((cause) => persistenceError("list-targets", cause)), ), + listDisabled: catalog.read.pipe( + Effect.map((document) => document.disabledEnvironmentIds), + Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)), + ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => @@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext( catalog .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), + setEnabled: (environmentId, enabled) => + catalog + .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) + .pipe(Effect.mapError((cause) => persistenceError("set-connection-enabled", cause))), }); const profileStore = ProfileStore.make({ get: (connectionId) => diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index a38e107a7d7d..f376203cce6b 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -238,7 +238,10 @@ describe("attachmentUploadQueue", () => { }); it("uploads generic files and sends file attachment references", async () => { - const file = makeFile("report"); + const file = { + ...makeFile("report"), + source: { _tag: "pasted-text" as const }, + }; startAttachmentUpload({ environmentId: firstEnvironment, image: file }); await Promise.resolve(); @@ -268,6 +271,7 @@ describe("attachmentUploadQueue", () => { name: "report.pdf", mimeType: "application/pdf", sizeBytes: 3, + source: { _tag: "pasted-text" }, }, ]); }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index a309b95b6a41..2a98f38133ac 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -563,7 +563,7 @@ export function getUploadedAttachments(input: { name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, - ...(image.type === "image" && image.source ? { source: image.source } : {}), + ...(image.source ? { source: image.source } : {}), }); } return attachments; diff --git a/apps/web/src/lib/composerContextReferences.test.ts b/apps/web/src/lib/composerContextReferences.test.ts index 6214294fe17c..2d70b6bfa915 100644 --- a/apps/web/src/lib/composerContextReferences.test.ts +++ b/apps/web/src/lib/composerContextReferences.test.ts @@ -6,6 +6,7 @@ import { ensureInlineContextReferences, formatInlineContextReference, insertInlineContextReference, + inlineContextReferenceReplacement, removeInlineContextReference, stripInlineContextReferences, toComposerContextId, @@ -18,6 +19,17 @@ const reviewLink = "[a.ts L4](t3-context://v1/review-comment/rc-1)"; const previewLink = "[Checkout](t3-context://v1/preview-annotation/pa-1)"; describe("composerContextReferences", () => { + it.each([ + { prompt: "before selected after", start: 7, end: 15, expected: `before ${reviewLink} after` }, + { prompt: "selected", start: 0, end: 8, expected: `${reviewLink} ` }, + { prompt: "aSELECTb", start: 1, end: 7, expected: `a ${reviewLink} b` }, + ])( + "replaces selected text in '$prompt' with the attachment chip", + ({ prompt, start, end, expected }) => { + const edit = inlineContextReferenceReplacement(prompt, { start, end }, [review]); + expect(`${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`).toBe(expected); + }, + ); it("formats, collects and strips references of any kind", () => { expect(formatInlineContextReference(review)).toBe(reviewLink); const prompt = `x ${reviewLink} y ${previewLink} ${reviewLink}`; diff --git a/apps/web/src/lib/composerContextReferences.ts b/apps/web/src/lib/composerContextReferences.ts index 6ca28fc37f11..b80e021a3cab 100644 --- a/apps/web/src/lib/composerContextReferences.ts +++ b/apps/web/src/lib/composerContextReferences.ts @@ -93,19 +93,34 @@ function isBoundaryWhitespace(char: string | undefined): boolean { return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; } +/** Replaces a selected range with chips, keeping word boundaries and the trailing caret space. */ +export function inlineContextReferenceReplacement( + prompt: string, + selection: { start: number; end: number }, + references: ReadonlyArray, +): { start: number; end: number; text: string } { + const start = Math.max(0, Math.min(prompt.length, Math.floor(selection.start))); + const end = Math.max(start, Math.min(prompt.length, Math.floor(selection.end))); + const needsLeadingSpace = !isBoundaryWhitespace(prompt[start - 1]); + return { + start, + end: prompt[end] === " " ? end + 1 : end, + text: `${needsLeadingSpace ? " " : ""}${references.map(formatInlineContextReference).join(" ")} `, + }; +} + /** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */ export function insertInlineContextReference( prompt: string, cursorInput: number, reference: ComposerContextReference, ): { prompt: string; cursor: number } { - const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput))); - const needsLeadingSpace = !isBoundaryWhitespace(prompt[cursor - 1]); - const replacement = `${needsLeadingSpace ? " " : ""}${formatInlineContextReference(reference)} `; - const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor; + const edit = inlineContextReferenceReplacement(prompt, { start: cursorInput, end: cursorInput }, [ + reference, + ]); return { - prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`, - cursor: cursor + replacement.length, + prompt: `${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`, + cursor: edit.start + edit.text.length, }; } diff --git a/apps/web/src/lib/desktopPasteAsText.test.ts b/apps/web/src/lib/desktopPasteAsText.test.ts new file mode 100644 index 000000000000..18acb65a49a9 --- /dev/null +++ b/apps/web/src/lib/desktopPasteAsText.test.ts @@ -0,0 +1,32 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { DESKTOP_PASTE_AS_TEXT_EVENT, installDesktopPasteAsText } from "./desktopPasteAsText"; + +describe("desktop paste as text", () => { + it.each([false, true])("pastes with a mounted composer: %s", (hasComposer) => { + const target = new EventTarget(); + let menuAction: ((action: string) => void) | undefined; + const order: string[] = []; + const bridge = { + onMenuAction: (listener) => { + menuAction = listener; + return () => { + menuAction = undefined; + }; + }, + pasteAsText: vi.fn(async () => { + order.push("paste"); + }), + } satisfies Pick; + if (hasComposer) + target.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, () => order.push("armed")); + const uninstall = installDesktopPasteAsText(bridge, target); + menuAction?.("open-settings"); + expect(order).toEqual([]); + menuAction?.("paste-as-text"); + expect(order).toEqual(hasComposer ? ["armed", "paste"] : ["paste"]); + uninstall?.(); + menuAction?.("paste-as-text"); + expect(bridge.pasteAsText).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/lib/desktopPasteAsText.ts b/apps/web/src/lib/desktopPasteAsText.ts new file mode 100644 index 000000000000..f106efbe4b13 --- /dev/null +++ b/apps/web/src/lib/desktopPasteAsText.ts @@ -0,0 +1,15 @@ +import type { DesktopBridge } from "@t3tools/contracts"; + +export const DESKTOP_PASTE_AS_TEXT_EVENT = "t3:paste-as-text"; + +/** Arm composer paste handling before Electron delivers the native clipboard event. */ +export function installDesktopPasteAsText( + bridge: Pick | undefined, + target: EventTarget, +): (() => void) | undefined { + return bridge?.onMenuAction((action) => { + if (action !== "paste-as-text") return; + target.dispatchEvent(new Event(DESKTOP_PASTE_AS_TEXT_EVENT)); + void bridge.pasteAsText?.(); + }); +} diff --git a/apps/web/src/questionAttachments.test.ts b/apps/web/src/questionAttachments.test.ts index 46fbe0260a21..4069c3104e27 100644 --- a/apps/web/src/questionAttachments.test.ts +++ b/apps/web/src/questionAttachments.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, it, vi } from "vite-plus/test"; import { useComposerDraftStore } from "./composerDraftStore"; import { questionAttachmentDraftId, + countQuestionAttachments, questionAttachmentDraftPrefix, changeQuestionAttachmentPreparation, clearQuestionAttachmentDraft, @@ -98,3 +99,43 @@ it("does not clear another environment whose id contains a question prefix", () expect(store.getComposerDraft(ownKey)).toBeNull(); expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer"); }); + +it("counts files, images, and pending preparation across the shared question budget", () => { + const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first"); + const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second"); + const other = questionAttachmentDraftId( + EnvironmentId.make("other"), + threadId, + requestId, + "first", + ); + const store = useComposerDraftStore.getState(); + store.addFiles( + second, + Array.from({ length: 6 }, (_, index) => ({ + type: "file" as const, + id: `file-${index}`, + name: `spec-${index}.txt`, + mimeType: "text/plain", + sizeBytes: 4, + file: new File(["spec"], `spec-${index}.txt`), + })), + ); + store.addImages(first, [ + { + type: "image", + id: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 5, + previewUrl: "blob:count-test", + file: new File(["image"], "image.png"), + }, + ]); + changeQuestionAttachmentPreparation(second, 1); + changeQuestionAttachmentPreparation(other, 8); + expect(countQuestionAttachments([first, second])).toBe(8); + expect(countQuestionAttachments([second])).toBe(7); + changeQuestionAttachmentPreparation(second, -1); + expect(countQuestionAttachments([first, second])).toBe(7); +}); diff --git a/apps/web/src/questionAttachments.ts b/apps/web/src/questionAttachments.ts index 92033d05dc3f..7434411a9dc3 100644 --- a/apps/web/src/questionAttachments.ts +++ b/apps/web/src/questionAttachments.ts @@ -25,6 +25,16 @@ export const useQuestionAttachmentPreparation = create<{ counts: Record): number { + const store = useComposerDraftStore.getState(); + const { counts } = useQuestionAttachmentPreparation.getState(); + return keys.reduce((total, key) => { + const draft = store.getComposerDraft(key); + return total + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + (counts[key] ?? 0); + }, 0); +} + export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void { useQuestionAttachmentPreparation.setState((state) => delta < 0 && !(key in state.counts) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index dd22177f0143..ed874821c0cb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -67,6 +67,7 @@ import { } from "../components/KeybindingsUpdateToast.logic"; import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; +import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; export const Route = createRootRoute({ @@ -108,6 +109,7 @@ export const Route = createRootRoute({ }); function RootRouteView() { + useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 904688f49ab5..2dbcaeeb7929 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("deriveWorkLogEntries quiet-timeline guarantee", () => { - it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { + it("concurrent subagents replace their launch tools with one lifecycle row", () => { const activities: OrchestrationThreadActivity[] = []; + for (let agent = 0; agent < 5; agent += 1) { + activities.push( + makeActivity({ + kind: "tool.updated", + summary: "Subagent task", + payload: { + toolCallId: `launch-${agent}`, + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: agent % 2 === 0 ? "Agent" : "Task" }, + }, + turnId: "turn-batch", + sequence: agent - 10, + }), + ); + expect(deriveWorkLogEntries(activities)).toHaveLength(0); + } for (let agent = 0; agent < 5; agent += 1) { const taskId = `task-${agent}`; + const toolUseId = `launch-${agent}`; + expect(deriveWorkLogEntries(activities)).toHaveLength(agent === 0 ? 0 : 1); + activities.push( + makeActivity({ + id: `started-${agent}`, + kind: "task.started", + summary: "Task started", + payload: { taskId, toolUseId, taskType: "local_agent" }, + turnId: "turn-batch", + sequence: agent * 20 - 1, + }), + ); + const runningEntries = deriveWorkLogEntries(activities); + expect(runningEntries).toHaveLength(1); + expect(runningEntries[0]!.id).toBe("started-0"); + expect(runningEntries[0]!.agentSpawn?.agentTaskIds).toHaveLength(agent + 1); // Progress ticks (several per agent) + attributed tool rows. for (let tick = 0; tick < 4; tick += 1) { activities.push( @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { kind: "task.progress", summary: `agent ${agent} tick ${tick}`, tone: "info", - payload: { taskId, summary: `working ${tick}`, role: "explorer" }, + payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" }, turnId: "turn-batch", sequence: agent * 20 + tick, }), @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { tone: "info", payload: { taskId, + toolUseId, status: "completed", summary: `agent ${agent} done`, role: "explorer", @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { turnId: "turn-batch", sequence: agent * 20 + 19, }), + makeActivity({ + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: toolUseId, status: "completed" }, + turnId: "turn-batch", + sequence: agent * 20 + 19, + }), ); } @@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { ); }); - it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { + it("keeps unrelated tools and failed launches, including failures after a task starts", () => { const entries = deriveWorkLogEntries([ makeActivity({ kind: "tool.completed", summary: "Bash", payload: { itemType: "command_execution", command: "ls" }, }), + makeActivity({ + id: "unlinked-failure", + kind: "tool.completed", + summary: "Subagent task", + tone: "error", + payload: { toolCallId: "unlinked", status: "failed" }, + }), + makeActivity({ + id: "linked-task", + kind: "task.started", + summary: "Task started", + payload: { taskId: "agent", toolUseId: "linked", taskType: "local_agent" }, + }), + makeActivity({ + id: "linked-failure", + kind: "tool.completed", + summary: "Subagent task", + payload: { toolCallId: "linked", status: "failed" }, + }), + makeActivity({ + id: "orphan-completion", + kind: "tool.completed", + summary: "Subagent task", + payload: { + toolCallId: "orphan", + itemType: "collab_agent_tool_call", + status: "completed", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: "send-input", + kind: "tool.updated", + payload: { + toolCallId: "send-input", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "send_input" }, + }, + }), + makeActivity({ + id: "active-launch-error", + kind: "tool.updated", + tone: "error", + payload: { + toolCallId: "active-launch-error", + itemType: "collab_agent_tool_call", + status: "inProgress", + data: { toolName: "Task" }, + }, + }), ]); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(7); + expect(entries.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["unlinked-failure", "linked-task", "linked-failure"]), + ); }); it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d3d81dde9c45..6a0920681bc3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -83,10 +83,9 @@ export interface WorkLogEntry { /** Agent role (subagent_type) for labeled timeline rows. */ agentRole?: string; /** - * Present on agent-spawn CTA rows: one per workflow run or per-turn batch - * of direct spawns. The row renders as a call-to-action ("Kicked off N - * subagents") whose live status is derived from the agent panel model at - * render time; clicking opens the Agents panel. + * Present on agent-spawn rows: one per workflow run or per-turn batch of + * direct spawns. The row ("Kicked off N subagents") derives its live + * status and member list from the agent panel model at render time. */ agentSpawn?: { /** Workflow coordinator taskId, or null for a direct-spawn batch. */ @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( * - tool rows attributed to an owning agent (payload.agentId) are re-homed; * - task.progress ticks collapse into one row per taskId; * - task.updated is fold input only (status patches are not narrative). - * Unattributed rows always stay: over-hiding loses the only terminal signal. + * Unattributed rows stay unless a linked agent row replaces their launch; + * failed launches stay so the only terminal signal cannot disappear. */ /** Agent (non-background) task.started rows seed spawn CTA batches. */ function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean activity.kind === "task.completed"; // Task rows classify by the server stamp: a subagent's own background // shell (agentId + "background") is agent-internal, but a nested AGENT - // (agentId + "agent") stays visible so its rows can anchor a spawn CTA + // (agentId + "agent") stays visible so its rows can anchor a spawn row // (review finding: hiding on agentId alone removed nested agents and // their anchors). Bypassed agent lifecycle rows also pass — collapse // folds every such row into its batch's single CTA row, which is how @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); + // A launch tool and its task lifecycle describe the same run. Only hide + // launch rows once their tool-use id has an agent row to replace them. + const agentLaunchToolIds = new Set(); + for (const activity of ordered) { + if ( + (activity.kind === "task.started" || + activity.kind === "task.progress" || + activity.kind === "task.completed") && + isAgentTaskStartedActivity(activity) + ) { + const toolUseId = asTrimmedString(asRecord(activity.payload)?.toolUseId); + if (toolUseId) agentLaunchToolIds.add(toolUseId); + } + } const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + const entry = toDerivedWorkLogEntry(activity); + // Native agent launches get their visible row from task.started. Defer + // their active tool row so another launch cannot duplicate the batch. + if ( + activity.kind === "tool.updated" && + entry.itemType === "collab_agent_tool_call" && + entry.toolLifecycleStatus === "inProgress" && + entry.tone !== "error" + ) { + const toolName = asRecord(asRecord(activity.payload)?.data)?.toolName; + if (toolName === "Agent" || toolName === "Task") continue; + } + if ( + (activity.kind === "tool.updated" || activity.kind === "tool.completed") && + entry.toolCallId && + agentLaunchToolIds.has(entry.toolCallId) && + entry.tone !== "error" && + entry.toolLifecycleStatus !== "failed" + ) { + continue; + } + entries.push(entry); } return collapseDerivedWorkLogEntries(entries); } @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( const collapsed: DerivedWorkLogEntry[] = []; // Subagent rows collapse by spawn group, not adjacency: a workflow run (or // a turn's batch of direct spawns) is ONE narrative event in the chat — a - // CTA row that opens the Agents panel — no matter how many agents it + // spawn row in the timeline — no matter how many agents it // contains or how their progress rows interleave (quiet-timeline // guarantee). const spawnRowIndex = new Map(); diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 745e674400d3..aef4f1ba16f6 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata label: environmentId, }), profile: Option.none(), + enabled: true, }, ]), ), diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index b1719819da9d..e8f61209ae64 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -8,7 +8,10 @@ import { createShellEnvironmentAtoms, type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import { + type EnvironmentCatalogState, + enabledEnvironmentIds, +} from "@t3tools/client-runtime/state/connections"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isNone(catalog)) { return false; } - for (const environmentId of catalog.value.entries.keys()) { + for (const environmentId of enabledEnvironmentIds(catalog.value)) { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; } @@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { ) { return false; } - for (const environmentId of catalog.entries.keys()) { + for (const environmentId of enabledEnvironmentIds(catalog)) { const shell = get(input.shellStateValueAtom(environmentId)); if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; } diff --git a/docs/user/composer.md b/docs/user/composer.md index 186bbf034aa0..4da452215893 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -6,6 +6,12 @@ include a skill when the task needs more context. Messages can contain up to 120,000 characters. Longer drafts stay in the composer so you can shorten them or split them into several messages. +Pasting 32 KiB or more of text adds that fragment as a text-file attachment so +the agent can inspect it without filling the model context. A smaller paste also +becomes an attachment when inserting it would exceed the message limit. On a +hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere +to keep a large paste editable in the composer instead. + ## Attach files Attach up to eight files per message. Images can be up to 10 MB; other files can diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..f4fdb5cca002 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -9,6 +9,12 @@ cost. These estimates are not your subscription bill. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +Usage includes each configured account's history, including disabled accounts. Custom homes follow +the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment +variable. Use absolute paths or `~/` paths in the account's environment settings; relative +environment paths depend on each project's working directory and cannot be reliably discovered +by Usage. Accounts sharing a history directory count once. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds. diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 5037dc943413..a2d1997e43f9 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -2,6 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; +import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; @@ -14,6 +15,7 @@ export default definePlugin({ rules: { "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, + "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts new file mode 100644 index 000000000000..4bd751c13c9a --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts @@ -0,0 +1,57 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", { + filename: "fixture.ts", +}); + +describe("t3code/no-hermes-unsupported-array-methods", () => { + rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`); + + rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`); + + rule.valid( + "allows other ES2023 methods Hermes ships", + `const last = items.findLast(Boolean); const tail = items.at(-1);`, + ); + + rule.valid( + "ignores property reads that are not calls", + `const hasToSorted = typeof Array.prototype.toSorted === "function";`, + ); + + rule.invalid("reports toSorted", `const sorted = items.toSorted(compare);`, (output) => { + assert.match(output, /Array#toSorted/); + }); + + rule.invalid( + "reports toReversed in a chain", + `const open = chains.map((chain) => chain.layers.toReversed().filter(isOpen));`, + (output) => { + assert.match(output, /Array#toReversed/); + }, + ); + + rule.invalid( + "reports toSpliced via computed access with a copy-then-splice remediation", + `const next = items["toSpliced"](0, 1);`, + (output) => { + assert.match(output, /Array#toSpliced/); + assert.match(output, /const copy = \[\.\.\.array\]; copy\.splice\(\.\.\.\); use copy/); + }, + ); + + rule.invalid( + "reports a static template-literal property name", + "const reversed = items[`toReversed`]();", + (output) => { + assert.match(output, /Array#toReversed/); + }, + ); + + rule.valid( + "ignores a template-literal property with substitutions", + "const value = items[`to${suffix}`]();", + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts new file mode 100644 index 000000000000..e5dd8867e696 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts @@ -0,0 +1,45 @@ +import { defineRule } from "@oxlint/plugins"; + +// ES2023 change-array-by-copy methods. Hermes does not implement them, and +// tsconfig targets ESNext, so nothing but this rule stands between a call and a +// TypeError that is fatal on every mobile launch that reaches it. +const UNSUPPORTED_METHODS = new Map([ + ["toSorted", "[...array].sort(...)"], + ["toReversed", "[...array].reverse()"], + // splice returns the removed elements, so the copy itself is the result. + ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"], +]); + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.", + }, + }, + create(context) { + return { + CallExpression(node) { + if (node.callee.type !== "MemberExpression") return; + const { property } = node.callee; + const name = + property.type === "Identifier" + ? property.name + : property.type === "Literal" && typeof property.value === "string" + ? property.value + : property.type === "TemplateLiteral" && property.expressions.length === 0 + ? (property.quasis[0]?.value.cooked ?? null) + : null; + if (name === null) return; + const replacement = UNSUPPORTED_METHODS.get(name); + if (replacement === undefined) return; + + context.report({ + node: property, + message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`, + }); + }, + }; + }, +}); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bf2f9c1be3bd..b4a3d5da118d 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -103,6 +103,10 @@ "types": "./src/state/attachments.ts", "default": "./src/state/attachments.ts" }, + "./text-paste": { + "types": "./src/textPaste.ts", + "default": "./src/textPaste.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index a79307947c5e..5b6ccd791b96 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type; export interface ConnectionCatalogEntry { readonly target: ConnectionTarget; readonly profile: Option.Option; + /** False when the user switched the environment off: saved, but never connects. */ + readonly enabled: boolean; } export class BearerConnectionCredential extends Schema.TaggedClass()( @@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry( return { target: registration.target, profile: Option.none(), + enabled: true, }; case "BearerConnectionRegistration": case "SshConnectionRegistration": return { target: registration.target, profile: Option.some(registration.profile), + enabled: true, }; } } diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb0..a0c73d2bba87 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -185,6 +185,7 @@ describe("connection onboarding", () => { wsBaseUrl: "ws://old.example.test/", }), ), + enabled: true, }), credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), }); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 80ce8a374a9c..979b6adb4003 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = { wsBaseUrl: "wss://environment.example.test", }), ), + enabled: true, }; function supervisorState(overrides: Partial): SupervisorConnectionState { diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 15df040643ea..ba0d9e94ac65 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -140,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeRegistrationRemove?: ( target: ConnectionTarget, ) => Effect.Effect; + readonly initialDisabled?: ReadonlyArray; }, ) { const storedTargets = yield* Ref.make( @@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ); const disconnectedSshTargets = yield* Ref.make>([]); + const storedDisabled = yield* Ref.make>( + new Set(options?.initialDisabled ?? []), + ); const targetStore = Persistence.ConnectionTargetStore.of({ list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), + listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration) => @@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }); }), + setEnabled: (environmentId, enabled) => + Ref.update(storedDisabled, (current) => { + const next = new Set(current); + if (enabled) { + next.delete(environmentId); + } else { + next.add(environmentId); + } + return next; + }), }); const cacheStore = Persistence.EnvironmentCacheStore.of({ loadShell: (environmentId) => @@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( profileReadCount, storedCredentials, storedRemoteTokens, + storedDisabled, disconnectedSshTargets, networkStatus, }; @@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("switching an environment off disconnects it and persists the flag", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.setEnabled(RELAY_TARGET.environmentId, false); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "available", + ); + + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + RELAY_TARGET.environmentId, + ); + expect(entry?.enabled).toBe(false); + expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true); + expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.releasedSessions)).toBe(1); + + yield* registry.setEnabled(RELAY_TARGET.environmentId, true); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe( + false, + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("re-registering a switched-off environment keeps it off", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + initialDisabled: [RELAY_TARGET.environmentId], + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* registry.register( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ ...RELAY_TARGET, label: "Renamed" }), + }), + ); + yield* Effect.yieldNow; + + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + RELAY_TARGET.environmentId, + ); + expect(entry?.target.label).toBe("Renamed"); + expect(entry?.enabled).toBe(false); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("switching an SSH environment off tears down its managed backend", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + SSH_CONNECTION.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.setEnabled(SSH_CONNECTION.environmentId, false); + yield* awaitConnectionState( + registry, + SSH_CONNECTION.environmentId, + (state) => state.phase === "available", + ); + + expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); + expect((yield* Ref.get(harness.storedTargets)).has(SSH_CONNECTION.environmentId)).toBe( + true, + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("does not connect a persisted environment that was switched off", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + initialDisabled: [RELAY_TARGET.environmentId], + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* Effect.yieldNow; + + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + RELAY_TARGET.environmentId, + ); + expect(entry?.enabled).toBe(false); + expect((yield* registry.state(RELAY_TARGET.environmentId)).phase).toBe("available"); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("moves durable streams to a replacement supervisor", () => Effect.gen(function* () { const replacement = new RelayConnectionTarget({ diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 16983fcbcc9f..dda8ee429eb4 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError >; readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + /** + * Switches a saved environment on or off. Off drops the socket, stops the + * retry ladder, and persists so the next launch stays off. Registration, + * credentials, and cache are untouched. + */ + readonly setEnabled: ( + environmentId: EnvironmentId, + enabled: boolean, + ) => Effect.Effect< + void, + EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError + >; readonly state: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -139,6 +151,7 @@ export const make = Effect.gen(function* () { const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; + const disabledEnvironmentIds = new Set(yield* storage.listDisabled); const initialEntries = new Map( yield* Effect.forEach( persistedTargets, @@ -149,7 +162,11 @@ export const make = Effect.gen(function* () { : Option.none(); return [ target.environmentId, - { target, profile } satisfies ConnectionCatalogEntry, + { + target, + profile, + enabled: !disabledEnvironmentIds.has(target.environmentId), + } satisfies ConnectionCatalogEntry, ] as const; }), { concurrency: "unbounded" }, @@ -261,7 +278,9 @@ export const make = Effect.gen(function* () { Scope.provide(scope), Effect.onError(() => Scope.close(scope, Exit.void)), ); - yield* supervisor.connect; + if (entry.enabled) { + yield* supervisor.connect; + } yield* SubscriptionRef.update(serviceScopes, (current) => { const next = new Map(current); next.set(environmentId, { entry, supervisor, scope }); @@ -391,14 +410,19 @@ export const make = Effect.gen(function* () { const register = Effect.fn("EnvironmentRegistry.register")(function* ( registration: ConnectionRegistration, ) { - const entry = connectionRegistrationCatalogEntry(registration); - const environmentId = entry.target.environmentId; + const registered = connectionRegistrationCatalogEntry(registration); + const environmentId = registered.target.environmentId; yield* withLeaseLock( environmentId, Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return; } + // Editing a saved environment re-registers it; that must not switch a + // disabled one back on. + const previous = (yield* SubscriptionRef.get(entries)).get(environmentId); + const entry: ConnectionCatalogEntry = + previous === undefined ? registered : { ...registered, enabled: previous.enabled }; yield* registrations.register(registration); yield* Ref.update(persistedTargetsByEnvironment, (current) => { const next = new Map(current); @@ -630,6 +654,65 @@ export const make = Effect.gen(function* () { Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), Effect.withSpan("EnvironmentRegistry.retryNow"), ); + const setEnabled = Effect.fn("EnvironmentRegistry.setEnabled")(function* ( + environmentId: EnvironmentId, + enabled: boolean, + ) { + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* getEntry(environmentId); + if (entry.enabled === enabled) { + return; + } + // Platform-managed environments are reconciled from the host and are + // never persisted, so only user-saved ones write the flag. + if (!(yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + yield* registrations.setEnabled(environmentId, enabled); + } + const next: ConnectionCatalogEntry = { ...entry, enabled }; + // Update the lease in place so the supervisor keeps its generation and + // durable streams; `installEntryLocked` would tear it down instead. + const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (lease !== undefined) { + yield* SubscriptionRef.update(serviceScopes, (current) => { + const nextScopes = new Map(current); + nextScopes.set(environmentId, { ...lease, entry: next }); + return nextScopes; + }); + } + yield* SubscriptionRef.update(entries, (current) => { + const nextEntries = new Map(current); + nextEntries.set(environmentId, next); + return nextEntries; + }); + if (lease !== undefined) { + yield* enabled ? lease.supervisor.connect : lease.supervisor.disconnect; + } else if (enabled) { + yield* createServiceScope(next); + } + // The supervisor only owns the RPC session. A managed SSH backend and + // its tunnel outlive it, so switching off tears those down as well. + if ( + !enabled && + entry.target._tag === "SshConnectionTarget" && + Option.isSome(entry.profile) && + isSshConnectionProfile(entry.profile.value) + ) { + yield* ssh.disconnect(entry.profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the switched-off SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + }), + ); + }); + const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { const supervisor = yield* acquireSupervisor(environmentId); return yield* SubscriptionRef.get(supervisor.state); @@ -669,6 +752,7 @@ export const make = Effect.gen(function* () { remove, removeRelayEnvironments, retryNow, + setEnabled, state, stateChanges, run, diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d1fc270f21fe..35d9d9c54e0d 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -45,7 +45,7 @@ function catalogEntry( target: ConnectionTarget, profile: Option.Option = Option.none(), ): ConnectionCatalogEntry { - return { target, profile }; + return { target, profile, enabled: true }; } function collectingTracer(spans: Array): Tracer.Tracer { diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 2da7a68bd3ca..882e67aac33c 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({ const TARGET_ENTRY: ConnectionCatalogEntry = { target: TARGET, profile: Option.none(), + enabled: true, }; const RELAY_ENTRY: ConnectionCatalogEntry = { target: RELAY_TARGET, profile: Option.none(), + enabled: true, }; const PREPARED_CONNECTION: PreparedConnection = { diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 2e59aeee24e0..99469081dc10 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError, ConnectionPersistenceError>; + /** Saved environments the user switched off. See `ConnectionRegistrationStore.setEnabled`. */ + readonly listDisabled: Effect.Effect, ConnectionPersistenceError>; } >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} @@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service< registration: ConnectionRegistration, ) => Effect.Effect; readonly remove: (target: ConnectionTarget) => Effect.Effect; + readonly setEnabled: ( + environmentId: EnvironmentId, + enabled: boolean, + ) => Effect.Effect; } >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index c9ab41e3bf51..ae0f04268666 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -22,9 +22,11 @@ import { putRemoteDpopTokenInCatalog, registerConnectionInCatalog, removeConnectionFromCatalog, + setConnectionEnabledInCatalog, } from "./storageDocument.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument); const RELAY_TARGET = new RelayConnectionTarget({ environmentId: ENVIRONMENT_ID, @@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => { expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay); }); + it("decodes a document written before the disabled list existed", () => { + const decoded = decodeCatalogDocument({ + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], + }); + + expect(decoded.disabledEnvironmentIds).toEqual([]); + }); + + it("switches a saved environment off and back on without touching its records", () => { + const registered = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + const disabled = setConnectionEnabledInCatalog(registered, ENVIRONMENT_ID, false); + expect(disabled.disabledEnvironmentIds).toEqual([ENVIRONMENT_ID]); + expect(disabled.targets).toEqual(registered.targets); + expect(disabled.credentials).toEqual(registered.credentials); + // Idempotent: switching off twice stores the id once. + expect( + setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, false).disabledEnvironmentIds, + ).toEqual([ENVIRONMENT_ID]); + + expect( + setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, true).disabledEnvironmentIds, + ).toEqual([]); + // Re-registering (editing label or URL) keeps the flag. + expect( + registerConnectionInCatalog( + disabled, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ).disabledEnvironmentIds, + ).toEqual([ENVIRONMENT_ID]); + expect(removeConnectionFromCatalog(disabled, BEARER_TARGET).disabledEnvironmentIds).toEqual([]); + }); + it("persists the normalized SSH profile beside its target", () => { const target = new SshConnectionTarget({ environmentId: ENVIRONMENT_ID, diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 40a008b6c9ac..1ef448b3403e 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -1,3 +1,5 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { @@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({ profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), + // Saved environments the user switched off. They stay registered with their + // credentials and cache but never connect until switched back on. Older + // documents predate the key, so decoding defaults it to none. + disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( + Schema.withDecodingDefaultKey(Effect.succeed([])), + ), }); export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; @@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje profiles: [], credentials: [], remoteDpopTokens: [], + disabledEnvironmentIds: [], }); export function replaceCatalogValue( @@ -87,6 +96,11 @@ function removeConnectionMetadata( target.environmentId, ) : document.remoteDpopTokens, + // Re-registration passes `removeRemoteToken: false` and must keep the + // switched-off flag; only a real removal clears it. + disabledEnvironmentIds: removeRemoteToken + ? removeCatalogValue(document.disabledEnvironmentIds, (value) => value, target.environmentId) + : document.disabledEnvironmentIds, }; } @@ -100,6 +114,8 @@ export function registerConnectionInCatalog( ); const cleaned = previous === undefined ? document : removeConnectionMetadata(document, previous, false); + // Re-registering (for example editing a label or URL) keeps the disabled + // flag; only `setConnectionEnabledInCatalog` or removal changes it. const next: ConnectionCatalogDocument = { ...cleaned, targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), @@ -140,6 +156,24 @@ export function removeConnectionFromCatalog( return removeConnectionMetadata(document, target, true); } +/** Flips the disabled flag for a saved environment; unknown ids are ignored. */ +export function setConnectionEnabledInCatalog( + document: ConnectionCatalogDocument, + environmentId: EnvironmentId, + enabled: boolean, +): ConnectionCatalogDocument { + const registered = document.targets.some((target) => target.environmentId === environmentId); + const without = removeCatalogValue( + document.disabledEnvironmentIds, + (value) => value, + environmentId, + ); + return { + ...document, + disabledEnvironmentIds: registered && !enabled ? [...without, environmentId] : without, + }; +} + export function putRemoteDpopTokenInCatalog( document: ConnectionCatalogDocument, token: TokenStore.RemoteDpopAccessToken, diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index a81739db1b7e..c4f1b1caa600 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -20,6 +20,21 @@ export interface EnvironmentCatalogState { readonly entries: ReadonlyMap; } +/** + * Environments that take part in the workspace: projects, threads, and shell + * summaries only come from these. Disabled environments stay in `entries` so + * Settings can list them and switch them back on. + */ +export function* enabledEnvironmentIds( + catalog: EnvironmentCatalogState, +): Generator { + for (const [environmentId, entry] of catalog.entries) { + if (entry.enabled) { + yield environmentId; + } + } +} + const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ isReady: false, entries: new Map(), @@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms( Effect.flatMap((registry) => registry.removeRelayEnvironments()), ), }); + const setEnabled = createRuntimeCommand(runtime, { + label: "environment-catalog:set-enabled", + scheduler: commandScheduler, + concurrency: serial, + execute: (input: { readonly environmentId: EnvironmentIdType; readonly enabled: boolean }) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)), + ), + }); const retryNow = createRuntimeCommand(runtime, { label: "environment-catalog:retry-now", scheduler: commandScheduler, @@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms( remove, removeRelayEnvironments, retryNow, + setEnabled, }; } diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 7b33a5e7ebde..2efe1076dc4b 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState }; } -function makeHarness(environmentIds: ReadonlyArray = [ENVIRONMENT_ID]) { +function makeHarness( + environmentIds: ReadonlyArray = [ENVIRONMENT_ID], + disabledEnvironmentIds: ReadonlySet = new Set(), +) { const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => Atom.make(AsyncResult.success(shellState(SNAPSHOT))), ); @@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray = [ENVIRONMENT wsBaseUrl: "wss://example.test", }), profile: Option.none(), + enabled: !disabledEnvironmentIds.has(environmentId), }, ]), ), @@ -361,6 +365,23 @@ describe("environment entity projections", () => { } }); + it("hides projects and threads of a switched-off environment while keeping its cache", () => { + const offEnvironmentId = EnvironmentId.make("off-environment"); + const harness = makeHarness([ENVIRONMENT_ID, offEnvironmentId], new Set([offEnvironmentId])); + const projects = harness.registry.get(harness.projects.projectsAtom); + const threads = harness.registry.get(harness.threadShells.threadShellsAtom); + + expect(projects.every((project) => project.environmentId === ENVIRONMENT_ID)).toBe(true); + expect(projects).toHaveLength(2); + expect(threads.every((thread) => thread.environmentId === ENVIRONMENT_ID)).toBe(true); + expect(threads).toHaveLength(2); + // The per-environment atoms still read the cached snapshot, so switching + // back on restores the rows without a refetch. + expect( + harness.registry.get(harness.projects.environmentProjectsAtom(offEnvironmentId)), + ).toHaveLength(2); + }); + it("keeps scoped identities and list order across project and environment changes", () => { const remoteEnvironmentId = EnvironmentId.make("remote-environment"); const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]); diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts index 4d51b4d427e9..ade1edccb50d 100644 --- a/packages/client-runtime/src/state/projectEntities.ts +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -9,7 +9,7 @@ import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentProject } from "./models.ts"; import { scopeProject } from "./models.ts"; -import type { EnvironmentCatalogState } from "./connections.ts"; +import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; const EMPTY_PROJECTS: ReadonlyArray = Object.freeze([]); @@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: { let previousProjectRefs: ReadonlyArray = []; const projectRefsAtom = Atom.make((get) => { const refs: ScopedProjectRef[] = []; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { refs.push(...get(environmentProjectRefsAtom(environmentId))); } if (projectRefsEqual(previousProjectRefs, refs)) { diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts index f1326e0a5cbe..3be7ac2142c8 100644 --- a/packages/client-runtime/src/state/shell.test.ts +++ b/packages/client-runtime/src/state/shell.test.ts @@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) { wsBaseUrl: `wss://${environmentId}.example.test`, }), profile: Option.none(), + enabled: true, }; } diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 95d90f9b36f2..b7f39b509462 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; -import type { EnvironmentCatalogState } from "./connections.ts"; +import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; @@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: { let firstError: string | null = null; let latestSnapshotUpdatedAt: string | null = null; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const state = get(input.shellStateValueAtom(environmentId)); hasSynchronizingShell ||= state.status === "synchronizing"; hasCachedShell ||= state.status === "cached"; @@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: { let previousServerConfigs = EMPTY_SERVER_CONFIGS; return Atom.make((get) => { const next = new Map(); - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const config = get(input.serverConfigValueAtom(environmentId)); if (config !== null) { next.set(environmentId, config); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 03f92a612e4a..69544e344718 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; import { scopeThreadShell } from "./models.ts"; -import type { EnvironmentCatalogState } from "./connections.ts"; +import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, parseProjectRefCollectionKey, @@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: { let previousThreadRefs: ReadonlyArray = []; const threadRefsAtom = Atom.make((get) => { const refs: ScopedThreadRef[] = []; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { refs.push(...get(environmentThreadRefsAtom(environmentId))); } if (threadRefsEqual(previousThreadRefs, refs)) { @@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: { let previousThreadShells: ReadonlyArray = []; const threadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { for (const thread of get(environmentThreadsAtom(environmentId))) { next.push(scopedThread(environmentId, thread)); } diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index f29e2e15f894..d65bb03c64f9 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? remove: () => Effect.die("Unexpected environment removal"), removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), retryNow: () => Effect.void, + setEnabled: () => Effect.die("Unexpected environment toggle"), state: () => SubscriptionRef.get(supervisor.state), stateChanges: () => SubscriptionRef.changes(supervisor.state), run: (_environmentId, effect) => diff --git a/packages/client-runtime/src/textPaste.test.ts b/packages/client-runtime/src/textPaste.test.ts new file mode 100644 index 000000000000..e5f5b06c4795 --- /dev/null +++ b/packages/client-runtime/src/textPaste.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + nextPastedTextFileName, + PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES, + isPasteAsTextShortcut, + pastedTextDisposition, + replaceTextSelection, + wouldTextPasteExceedLimit, +} from "./textPaste.ts"; + +describe("pasted text disposition", () => { + it("keeps ordinary text inline and folds at the 32 KiB boundary", () => { + expect( + pastedTextDisposition({ + text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES - 1), + canAttach: true, + }), + ).toBe("inline"); + expect( + pastedTextDisposition({ + text: "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES), + canAttach: true, + }), + ).toBe("attachment"); + }); + + it("measures UTF-8 bytes instead of UTF-16 characters", () => { + const text = "🙂".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES / 4); + expect(text.length).toBeLessThan(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); + expect(pastedTextDisposition({ text, canAttach: true })).toBe("attachment"); + }); + + it("keeps text inline for the explicit bypass or without attachment support", () => { + const text = "x".repeat(PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES); + expect(pastedTextDisposition({ text, canAttach: true, bypassAutoAttachment: true })).toBe( + "inline", + ); + expect(pastedTextDisposition({ text, canAttach: false })).toBe("inline"); + }); + + it("folds a smaller paste when the resulting prompt would exceed the input limit", () => { + expect( + pastedTextDisposition({ + text: "small paste", + canAttach: true, + wouldExceedInputLimit: true, + }), + ).toBe("attachment"); + }); +}); + +describe("pasted text attachment names", () => { + it("uses the first available readable sequence", () => { + expect(nextPastedTextFileName([])).toBe("pasted-text.txt"); + expect(nextPastedTextFileName(["PASTED-TEXT.TXT", "pasted-text-2.txt"])).toBe( + "pasted-text-3.txt", + ); + }); +}); + +describe("paste-as-text shortcut", () => { + it.each([ + { metaKey: true, ctrlKey: false, macPlatform: true }, + { metaKey: false, ctrlKey: true, macPlatform: false }, + ])("accepts the platform modifier with Shift", ({ metaKey, ctrlKey, macPlatform }) => { + expect( + isPasteAsTextShortcut( + { + key: "V", + metaKey, + ctrlKey, + shiftKey: true, + altKey: false, + }, + macPlatform, + ), + ).toBe(true); + }); + + it("does not claim ordinary or alternate paste chords", () => { + expect( + isPasteAsTextShortcut( + { + key: "v", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + true, + ), + ).toBe(false); + expect( + isPasteAsTextShortcut( + { + key: "v", + metaKey: true, + ctrlKey: false, + shiftKey: true, + altKey: true, + }, + true, + ), + ).toBe(false); + }); + + it("rejects the other platform's modifier", () => { + const event = { + key: "v", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + }; + expect(isPasteAsTextShortcut(event, true)).toBe(false); + expect(isPasteAsTextShortcut({ ...event, metaKey: true, ctrlKey: false }, false)).toBe(false); + }); +}); + +describe("text paste insertion", () => { + it("replaces the selected range and returns the collapsed cursor", () => { + expect( + replaceTextSelection({ + value: "before old after", + selection: { start: 7, end: 10 }, + text: "new", + }), + ).toEqual({ value: "before new after", cursor: 10 }); + }); + + it("clamps stale native selections", () => { + expect( + replaceTextSelection({ value: "abc", selection: { start: 20, end: 30 }, text: "!" }), + ).toEqual({ value: "abc!", cursor: 4 }); + }); + + it("measures the replacement value after removing the selected range", () => { + expect( + wouldTextPasteExceedLimit({ + valueLength: 100, + selection: { start: 40, end: 80 }, + textLength: 30, + maxLength: 100, + }), + ).toBe(false); + expect( + wouldTextPasteExceedLimit({ + valueLength: 100, + selection: { start: 40, end: 80 }, + textLength: 41, + maxLength: 100, + }), + ).toBe(true); + }); +}); diff --git a/packages/client-runtime/src/textPaste.ts b/packages/client-runtime/src/textPaste.ts new file mode 100644 index 000000000000..4155f0baad78 --- /dev/null +++ b/packages/client-runtime/src/textPaste.ts @@ -0,0 +1,76 @@ +export const PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES = 32 * 1024; + +const textEncoder = new TextEncoder(); + +export type PastedTextDisposition = "attachment" | "inline"; + +export function isPasteAsTextShortcut( + event: Pick, + macPlatform: boolean, +): boolean { + return ( + event.key.toLowerCase() === "v" && + event.shiftKey && + !event.altKey && + (macPlatform ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) + ); +} + +/** + * Large clipboard text becomes a file so an agent can inspect it selectively. + * The threshold is byte-based: character counts substantially understate the + * context cost of some Unicode-heavy clipboard contents. + */ +export function pastedTextDisposition(input: { + readonly text: string; + readonly canAttach: boolean; + readonly bypassAutoAttachment?: boolean; + readonly wouldExceedInputLimit?: boolean; +}): PastedTextDisposition { + if (input.bypassAutoAttachment || !input.canAttach || input.text.length === 0) { + return "inline"; + } + return input.wouldExceedInputLimit || + input.text.length >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES || + textEncoder.encode(input.text).byteLength >= PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES + ? "attachment" + : "inline"; +} + +/** Stable, human-readable names when a draft contains several folded pastes. */ +export function nextPastedTextFileName(existingNames: ReadonlyArray): string { + const names = new Set(existingNames.map((name) => name.toLowerCase())); + if (!names.has("pasted-text.txt")) { + return "pasted-text.txt"; + } + for (let sequence = 2; ; sequence += 1) { + const candidate = `pasted-text-${sequence}.txt`; + if (!names.has(candidate)) { + return candidate; + } + } +} + +export function replaceTextSelection(input: { + readonly value: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly text: string; +}): { readonly value: string; readonly cursor: number } { + const start = Math.max(0, Math.min(input.value.length, input.selection.start)); + const end = Math.max(start, Math.min(input.value.length, input.selection.end)); + return { + value: `${input.value.slice(0, start)}${input.text}${input.value.slice(end)}`, + cursor: start + input.text.length, + }; +} + +export function wouldTextPasteExceedLimit(input: { + readonly valueLength: number; + readonly selection: { readonly start: number; readonly end: number }; + readonly textLength: number; + readonly maxLength: number; +}): boolean { + const start = Math.max(0, Math.min(input.valueLength, input.selection.start)); + const end = Math.max(start, Math.min(input.valueLength, input.selection.end)); + return input.valueLength - (end - start) + input.textLength > input.maxLength; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e54abc5c0083..a223ef5feb94 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1308,6 +1308,8 @@ export interface DesktopBridge { * builds lack it; callers fall back to VS Code only. */ probeRemoteEditors?: () => Promise; + /** Present when the desktop shell can perform an ordered plain-text paste. */ + pasteAsText?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; /** diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4a9b7b3e554f..774a630ad44b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -310,6 +310,9 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; +export const PastedTextAttachmentSource = Schema.TaggedStruct("pasted-text", {}); +export type PastedTextAttachmentSource = typeof PastedTextAttachmentSource.Type; + export const ChatFileAttachment = Schema.Struct({ type: Schema.Literal("file"), id: ChatAttachmentId, @@ -319,6 +322,10 @@ export const ChatFileAttachment = Schema.Struct({ Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), ), + /** Clipboard text folded by a client. Providers keep these path-only so the + agent can inspect the file selectively instead of eagerly spending the + same context the fold is intended to preserve. */ + source: Schema.optional(PastedTextAttachmentSource), }); export type ChatFileAttachment = typeof ChatFileAttachment.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2fb6ead09c16..48f147be051e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -164,6 +164,20 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings default diff file state", () => { + it("keeps files expanded when existing settings omit the preference", () => { + expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false); + }); + + it.each([true, false])("preserves a saved collapsed preference of %s", (diffFilesCollapsed) => { + const settings = decodeClientSettings({ diffFilesCollapsed }); + expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed); + expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe( + diffFilesCollapsed, + ); + }); +}); + describe("ClientSettings diff colors", () => { it("keeps red and green for existing settings without a saved palette", () => { expect(decodeClientSettings({}).diffColorScheme).toBe("red-green"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6b931f4ef3ea..b420b737ee0a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -344,6 +344,7 @@ export const ClientSettingsSchema = Schema.Struct({ dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + diffFilesCollapsed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( @@ -1432,6 +1433,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), + diffFilesCollapsed: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), diff --git a/packages/shared/src/composerContextLegacy.test.ts b/packages/shared/src/composerContextLegacy.test.ts index 440dfbaa3d32..ee52498cb3f4 100644 --- a/packages/shared/src/composerContextLegacy.test.ts +++ b/packages/shared/src/composerContextLegacy.test.ts @@ -582,6 +582,22 @@ describe("upgradeLegacyContextMessage", () => { ]); }); + it("keeps adjacent trailing reviews in place, including their original spacing", () => { + const upgraded = upgradeLegacyContextMessage(`Compare ${review} ${review}`); + expect(upgraded.text).toBe( + "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)", + ); + }); + + it("keeps a trailing review beside a terminal chip whose payload follows it", () => { + const upgraded = upgradeLegacyContextMessage( + `@build:7 ${review}\n\n\n- Build line 7:\n output\n`, + ); + expect(upgraded.text).toBe( + "[Build line 7](t3-context://v1/terminal/legacy_terminal_1) [f.ts line](t3-context://v1/review-comment/legacy_review-comment_1)", + ); + }); + it("handles the combined legacy send order: terminal, element, preview, review", () => { const text = [ "See ", diff --git a/packages/shared/src/composerContextLegacy.ts b/packages/shared/src/composerContextLegacy.ts index d9898f7c987e..4f3fd51f1a70 100644 --- a/packages/shared/src/composerContextLegacy.ts +++ b/packages/shared/src/composerContextLegacy.ts @@ -339,15 +339,22 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext // Blocks were appended in send order (terminal, element, preview, review), so they peel // off the end in reverse. Each peel exposes the next block as trailing. - // Only reviews that trailed the original text were appended by the old send path; a - // review that sat before other blocks keeps its place. + // Peel reviews only when they hide another trailing context block. A review at the end + // of ordinary prose can still be inline; keep its original spacing and line breaks. const trailingReviewTokens: number[] = []; const tokens = trailingReviewTokenPattern.exec(rest); if (tokens && tokens[0].length > 0) { - trailingReviewTokens.push( - ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])), - ); - rest = rest.slice(0, tokens.index).replace(/\n+$/, ""); + const preceding = rest.slice(0, tokens.index); + if ( + TRAILING_PREVIEW.test(preceding) || + TRAILING_ELEMENT.test(preceding) || + TRAILING_TERMINAL.test(preceding) + ) { + trailingReviewTokens.push( + ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])), + ); + rest = preceding; + } } for (;;) { const preview = stripTrailing(rest, TRAILING_PREVIEW); diff --git a/packages/shared/src/composerContextLegacySend.test.ts b/packages/shared/src/composerContextLegacySend.test.ts index a6c43904278b..41df52042c61 100644 --- a/packages/shared/src/composerContextLegacySend.test.ts +++ b/packages/shared/src/composerContextLegacySend.test.ts @@ -99,6 +99,31 @@ describe("serializeLegacyContextMessage", () => { }); }); + it.each([" ", "\n", "\n\n"])( + "preserves the separator between a skill and a trailing PR through an older server: %j", + (separator) => { + const pullRequest = { + ...review, + label: "#11440", + sectionId: "pull-request:11440", + sectionTitle: "Pull request", + filePath: "PR #11440", + rangeLabel: "summary", + text: "Audit Mobile Photo Import", + diff: "", + }; + const text = `$pr-audit${separator}${formatComposerContextReference(pullRequest)}`; + const upgraded = upgradeLegacyContextMessage( + serializeLegacyContextMessage({ text, records: [pullRequest] }), + ); + + expect(upgraded.records).toHaveLength(1); + expect(upgraded.text).toBe( + `$pr-audit${separator}${formatComposerContextReference(upgraded.records[0]!)}`, + ); + }, + ); + it("retains picked-element details for preview annotations sent through an older server", () => { const text = `Update ${formatComposerContextReference(annotation)}`; const upgraded = upgradeLegacyContextMessage( diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 05fc7b12b021..6f231f28989b 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -3,7 +3,7 @@ import { type ThreadPullRequestLink, type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { legacyLinkedPullRequestOf, @@ -15,6 +15,22 @@ import { threadPullRequestKeysEqual, } from "./threadPullRequests.ts"; +// Match Hermes: these ES2023 array methods are absent on mobile, and this module runs in +// the home thread list on every launch. +beforeEach(() => { + const methods = ["toSorted", "toReversed", "toSpliced"] as const; + const descriptors = methods.map((method) => + Object.getOwnPropertyDescriptor(Array.prototype, method), + ); + for (const method of methods) Reflect.deleteProperty(Array.prototype, method); + return () => { + for (const [index, method] of methods.entries()) { + const descriptor = descriptors[index]; + if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor); + } + }; +}); + function snapshot(input: Partial = {}): ThreadPullRequestSnapshot { return { state: "open", diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 5d91d98d52c9..55e23027832d 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -106,8 +106,10 @@ export function resolveThreadCurrentPullRequest( if (open.length === 1) return { kind: "single", link: open[0]! }; const chains = resolveThreadPullRequestChains(visible); if (open.length > 1) { + // `.reverse()` on a copy, not `.toReversed()`: this runs on Hermes, which has no ES2023 + // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. const openChains = chains - .map((chain) => chain.layers.toReversed().filter(isOpen)) + .map((chain) => [...chain.layers].reverse().filter(isOpen)) .filter((layers) => layers.length > 0) .sort( (left, right) => diff --git a/vite.config.ts b/vite.config.ts index e26d5d6e653e..b9f2c9cc2c4b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -135,6 +135,19 @@ export default defineConfig({ files: ["apps/mobile/src/**"], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, }, + { + // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and + // tsconfig targets ESNext, so only lint stands between a call and a fatal launch. + // Tests run on Node and are exempt. + files: [ + "apps/mobile/src/**", + "packages/client-runtime/src/**", + "packages/contracts/src/**", + "packages/shared/src/**", + ], + excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], + rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, + }, { // Reviewed native and third-party interop boundaries that cannot consume a className. files: [