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:
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user