refactor(desktop): large-paste writer in its own electron module, 3k threshold

Move writeComposerPaste out of electron/main.ts into composer-paste.ts
(placement gate: no new behaviour appended to the facade). Lower the
conversion threshold from 10k to 3k characters so a pasted stack trace or
log excerpt already becomes a chip. Trim the policy tests to two
invariants (strict threshold boundary; chip size label is byte-based) and
drop vendor references from code comments.
This commit is contained in:
Teknium
2026-09-11 23:46:48 -07:00
parent 22e3888543
commit ab7f03049e
8 changed files with 39 additions and 50 deletions
+20
View File
@@ -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<string> {
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
}
+2 -15
View File
@@ -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 () => {
+2 -2
View File
@@ -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
@@ -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')
})
})
@@ -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
@@ -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
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}