diff --git a/apps/desktop/electron/composer-paste.ts b/apps/desktop/electron/composer-paste.ts new file mode 100644 index 0000000000..df26f41e4b --- /dev/null +++ b/apps/desktop/electron/composer-paste.ts @@ -0,0 +1,20 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +/** + * Persist a large plain-text paste as a `.txt` file the composer can attach + * as a chip instead of flooding the input. The renderer never chooses the + * path: the file lands in a Desktop-managed directory with a generated name, + * mirroring how `writeComposerImage` handles pasted images. + */ +export async function writeComposerPaste(userDataDir: string, text: string): Promise { + const dir = path.join(userDataDir, '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 +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b8c0a2589a..288c97e00d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -97,6 +97,7 @@ import { import { detectBundleSkew } from './bundle-skew' import { detectBundleSwap } from './bundle-swap' import { registerChatOnboardingWindow } from './chat-onboarding-window' +import { writeComposerPaste } from './composer-paste' import { applyConnectionChange, teardownSshState } from './connection-apply' import { apiRequestRegistryConnectionId, @@ -6298,20 +6299,6 @@ async function writeComposerImage(buffer, ext = '.png', name = '') { 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}` } @@ -17038,7 +17025,7 @@ ipcMain.handle('hermes:savePastedText', async (_event, payload) => { throw new Error('savePastedText: missing text') } - return writeComposerPaste(text) + return writeComposerPaste(app.getPath('userData'), text) }) ipcMain.handle('hermes:saveClipboardImage', async () => { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1b3d31f618..d2dda2df39 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -564,8 +564,8 @@ 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. + // A paste past the large-paste 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 diff --git a/apps/desktop/src/app/chat/composer/large-paste.test.ts b/apps/desktop/src/app/chat/composer/large-paste.test.ts index 8d11daf27f..f529daf4f7 100644 --- a/apps/desktop/src/app/chat/composer/large-paste.test.ts +++ b/apps/desktop/src/app/chat/composer/large-paste.test.ts @@ -1,33 +1,16 @@ import { describe, expect, it } from 'vitest' -import { LARGE_PASTE_ATTACHMENT_THRESHOLD, shouldConvertPasteToAttachment } from './large-paste' +import { LARGE_PASTE_ATTACHMENT_THRESHOLD, pasteSizeLabel, 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', () => { +describe('large paste policy', () => { + it('converts only pastes strictly past the threshold', () => { 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) + it('labels the chip by encoded byte size, not character count', () => { + expect(pasteSizeLabel('a'.repeat(512))).toBe('512 B') + expect(pasteSizeLabel('\u00e9'.repeat(1024))).toBe('2.0 KB') }) }) diff --git a/apps/desktop/src/app/chat/composer/large-paste.ts b/apps/desktop/src/app/chat/composer/large-paste.ts index 01afece913..127778ccb8 100644 --- a/apps/desktop/src/app/chat/composer/large-paste.ts +++ b/apps/desktop/src/app/chat/composer/large-paste.ts @@ -1,15 +1,14 @@ /** * 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. + * Pasting more than ~3k 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 +export const LARGE_PASTE_ATTACHMENT_THRESHOLD = 3_000 /** * True when a plain-text paste should be converted into a text attachment 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 d9260a9a37..c964c51553 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -591,8 +591,8 @@ export function useComposerActions({ ) /** - * Convert a very large plain-text paste into a `.txt` attachment chip - * (ChatGPT Work-style). The exact pasted text is written to a + * Convert a very large plain-text paste into a `.txt` attachment chip. + * The trimmed, sanitized paste 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 diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index ec77601993..14151e1278 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -759,8 +759,8 @@ const ChatViewContent = memo(function ChatViewContent({ onAddUrl={onAddUrl} onAttachDroppedItems={onAttachDroppedItems} onAttachImageBlob={onAttachImageBlob} - onAttachPrCommentUrl={onAttachPrCommentUrl} onAttachPastedText={onAttachPastedText} + onAttachPrCommentUrl={onAttachPrCommentUrl} 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 7ad89b1610..ec8e6e6c78 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -307,8 +307,8 @@ function TileChat({ onAddUrl={onAddUrl} onAttachDroppedItems={composer.attachDroppedItems} onAttachImageBlob={composer.attachImageBlob} - onAttachPrCommentUrl={composer.attachPrCommentUrl} onAttachPastedText={composer.attachPastedText} + onAttachPrCommentUrl={composer.attachPrCommentUrl} onCancel={actions.cancelRun} onDeleteSelectedSession={noop} onDismissError={actions.dismissError}