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:
parent
226b095a59
commit
666434e4ae
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type ChatActions = Pick<
|
|||
| 'onAddUrl'
|
||||
| 'onAttachDroppedItems'
|
||||
| 'onAttachImageBlob'
|
||||
| 'onAttachPastedText'
|
||||
| 'onBranchInNewChat'
|
||||
| 'onCancel'
|
||||
| 'onDeleteSelectedSession'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2571,6 +2571,8 @@ export const ar = defineLocale({
|
|||
imageAttach: 'إرفاق الصورة',
|
||||
imageWriteFailed: 'فشل كتابة الصورة',
|
||||
imageAttachFailed: 'فشل إرفاق الصورة',
|
||||
pastedContent: 'محتوى ملصق',
|
||||
pasteAttachFailed: 'تعذر إرفاق النص الملصق',
|
||||
attachImages: 'إرفاق الصور',
|
||||
clipboard: 'الحافظة',
|
||||
noClipboardImage: 'لا توجد صورة في الحافظة',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2782,6 +2782,8 @@ export const ja = defineLocale({
|
|||
imageAttach: '画像を添付',
|
||||
imageWriteFailed: '画像のディスクへの書き込みに失敗しました。',
|
||||
imageAttachFailed: '画像の添付に失敗しました',
|
||||
pastedContent: '貼り付けた内容',
|
||||
pasteAttachFailed: '貼り付けたテキストを添付できませんでした',
|
||||
attachImages: '画像を添付',
|
||||
clipboard: 'クリップボード',
|
||||
noClipboardImage: 'クリップボードに画像が見つかりません',
|
||||
|
|
|
|||
|
|
@ -2495,6 +2495,8 @@ export interface Translations {
|
|||
imageAttach: string
|
||||
imageWriteFailed: string
|
||||
imageAttachFailed: string
|
||||
pastedContent: string
|
||||
pasteAttachFailed: string
|
||||
attachImages: string
|
||||
clipboard: string
|
||||
noClipboardImage: string
|
||||
|
|
|
|||
|
|
@ -2668,6 +2668,8 @@ export const zhHant = defineLocale({
|
|||
imageAttach: '附加圖片',
|
||||
imageWriteFailed: '無法將圖片寫入磁碟。',
|
||||
imageAttachFailed: '附加圖片失敗',
|
||||
pastedContent: '貼上內容',
|
||||
pasteAttachFailed: '無法附加貼上的文字',
|
||||
attachImages: '附加圖片',
|
||||
clipboard: '剪貼簿',
|
||||
noClipboardImage: '剪貼簿中沒有圖片',
|
||||
|
|
|
|||
|
|
@ -3103,6 +3103,8 @@ export const zh: Translations = {
|
|||
imageAttach: '附加图片',
|
||||
imageWriteFailed: '无法将图片写入磁盘。',
|
||||
imageAttachFailed: '附加图片失败',
|
||||
pastedContent: '粘贴内容',
|
||||
pasteAttachFailed: '无法附加粘贴的文本',
|
||||
attachImages: '附加图片',
|
||||
clipboard: '剪贴板',
|
||||
noClipboardImage: '剪贴板中没有图片',
|
||||
|
|
|
|||
Loading…
Reference in New Issue