From 2fc38accaa6d15f537de929147b5d8d95a340234 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:50:48 -0600 Subject: [PATCH 01/91] feat(chat): attach large pasted text without eager context --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 54 +++ apps/desktop/src/ipc/methods/window.ts | 28 ++ apps/desktop/src/preload.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 30 ++ .../src/window/DesktopApplicationMenu.ts | 36 +- .../src/environment/ServerEnvironment.ts | 1 + .../provider/Layers/ProviderService.test.ts | 80 +++++ .../src/provider/Layers/ProviderService.ts | 45 ++- .../acp/AntigravityAcpSupport.test.ts | 45 +++ .../src/provider/acp/AntigravityAcpSupport.ts | 15 + .../opencodeRuntime.cliParsers.test.ts | 14 + apps/server/src/provider/opencodeRuntime.ts | 7 + apps/web/src/components/ChatView.tsx | 84 ++++- .../components/ComposerPromptEditor.test.ts | 189 +++++----- .../src/components/ComposerPromptEditor.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 333 ++++++++++++++++-- .../chat/composerAttachmentFiles.test.ts | 31 ++ .../chat/composerAttachmentFiles.ts | 13 +- .../components/composerInlineTokenPaste.ts | 61 ++-- .../files/AttachmentFilePreview.tsx | 21 ++ .../src/components/files/FilePreviewPanel.tsx | 22 +- apps/web/src/composerDraftStore.test.ts | 6 +- apps/web/src/composerDraftStore.ts | 5 + .../web/src/lib/attachmentUploadQueue.test.ts | 6 +- apps/web/src/lib/attachmentUploadQueue.ts | 2 +- .../src/lib/composerContextReferences.test.ts | 12 + apps/web/src/lib/composerContextReferences.ts | 27 +- apps/web/src/lib/desktopPasteAsText.test.ts | 32 ++ apps/web/src/lib/desktopPasteAsText.ts | 15 + apps/web/src/questionAttachments.test.ts | 77 +++- apps/web/src/questionAttachments.ts | 26 +- apps/web/src/routes/__root.tsx | 2 + packages/client-runtime/package.json | 4 + .../client-runtime/src/rpc/client.test.ts | 95 +++++ packages/client-runtime/src/rpc/client.ts | 31 ++ .../client-runtime/src/state/session.test.ts | 88 ++++- packages/client-runtime/src/state/session.ts | 77 +++- packages/client-runtime/src/textPaste.test.ts | 156 ++++++++ packages/client-runtime/src/textPaste.ts | 76 ++++ packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/orchestration.ts | 7 + .../shared/src/composerContextLegacy.test.ts | 16 + packages/shared/src/composerContextLegacy.ts | 19 +- .../src/composerContextLegacySend.test.ts | 24 ++ 48 files changed, 1744 insertions(+), 194 deletions(-) create mode 100644 apps/web/src/lib/desktopPasteAsText.test.ts create mode 100644 apps/web/src/lib/desktopPasteAsText.ts create mode 100644 packages/client-runtime/src/textPaste.test.ts create mode 100644 packages/client-runtime/src/textPaste.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index b99d4219f7..579dbfac12 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, @@ -138,6 +139,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(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 99234d9f2a..2bc35d303c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,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 1b21480a77..4d4ca7b63e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -10,6 +10,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"; @@ -17,6 +26,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -158,6 +168,50 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect( + "pastes into the focused guest only after the main renderer acknowledges the menu action", + () => { + const pasteAndMatchStyle = vi.fn(); + const mainPasteAndMatchStyle = vi.fn(); + const window = { + webContents: { id: 42, pasteAndMatchStyle: mainPasteAndMatchStyle }, + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + focusedWebContents.mockReturnValue({ pasteAndMatchStyle, isDestroyed: () => false }); + ownerWindow.mockReturnValue(window); + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + assert.equal(mainPasteAndMatchStyle.mock.calls.length, 0); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue(null); // Detached contents. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue(window); + focusedWebContents.mockReturnValue({ pasteAndMatchStyle, isDestroyed: () => true }); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + focusedWebContents.mockReturnValue(null); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.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 1b8a9bbf89..06305551b9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -339,6 +339,34 @@ 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 + ) { + // Paste only the text representation into whichever app editor owns + // focus. The composer also intercepts the event to bypass folding. + focused.pasteAndMatchStyle(); + } + }), +}); + /** 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 c99481ffef..3d0acc083e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -181,6 +181,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/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 2fdff27515..2e5279f721 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -152,6 +152,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 2d5c9bd5e9..eb67af5dc5 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/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 951003a271..9157a902fa 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -220,6 +220,7 @@ export const make = Effect.gen(function* () { attachmentUploads: true, questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, + pastedTextAttachments: true, pullRequests: true, inlineMessageContext: true, threadSettlement: true, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1e228af47d..db2a7a5c91 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -3057,6 +3057,34 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(followUpInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.include(followUpInput.input ?? "", `${fileAttachment.id}.pdf]`); assert.deepEqual(followUpInput.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]); + + routing.codex.followUp.mockClear(); + yield* provider.followUp({ + threadId: session.threadId, + input: "And inspect this paste", + attachments: [pastedTextAttachment], + }); + const pastedFollowUp = routing.codex.followUp.mock.calls[0]?.[0] as ProviderFollowUpInput; + assert.include(pastedFollowUp.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.deepEqual(pastedFollowUp.attachments, [pastedTextAttachment]); yield* provider.stopSession({ threadId: session.threadId }); }), @@ -6680,6 +6708,58 @@ 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 unsafe pasted-text attachment IDs before provider dispatch", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-invalid-path"), + input: "Inspect this paste", + attachments: [ + { + type: "file", + id: "../unsafe", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" }, + }, + ], + }) + .pipe(Effect.flip); + assert.instanceOf(failure, ProviderValidationError); + 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 2fc810c8a3..8817ed3081 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -528,30 +528,41 @@ const correlateRuntimeEventWithInstance = ( * makes the path line the sole channel a non-image attachment has on those * providers, which is why follow-ups need it exactly as much as turns do. * - * A context block that would push the input past the provider limit is left - * out. Unresolvable ids are skipped here and surface as adapter errors when the - * file is read. + * Ordinary context that would push input past the provider limit is left out. + * A pasted-text path is required: silently omitting it would leave the agent + * unable to inspect the folded paste. Unresolvable ids are skipped here and + * surface as adapter errors when the file is read. */ const appendAttachmentContext = ( attachmentsDir: string, input: string | undefined, attachments: ReadonlyArray, -): string | undefined => { +): { readonly input: string | undefined; readonly pastedTextOverflow: boolean } => { let inputWithContext = input; + let pastedTextOverflow = false; const append = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputWithContext ? `${inputWithContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputWithContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir, attachment }); - append( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = append( 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 && (attachmentPath === null || !appended)) pastedTextOverflow = true; } for (const attachment of attachments) { const source = isChatImageAttachment(attachment) ? attachment.source : undefined; @@ -583,7 +594,7 @@ const appendAttachmentContext = ( : undefined, ); } - return inputWithContext; + return { input: inputWithContext, pastedTextOverflow }; }; const makeProviderService = Effect.fn("makeProviderService")(function* ( @@ -2324,11 +2335,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Pylon keeps the attachment context in a shared helper; feed it the // citation-expanded text so both transforms land in the provider prompt. - const inputTextWithAttachmentContext = appendAttachmentContext( + const attachmentContext = appendAttachmentContext( serverConfig.attachmentsDir, inputTextWithCitations, attachments, ); + if (attachmentContext.pastedTextOverflow) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Pasted-text attachment path is unavailable or exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character input limit`, + ); + } + const inputTextWithAttachmentContext = attachmentContext.input; const input = { ...parsed, @@ -3356,11 +3374,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // generic file attached to a follow-up is lost outright: every adapter // except OpenCode skips non-images, so the path line is the only thing // that tells the agent the file exists. - const followUpInputText = appendAttachmentContext( + const followUpContext = appendAttachmentContext( serverConfig.attachmentsDir, input.input, input.attachments, ); + if (followUpContext.pastedTextOverflow) { + return yield* toValidationError( + "ProviderService.followUp", + `Pasted-text attachment path is unavailable or exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character input limit`, + ); + } + const followUpInputText = followUpContext.input; return yield* followUpSession({ ...input, ...(followUpInputText !== undefined ? { input: followUpInputText } : {}), diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b..aee10782a6 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 96bbee73a4..88be6e4b5b 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -289,6 +289,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); @@ -321,6 +328,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 35a7791c62..c66a2f36a2 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 ae23bccc10..4bb43cf2a2 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -491,6 +491,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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index de1de699f0..26113cb71c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,6 +61,9 @@ import { isRollbackActive, type RollbackTarget, } from "@t3tools/client-runtime/rollback"; +import { shouldRoutePlainTextPaste } from "./composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { connectedInitialConfigForState } from "@t3tools/client-runtime/state/session"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -112,6 +115,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 { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; @@ -126,6 +130,7 @@ import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; +import * as Option from "effect/Option"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; @@ -542,7 +547,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { readPreparedConnection } from "../state/session"; +import { environmentSession, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; @@ -758,6 +763,19 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { return text.length > 0 ? text : null; } +function supportsPastedTextAttachmentsNow(environmentId: EnvironmentId): boolean { + const connection = appAtomRegistry.get(environmentCatalog.stateAtom(environmentId)); + const leaseConfig = appAtomRegistry.get( + environmentSession.connectedInitialConfigAtom(environmentId), + ); + if (connection.waiting || leaseConfig.waiting) return false; + const config = connectedInitialConfigForState( + Option.getOrNull(AsyncResult.value(connection)), + Option.getOrElse(AsyncResult.value(leaseConfig), () => Option.none()), + ); + return config?.environment.capabilities.pastedTextAttachments === true; +} + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -1750,6 +1768,7 @@ export default function ChatView(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const branchToolbarRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -2690,10 +2709,30 @@ export default function ChatView(props: ChatViewProps) { observedPullRequestProviders, ]); const pullRequestsUnavailableGitHubUrl = pullRequestsUnavailableFallbackLink?.url ?? null; - const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; + const attachmentEnvironment = environmentById.get(environmentId) ?? null; + const attachmentEnvironmentConfig = attachmentEnvironment?.serverConfig ?? null; + // The presentation config may retain a previous server's capabilities. + // Both atoms carry the exact supervisor state object: if the environment ID + // reconnects to a different server, the old config cannot match its lease. + const attachmentConnectionResult = useAtomValue(environmentCatalog.stateAtom(environmentId)); + const attachmentLeaseConfigResult = useAtomValue( + environmentSession.connectedInitialConfigAtom(environmentId), + ); + const attachmentConnectionState = attachmentConnectionResult.waiting + ? null + : Option.getOrNull(AsyncResult.value(attachmentConnectionResult)); + const attachmentLeaseConfig = attachmentLeaseConfigResult.waiting + ? Option.none() + : Option.getOrElse(AsyncResult.value(attachmentLeaseConfigResult), () => Option.none()); + const attachmentBootstrapConfig = connectedInitialConfigForState( + attachmentConnectionState, + attachmentLeaseConfig, + ); const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsQuestionAttachments = attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true; + const supportsPastedTextAttachments = + attachmentBootstrapConfig?.environment.capabilities.pastedTextAttachments === true; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const advertisedFileAttachmentBytes = @@ -7346,24 +7385,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, - ) + (shouldRoutePlainTextPaste(clipboardData, bypassAutoAttachment) && + 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<{ @@ -8036,6 +8089,7 @@ export default function ChatView(props: ChatViewProps) { supportsAttachmentUploads: liveSupportsAttachmentUploads, fileBlockReason: fileAttachmentCapabilityBlockReason({ files: composerFilesSnapshot, + supportsPastedTextAttachments: supportsPastedTextAttachmentsNow(environmentId), attachmentUploadsCapabilityKnown: config !== null, supportsAttachmentUploads: liveSupportsAttachmentUploads, maxFileAttachmentBytes: @@ -8159,6 +8213,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = @@ -8971,6 +9026,18 @@ export default function ChatView(props: ChatViewProps) { const draft = useComposerDraftStore.getState().getComposerDraft(target); const attachments = draft ? [...draft.images, ...draft.files] : []; if (attachments.length === 0) continue; + if ( + attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ) && + !supportsPastedTextAttachmentsNow(environmentId) + ) { + setThreadError( + activeThreadId, + "This server cannot use a saved large-paste attachment. Remove it or reconnect to a server that supports large pastes.", + ); + return; + } const uploaded = getUploadedAttachments({ environmentId, images: attachments }); if (!uploaded) { setThreadError( @@ -10691,6 +10758,7 @@ export default function ChatView(props: ChatViewProps) { attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} supportsQuestionAttachments={supportsQuestionAttachments} + supportsPastedTextAttachments={supportsPastedTextAttachments} maxFileAttachmentBytes={maxFileAttachmentBytes} routeKind={routeKind} routeThreadRef={routeThreadRef} diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index e295c72863..98bdfcc590 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -19,6 +19,8 @@ import { import { importPastedComposerText, + readPastedComposerContext, + shouldRoutePlainTextPaste, registerComposerInlineTokenPaste, } from "./composerInlineTokenPaste"; import { @@ -499,95 +501,108 @@ 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"], - ["ctx-t", "ctx-t"], - ]); - }; - 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"], + ["ctx-t", "ctx-t"], + ]); + }; + 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"]); + expect(shouldRoutePlainTextPaste(event.clipboardData, false)).toBe(false); + expect(shouldRoutePlainTextPaste(event.clipboardData, true)).toBe(true); + 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 5bbcdec50c..a899823bf0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -839,6 +839,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1861,6 +1862,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2028,7 +2033,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index af18598e9a..37b21c882b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,17 +5,20 @@ import { environmentThreadDetails } from "../../state/threads"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { trackPendingContextImport } from "./pendingContextImport"; import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; +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, + countCurrentDraftAttachments, useQuestionAttachmentPreparation, changeQuestionAttachmentPreparation, } from "../../questionAttachments"; @@ -49,6 +52,7 @@ 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 { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation"; @@ -87,6 +91,12 @@ import { sessionResourceViewIdentity, supportsSessionResourceReload, } from "@t3tools/client-runtime/state/session-resources"; +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 { @@ -246,6 +256,7 @@ import { ensureInlineContextReferences, formatInlineContextReference, insertInlineContextReference, + inlineContextReferenceReplacement, toKindScopedComposerContextId, } from "~/lib/composerContextReferences"; import { @@ -342,7 +353,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, @@ -1386,6 +1397,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, @@ -1448,6 +1461,7 @@ export interface ChatComposerProps { attachmentUploadsCapabilityKnown: boolean; supportsAttachmentUploads: boolean; supportsQuestionAttachments: boolean; + supportsPastedTextAttachments: boolean; maxFileAttachmentBytes: number | null; routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; @@ -1641,6 +1655,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) attachmentUploadsCapabilityKnown, supportsAttachmentUploads, supportsQuestionAttachments, + supportsPastedTextAttachments, maxFileAttachmentBytes, routeKind, routeThreadRef, @@ -1895,6 +1910,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({ files: composerFiles, + supportsPastedTextAttachments, attachmentUploadsCapabilityKnown, supportsAttachmentUploads, maxFileAttachmentBytes, @@ -2962,6 +2978,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 composerSurfaceRef = useRef(null); @@ -2996,6 +3017,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 // ------------------------------------------------------------------ @@ -5031,6 +5095,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 @@ -5311,6 +5376,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 @@ -6185,11 +6251,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: attachments // ------------------------------------------------------------------ + const countReservedAttachments = () => { + // Read the authoritative draft synchronously. React's file/image refs are + // refreshed by an effect, so two paste events in one render could both + // see the last free slot and the second folded paste would be swallowed. + 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 ( + countCurrentDraftAttachments(attachmentDraftTarget, { + images: composerImagesRef.current.length, + files: 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[], options?: { - readonly source?: string; + readonly source?: ChatFileAttachment["source"]; readonly selection?: { start: number; end: number }; readonly skipImageInlineChip?: boolean; }, @@ -6230,31 +6323,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; - // The attachment limit covers one whole set of answers, not each question. - 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. @@ -6325,6 +6394,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mimeType: fileMimeType, sizeBytes: attachmentFile.size, file: attachmentFile, + ...(options?.source ? { source: options.source } : {}), }); } if (!matchingReattachMarker) { @@ -6338,8 +6408,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // and a chip for it would point at nothing. const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); + if (options?.source?._tag === "pasted-text" && storedFiles.length === 0) { + toastManager.add({ + type: "error", + title: "Pasted text could not be attached", + description: "Remove an attachment or try pasting again.", + data: { hideCopyButton: true }, + }); + } 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; @@ -6427,11 +6519,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) { @@ -6465,24 +6562,151 @@ 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, + }); + if (!supportsPastedTextAttachments) { + if (!wouldExceedInputLimit) return false; + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: + "This server cannot attach large pasted text. Reduce the clipboard contents and try again.", + data: { hideCopyButton: true }, + }); + return true; + } + 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; + if (bypassAutoAttachment) { + // Paste as Text must consume the plain payload before Lexical's rich + // clipboard importer can hydrate context chips, HTML, or attached files. + event.preventDefault(); + event.stopPropagation(); + if (plainText.length > 0) { + const selection = composerEditorRef.current?.readSelectionRange() ?? { + start: promptRef.current.length, + end: promptRef.current.length, + }; + if ( + wouldTextPasteExceedLimit({ + valueLength: promptRef.current.length, + selection, + textLength: plainText.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }) + ) { + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Reduce the clipboard contents and try again.", + data: { hideCopyButton: true }, + }); + } else { + applyPromptReplacement(selection.start, selection.end, plainText); + } + } + return; + } // 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( @@ -6708,6 +6932,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || pendingDraftWork.has(attachmentTargetKey), insertTextAtEnd: insertComposerTextAtEnd, + pasteTextAtEnd: (text: string, options) => { + const bypassAutoAttachment = + options?.bypassAutoAttachment === true || + Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; + const promptLength = promptRef.current.length; + if (bypassAutoAttachment) { + if ( + wouldTextPasteExceedLimit({ + valueLength: promptLength, + selection: { start: promptLength, end: promptLength }, + textLength: text.length, + maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + }) + ) { + toastManager.add({ + type: "error", + title: "Pasted text is too large for this message", + description: "Reduce the clipboard contents and try again.", + data: { hideCopyButton: true }, + }); + return true; + } + return insertComposerTextAtEnd(text); + } + if ( + !foldPastedText(text, bypassAutoAttachment, { + start: promptLength, + end: promptLength, + }) + ) { + return false; + } + focusComposer(); + return true; + }, citeAssistantText: (citation, sourceAnchor) => insertComposerText( formatAssistantCitationForComposer(citation, citation.comment), @@ -6847,6 +7107,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activeThread, addComposerAttachments, + foldPastedText, composerDraftTarget, composerCursor, composerTerminalContexts, diff --git a/apps/web/src/components/chat/composerAttachmentFiles.test.ts b/apps/web/src/components/chat/composerAttachmentFiles.test.ts index 193062e024..50f35805be 100644 --- a/apps/web/src/components/chat/composerAttachmentFiles.test.ts +++ b/apps/web/src/components/chat/composerAttachmentFiles.test.ts @@ -195,6 +195,37 @@ describe("composer attachment files", () => { ).toBe(unsupportedReason); }); + it("retains but blocks a marked paste after a server downgrade", () => { + const files = [ + { + name: "pasted-text.txt", + sizeBytes: 32_768, + source: { _tag: "pasted-text" }, + }, + ]; + const baseline = { + files, + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: 50 * 1024 * 1024, + }; + expect( + fileAttachmentCapabilityBlockReason({ + ...baseline, + supportsPastedTextAttachments: true, + }), + ).toBeNull(); + expect(fileAttachmentCapabilityBlockReason(baseline)).toContain( + "cannot use a saved large-paste attachment", + ); + expect( + fileAttachmentCapabilityBlockReason({ + ...baseline, + files: [{ name: "report.txt", sizeBytes: 32_768 }], + }), + ).toBeNull(); + }); + it("blocks retained files that exceed a newly lower server limit", () => { expect( fileAttachmentCapabilityBlockReason({ diff --git a/apps/web/src/components/chat/composerAttachmentFiles.ts b/apps/web/src/components/chat/composerAttachmentFiles.ts index 3166eeff41..c326f770a1 100644 --- a/apps/web/src/components/chat/composerAttachmentFiles.ts +++ b/apps/web/src/components/chat/composerAttachmentFiles.ts @@ -18,6 +18,7 @@ interface FileAttachmentCapabilityState { readonly attachmentUploadsCapabilityKnown: boolean; readonly supportsAttachmentUploads: boolean; readonly maxFileAttachmentBytes: number | null; + readonly supportsPastedTextAttachments?: boolean; } const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly> = { @@ -113,12 +114,22 @@ export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState) /** Why retained generic files cannot send with the current server config. */ export function fileAttachmentCapabilityBlockReason( input: FileAttachmentCapabilityState & { - readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; + readonly files: ReadonlyArray<{ + readonly name: string; + readonly sizeBytes: number; + readonly source?: { readonly _tag: string } | undefined; + }>; }, ): string | null { if (input.files.length === 0) { return null; } + if ( + input.supportsPastedTextAttachments !== true && + input.files.some((file) => file.source?._tag === "pasted-text") + ) { + return "This server cannot use a saved large-paste attachment. Remove it or reconnect to a server that supports large pastes."; + } if (!input.attachmentUploadsCapabilityKnown) { return "Waiting for the server before file attachments can send"; } diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 8db94a4207..f85589dcd8 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -146,40 +146,51 @@ 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)), + }; +} + +/** Paste as Text wins over a copied rich fragment when the composer is blurred. */ +export function shouldRoutePlainTextPaste( + clipboardData: Pick, + pasteAsText: boolean, +): boolean { + return pasteAsText || (readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0; +} + +/** 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 recordsById = new Map(fragment?.records.map((record) => [record.contextId, record]) ?? []); diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 7de994a2f9..c6d705deca 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -12,6 +12,7 @@ import { Eye, Table2, Trash2Icon, + WrapTextIcon, XIcon, } from "lucide-react"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; @@ -21,6 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { AudioPreview } from "./AudioPreview"; @@ -193,6 +195,16 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); + const wordWrap = useClientSettings((settings) => settings.wordWrap); + const updateClientSettings = useUpdateClientSettings(); + // Only the raw-text body honours word wrap. A rendered table or Markdown lays itself out, + // so offering the toggle there would be a control that visibly does nothing. + const showsRawText = + failure === null && + needsText && + content !== null && + !(delimiter && rendered) && + !(kind === "markdown" && rendered); const save = () => { if (saveController.current) return; @@ -325,6 +337,15 @@ export function AttachmentFilePreview(props: { )} ) : null} + {showsRawText ? ( + updateClientSettings({ wordWrap: !wordWrap })} + > + + + ) : null} {content ? (