From 666434e4aef557d8dfe8955148bdcbbe99aa0514 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:16:17 -0700 Subject: [PATCH] Inspired by ChatGPT Work: convert large pastes into .txt attachments in the Desktop composer Pasting more than 10k characters of plain text into the Desktop composer now converts the content into a 'Pasted content (NN KB)' .txt attachment chip instead of flooding the input, mirroring ChatGPT's large-paste handling (OpenAI release notes, Aug 4 2026). Short pastes stay inline; the exact text is preserved byte-for-byte in a Hermes-managed composer-pastes file and rides the existing @file: attachment pipeline. If the desktop bridge is missing or the write fails, the paste falls back to inline insertion so nothing is ever lost. Implements #66622. --- apps/desktop/electron/main.ts | 24 ++++++++++ apps/desktop/electron/preload.ts | 1 + apps/desktop/src/app/chat/composer/index.tsx | 26 +++++++++++ .../src/app/chat/composer/large-paste.test.ts | 33 ++++++++++++++ .../src/app/chat/composer/large-paste.ts | 40 +++++++++++++++++ apps/desktop/src/app/chat/composer/types.ts | 1 + .../app/chat/hooks/use-composer-actions.ts | 44 +++++++++++++++++++ apps/desktop/src/app/chat/index.tsx | 3 ++ apps/desktop/src/app/chat/session-tile.tsx | 1 + apps/desktop/src/app/contrib/types.ts | 1 + apps/desktop/src/app/contrib/wiring.tsx | 1 + apps/desktop/src/global.d.ts | 1 + apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + 18 files changed, 188 insertions(+) create mode 100644 apps/desktop/src/app/chat/composer/large-paste.test.ts create mode 100644 apps/desktop/src/app/chat/composer/large-paste.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 116c11bfc5330..28711e6bcf184 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4863,6 +4863,20 @@ async function writeComposerImage(buffer, ext = '.png') { return filePath } +// Large plain-text pastes are persisted as .txt files so the composer can +// show them as an attachment chip instead of flooding the input (ChatGPT +// Work-style large-paste handling). Mirrors writeComposerImage above. +async function writeComposerPaste(text) { + const dir = path.join(app.getPath('userData'), 'composer-pastes') + await fs.promises.mkdir(dir, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '') + const random = crypto.randomBytes(3).toString('hex') + const filePath = path.join(dir, `pasted_content_${stamp}_${random}.txt`) + await fs.promises.writeFile(filePath, text, 'utf8') + + return filePath +} + function previewLabelForUrl(url) { return `${url.host}${url.pathname === '/' ? '' : url.pathname}` } @@ -10549,6 +10563,16 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { return writeComposerImage(buffer, payload?.ext || '.png') }) +ipcMain.handle('hermes:savePastedText', async (_event, payload) => { + const text = typeof payload?.text === 'string' ? payload.text : '' + + if (!text) { + throw new Error('savePastedText: missing text') + } + + return writeComposerPaste(text) +}) + ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index dd9537b26fd73..d977064c1b8d3 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -120,6 +120,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'), saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }), + savePastedText: text => ipcRenderer.invoke('hermes:savePastedText', { text }), saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'), getPathForFile: file => { try { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 28dbcf23a67b1..506fca9a9923e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -54,6 +54,7 @@ import { useEmojiCompletions } from './hooks/use-emoji-completions' import { useComposerMicroActions } from './hooks/use-micro-actions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' +import { shouldConvertPasteToAttachment } from './large-paste' import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' @@ -91,6 +92,7 @@ export function ChatBar({ onAddUrl, onAttachDroppedItems, onAttachImageBlob, + onAttachPastedText, onPasteClipboardImage, onPickFiles, onPickFolders, @@ -488,6 +490,30 @@ export function ChatBar({ event.preventDefault() + // ChatGPT Work-style large-paste handling: a paste past the threshold + // becomes a `.txt` attachment chip instead of flooding the composer. + // The instruction the user types stays in the input; the pasted source + // material rides along as a file. Falls back to inline insertion if the + // attachment can't be created (missing bridge, write failure) so the + // paste is never lost. + if (onAttachPastedText && shouldConvertPasteToAttachment(pastedText)) { + const editor = event.currentTarget + + void Promise.resolve(onAttachPastedText(pastedText)).then(attached => { + if (attached) { + triggerHaptic('selection') + + return + } + + recordUndoPoint() + insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(pastedText)), openDirectiveScope(editor)) + scheduleFlushEditorToDraft(editor) + }) + + return + } + // Links in the paste land as `@url:` chips rather than a wall of URL text — // the same reference the "Add URL" dialog inserts, parsed in place so a link // mid-sentence keeps its position. Bare `@path` tokens promote the same way. diff --git a/apps/desktop/src/app/chat/composer/large-paste.test.ts b/apps/desktop/src/app/chat/composer/large-paste.test.ts new file mode 100644 index 0000000000000..8d11daf27fcf2 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/large-paste.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { LARGE_PASTE_ATTACHMENT_THRESHOLD, shouldConvertPasteToAttachment } from './large-paste' + +describe('shouldConvertPasteToAttachment', () => { + it('keeps short pastes inline', () => { + expect(shouldConvertPasteToAttachment('hello world')).toBe(false) + expect(shouldConvertPasteToAttachment('')).toBe(false) + }) + + it('keeps a paste exactly at the threshold inline', () => { + expect(shouldConvertPasteToAttachment('a'.repeat(LARGE_PASTE_ATTACHMENT_THRESHOLD))).toBe(false) + }) + + it('converts a paste one character past the threshold', () => { + expect(shouldConvertPasteToAttachment('a'.repeat(LARGE_PASTE_ATTACHMENT_THRESHOLD + 1))).toBe(true) + }) + + it('honors a custom threshold', () => { + expect(shouldConvertPasteToAttachment('abcdef', 5)).toBe(true) + expect(shouldConvertPasteToAttachment('abcde', 5)).toBe(false) + }) + + it('never converts when the threshold is disabled (non-positive)', () => { + expect(shouldConvertPasteToAttachment('a'.repeat(50_000), 0)).toBe(false) + expect(shouldConvertPasteToAttachment('a'.repeat(50_000), -1)).toBe(false) + }) + + it('rejects non-string input defensively', () => { + expect(shouldConvertPasteToAttachment(undefined as unknown as string)).toBe(false) + expect(shouldConvertPasteToAttachment(null as unknown as string)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/large-paste.ts b/apps/desktop/src/app/chat/composer/large-paste.ts new file mode 100644 index 0000000000000..01afece913ecc --- /dev/null +++ b/apps/desktop/src/app/chat/composer/large-paste.ts @@ -0,0 +1,40 @@ +/** + * Large-paste-to-attachment policy. + * + * Inspired by ChatGPT Work's composer behavior (OpenAI release notes, + * Aug 4 2026): pasting more than ~10k characters converts the content into a + * text attachment instead of inserting it inline, keeping the composer clean + * and preventing a single paste from flooding the input. Short pastes stay + * inline; the threshold lives here so every paste handler shares one policy. + */ + +/** Characters beyond which a plain-text paste becomes a `.txt` attachment. */ +export const LARGE_PASTE_ATTACHMENT_THRESHOLD = 10_000 + +/** + * True when a plain-text paste should be converted into a text attachment + * rather than inserted inline. Only sheer size qualifies — rich clipboard + * data, images, and files never route through this path (they have their own + * pipelines upstream of this check). + */ +export function shouldConvertPasteToAttachment( + text: string, + threshold: number = LARGE_PASTE_ATTACHMENT_THRESHOLD +): boolean { + return typeof text === 'string' && threshold > 0 && text.length > threshold +} + +/** Human-readable size of a paste's UTF-8 bytes, for the attachment chip. */ +export function pasteSizeLabel(text: string): string { + const bytes = new TextEncoder().encode(text).length + + if (bytes < 1024) { + return `${bytes} B` + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB` + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 24abcf3736971..4efe3d2a630ed 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -46,6 +46,7 @@ export interface ChatBarProps { onAddUrl?: (url: string) => void onAttachImageBlob?: (blob: Blob) => Promise | boolean | void onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | boolean | void + onAttachPastedText?: (text: string) => Promise | boolean onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void onPickFiles?: () => void onPickFolders?: () => void diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 3c31f4067c7fe..517f80a3b1644 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus' import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' +import { pasteSizeLabel } from '@/app/chat/composer/large-paste' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' @@ -517,6 +518,48 @@ export function useComposerActions({ [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage] ) + /** + * Convert a very large plain-text paste into a `.txt` attachment chip + * (ChatGPT Work-style). The exact pasted text is written to a + * Hermes-managed composer-pastes file via the main process, then attached + * through the same `@file:` pipeline as a manually attached text file. + * Returns false (paste stays inline) when the desktop bridge is missing + * or the write fails. + */ + const attachPastedText = useCallback( + async (text: string) => { + const save = window.hermesDesktop?.savePastedText + + if (!text || !save) { + return false + } + + try { + const savedPath = await save(text) + + if (!savedPath) { + return false + } + + attachToMain({ + id: attachmentId('file', savedPath), + kind: 'file', + label: `${copy.pastedContent} (${pasteSizeLabel(text)})`, + detail: contextPath(savedPath, currentCwd), + refText: `@file:${formatRefValue(savedPath)}`, + path: savedPath + }) + + return true + } catch (err) { + notifyError(err, copy.pasteAttachFailed) + + return false + } + }, + [attachToMain, copy.pasteAttachFailed, copy.pastedContent, currentCwd] + ) + const attachContextFolderPath = useCallback( (folderPath: string) => { if (!folderPath) { @@ -653,6 +696,7 @@ export function useComposerActions({ attachDroppedItems, attachImageBlob, attachImagePath, + attachPastedText, insertContextPathInlineRef, pasteClipboardImage, pickContextPaths, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2ed1169f4e168..5adf1ef5a1156 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -78,6 +78,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void + onAttachPastedText?: (text: string) => Promise | boolean onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise | void onPickFiles: () => void onPickFolders: () => void @@ -282,6 +283,7 @@ export const ChatView = memo(function ChatView({ onAddUrl, onAttachImageBlob, onAttachDroppedItems, + onAttachPastedText, onBranchInNewChat, maxVoiceRecordingSeconds, onPasteClipboardImage, @@ -609,6 +611,7 @@ export const ChatView = memo(function ChatView({ onAddUrl={onAddUrl} onAttachDroppedItems={onAttachDroppedItems} onAttachImageBlob={onAttachImageBlob} + onAttachPastedText={onAttachPastedText} onCancel={onCancel} onPasteClipboardImage={onPasteClipboardImage} onPickFiles={onPickFiles} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 9c30a632f9796..0f35783c3867c 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -199,6 +199,7 @@ function TileChat({ onAddUrl={onAddUrl} onAttachDroppedItems={composer.attachDroppedItems} onAttachImageBlob={composer.attachImageBlob} + onAttachPastedText={composer.attachPastedText} onCancel={actions.cancelRun} onDeleteSelectedSession={noop} onDismissError={actions.dismissError} diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts index 1e2c60ac776a0..87bc1c952c403 100644 --- a/apps/desktop/src/app/contrib/types.ts +++ b/apps/desktop/src/app/contrib/types.ts @@ -32,6 +32,7 @@ export type ChatActions = Pick< | 'onAddUrl' | 'onAttachDroppedItems' | 'onAttachImageBlob' + | 'onAttachPastedText' | 'onBranchInNewChat' | 'onCancel' | 'onDeleteSelectedSession' diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 02f95d9765a9d..a972f097bc01a 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -861,6 +861,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { onArchiveSession: sessionId => void archiveSession(sessionId), onAttachDroppedItems: composer.attachDroppedItems, onAttachImageBlob: composer.attachImageBlob, + onAttachPastedText: composer.attachPastedText, onBranchInNewChat: messageId => void branchInNewChat(messageId), onBranchSession: sessionId => void branchStoredSession(sessionId), onCancel: cancelRun, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index fbea2424abf22..c196d1b7258f4 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -139,6 +139,7 @@ declare global { readClipboard: () => Promise saveImageFromUrl: (url: string) => Promise saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise + savePastedText: (text: string) => Promise saveClipboardImage: () => Promise getPathForFile: (file: File) => string normalizePreviewTarget: (target: string, baseDir?: string) => Promise diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 65ba2f448dddf..4e9ea49bac0fb 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2571,6 +2571,8 @@ export const ar = defineLocale({ imageAttach: 'إرفاق الصورة', imageWriteFailed: 'فشل كتابة الصورة', imageAttachFailed: 'فشل إرفاق الصورة', + pastedContent: 'محتوى ملصق', + pasteAttachFailed: 'تعذر إرفاق النص الملصق', attachImages: 'إرفاق الصور', clipboard: 'الحافظة', noClipboardImage: 'لا توجد صورة في الحافظة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index ee3eeed7df751..46322bd8f9d1b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2942,6 +2942,8 @@ export const en: Translations = { imageAttach: 'Image attach', imageWriteFailed: 'Failed to write image to disk.', imageAttachFailed: 'Image attach failed', + pastedContent: 'Pasted content', + pasteAttachFailed: 'Could not attach pasted text', attachImages: 'Attach images', clipboard: 'Clipboard', noClipboardImage: 'No image found in clipboard', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 1d8ebb907e00f..24496ee57df0c 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2782,6 +2782,8 @@ export const ja = defineLocale({ imageAttach: '画像を添付', imageWriteFailed: '画像のディスクへの書き込みに失敗しました。', imageAttachFailed: '画像の添付に失敗しました', + pastedContent: '貼り付けた内容', + pasteAttachFailed: '貼り付けたテキストを添付できませんでした', attachImages: '画像を添付', clipboard: 'クリップボード', noClipboardImage: 'クリップボードに画像が見つかりません', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d29f3d38e0178..c3a4ed77851f5 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2495,6 +2495,8 @@ export interface Translations { imageAttach: string imageWriteFailed: string imageAttachFailed: string + pastedContent: string + pasteAttachFailed: string attachImages: string clipboard: string noClipboardImage: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index b614fe8abbfd8..38a19f2979fa7 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2668,6 +2668,8 @@ export const zhHant = defineLocale({ imageAttach: '附加圖片', imageWriteFailed: '無法將圖片寫入磁碟。', imageAttachFailed: '附加圖片失敗', + pastedContent: '貼上內容', + pasteAttachFailed: '無法附加貼上的文字', attachImages: '附加圖片', clipboard: '剪貼簿', noClipboardImage: '剪貼簿中沒有圖片', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index bb063e5752d35..a4175fccb6ba4 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3103,6 +3103,8 @@ export const zh: Translations = { imageAttach: '附加图片', imageWriteFailed: '无法将图片写入磁盘。', imageAttachFailed: '附加图片失败', + pastedContent: '粘贴内容', + pasteAttachFailed: '无法附加粘贴的文本', attachImages: '附加图片', clipboard: '剪贴板', noClipboardImage: '剪贴板中没有图片',