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.
This commit is contained in:
Teknium 2026-08-06 19:16:17 -07:00
parent 226b095a59
commit 666434e4ae
No known key found for this signature in database
18 changed files with 188 additions and 0 deletions

View File

@ -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()

View File

@ -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 {

View File

@ -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.

View File

@ -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)
})
})

View File

@ -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`
}

View File

@ -46,6 +46,7 @@ export interface ChatBarProps {
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onAttachPastedText?: (text: string) => Promise<boolean> | boolean
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void

View File

@ -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,

View File

@ -78,6 +78,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onAttachPastedText?: (text: string) => Promise<boolean> | boolean
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | 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}

View File

@ -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}

View File

@ -32,6 +32,7 @@ export type ChatActions = Pick<
| 'onAddUrl'
| 'onAttachDroppedItems'
| 'onAttachImageBlob'
| 'onAttachPastedText'
| 'onBranchInNewChat'
| 'onCancel'
| 'onDeleteSelectedSession'

View File

@ -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,

View File

@ -139,6 +139,7 @@ declare global {
readClipboard: () => Promise<string>
saveImageFromUrl: (url: string) => Promise<boolean>
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
savePastedText: (text: string) => Promise<string>
saveClipboardImage: () => Promise<string>
getPathForFile: (file: File) => string
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>

View File

@ -2571,6 +2571,8 @@ export const ar = defineLocale({
imageAttach: 'إرفاق الصورة',
imageWriteFailed: 'فشل كتابة الصورة',
imageAttachFailed: 'فشل إرفاق الصورة',
pastedContent: 'محتوى ملصق',
pasteAttachFailed: 'تعذر إرفاق النص الملصق',
attachImages: 'إرفاق الصور',
clipboard: 'الحافظة',
noClipboardImage: 'لا توجد صورة في الحافظة',

View File

@ -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',

View File

@ -2782,6 +2782,8 @@ export const ja = defineLocale({
imageAttach: '画像を添付',
imageWriteFailed: '画像のディスクへの書き込みに失敗しました。',
imageAttachFailed: '画像の添付に失敗しました',
pastedContent: '貼り付けた内容',
pasteAttachFailed: '貼り付けたテキストを添付できませんでした',
attachImages: '画像を添付',
clipboard: 'クリップボード',
noClipboardImage: 'クリップボードに画像が見つかりません',

View File

@ -2495,6 +2495,8 @@ export interface Translations {
imageAttach: string
imageWriteFailed: string
imageAttachFailed: string
pastedContent: string
pasteAttachFailed: string
attachImages: string
clipboard: string
noClipboardImage: string

View File

@ -2668,6 +2668,8 @@ export const zhHant = defineLocale({
imageAttach: '附加圖片',
imageWriteFailed: '無法將圖片寫入磁碟。',
imageAttachFailed: '附加圖片失敗',
pastedContent: '貼上內容',
pasteAttachFailed: '無法附加貼上的文字',
attachImages: '附加圖片',
clipboard: '剪貼簿',
noClipboardImage: '剪貼簿中沒有圖片',

View File

@ -3103,6 +3103,8 @@ export const zh: Translations = {
imageAttach: '附加图片',
imageWriteFailed: '无法将图片写入磁盘。',
imageAttachFailed: '附加图片失败',
pastedContent: '粘贴内容',
pasteAttachFailed: '无法附加粘贴的文本',
attachImages: '附加图片',
clipboard: '剪贴板',
noClipboardImage: '剪贴板中没有图片',