feat(ui): attach large text pastes as virtual files (#2619)

feat(ui): attach large text pastes as virtual files
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:31:04 +03:00
committed by GitHub
37 changed files with 565 additions and 14 deletions
+141 -7
View File
@@ -90,7 +90,18 @@ import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from
import {
assignImageAttachmentFilenames,
buildAttachmentCitationText,
nextPastedContextFilename,
} from './attachmentCitations';
import {
createPastedContextFile,
isLargePlainTextPaste,
} from './composer/largeTextPaste';
import {
LARGE_TEXT_PASTE_TOAST_CLASSNAME,
beginLargeTextPasteOffer,
resolveLargeTextPasteOffer,
} from './composer/largeTextPasteOffer';
import type { LargeTextPasteBehavior } from '@/stores/useUIStore';
import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
import {
classifyMention,
@@ -315,6 +326,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const messageRef = React.useRef(message);
const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
const largeTextPasteToastIdRef = React.useRef<string | number | null>(null);
const largeTextPasteOfferIdRef = React.useRef(0);
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -409,6 +422,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior);
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
@@ -1766,21 +1780,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!editor) {
// No mounted editor (collapsed mobile pill): append to the state
// the editor will be seeded from.
const nextValue = message + text;
const nextValue = messageRef.current + text;
setMessage(nextValue);
updateAutocompleteState(nextValue, nextValue.length, inputSource, text);
return;
}
const { start, end } = editor.getSelection();
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
// Read the live document — delayed toast actions must not use a
// paste-time React `message` closure.
const currentMessage = editor.getValue();
const nextValue = `${currentMessage.substring(0, start)}${text}${currentMessage.substring(end)}`;
const cursorPosition = start + text.length;
// One dispatch places both the text and the caret, so there is no
// frame where the caret sits at a stale offset.
editor.insertText(text);
updateAutocompleteState(nextValue, cursorPosition, inputSource, text);
}, [message, updateAutocompleteState]);
}, [updateAutocompleteState]);
const clearDropTextSuppression = React.useCallback(() => {
suppressNextFileDropTextInsertRef.current = false;
@@ -1921,14 +1938,131 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const imageFiles = Array.from(fileMap.values());
const pastedText = e.clipboardData.getData('text');
const sessionReady = Boolean(currentSessionId || newSessionDraftOpen);
if (imageFiles.length === 0) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
const behavior: LargeTextPasteBehavior = largeTextPasteBehavior;
const shouldOfferLargePaste = sessionReady
&& inputMode === 'normal'
&& behavior !== 'inline'
&& isLargePlainTextPaste(pastedText);
if (!shouldOfferLargePaste) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
return;
}
// Must run synchronously — ComposerEditor does not consume paste.
e.preventDefault();
const pasteInline = () => {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
insertTextAtSelection(
pastedText,
getFileMentionInputSourceForInsertedText(pastedText),
);
};
const attachAsFile = async () => {
// Read live attachment + composer state at action time — the ask
// toast can outlive the paste while the user types or attaches more.
const liveAttachedFiles = useInputStore.getState().attachedFiles;
const filename = nextPastedContextFilename([
...liveAttachedFiles.map((file) => file.filename),
...pendingPastedAttachmentFilenamesRef.current,
]);
const citationText = buildAttachmentCitationText([filename]);
const editor = composerRef.current;
const currentMessage = editor?.getValue() ?? messageRef.current;
const selectionStart = editor?.getSelection().start ?? currentMessage.length;
const selectionEnd = editor?.getSelection().end ?? currentMessage.length;
const insertionText = withInlineInsertionBoundaries(
citationText,
currentMessage.slice(0, selectionStart),
currentMessage.slice(selectionEnd),
);
insertTextAtSelection(
insertionText,
getFileMentionInputSourceForInsertedText(insertionText),
);
const file = createPastedContextFile(pastedText, filename);
pendingPastedAttachmentFilenamesRef.current.add(filename);
try {
await addAttachedFile(file);
} catch (error) {
console.error('Clipboard text attach failed', error);
toast.error(
error instanceof Error
? error.message
: t('chat.chatInput.toast.clipboardTextAttachFailed'),
);
} finally {
pendingPastedAttachmentFilenamesRef.current.delete(filename);
}
};
if (behavior === 'attach') {
await attachAsFile();
return;
}
const offerId = beginLargeTextPasteOffer(largeTextPasteOfferIdRef.current);
largeTextPasteOfferIdRef.current = offerId;
if (largeTextPasteToastIdRef.current !== null) {
// Invalidate first so a synchronous onDismiss from dismiss()
// cannot apply the superseded paste.
toast.dismiss(largeTextPasteToastIdRef.current);
largeTextPasteToastIdRef.current = null;
}
const resolveLargePaste = (action: 'attach' | 'inline') => {
const resolution = resolveLargeTextPasteOffer(
largeTextPasteOfferIdRef.current,
offerId,
);
largeTextPasteOfferIdRef.current = resolution.nextOfferId;
if (!resolution.accepted) {
return;
}
largeTextPasteToastIdRef.current = null;
if (action === 'attach') {
void attachAsFile();
return;
}
pasteInline();
};
largeTextPasteToastIdRef.current = toast.info(
t('chat.chatInput.toast.largeTextPaste.title'),
{
duration: Infinity,
className: LARGE_TEXT_PASTE_TOAST_CLASSNAME,
action: {
label: t('chat.chatInput.toast.largeTextPaste.attach'),
onClick: () => resolveLargePaste('attach'),
},
cancel: {
label: t('chat.chatInput.toast.largeTextPaste.inline'),
onClick: () => resolveLargePaste('inline'),
},
onDismiss: () => {
// Dismissing without a choice keeps the paste — insert inline
// so clipboard content is not lost.
resolveLargePaste('inline');
},
},
);
return;
}
if (!currentSessionId && !newSessionDraftOpen) {
if (!sessionReady) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
@@ -1969,7 +2103,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
pendingPastedAttachmentFilenamesRef.current.delete(filename);
}
}
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -5,6 +5,7 @@ import {
buildAttachmentCitationText,
findAttachmentCitationRanges,
isGenericImageFilename,
nextPastedContextFilename,
} from '../attachmentCitations';
describe('attachment citations', () => {
@@ -53,4 +54,10 @@ describe('attachment citations', () => {
['desktop.jpg'],
)).toEqual([{ start: 8, end: 21 }]);
});
test('assigns sequential pasted-context filenames', () => {
expect(nextPastedContextFilename([])).toBe('pasted-context-1.txt');
expect(nextPastedContextFilename(['pasted-context-1.txt', 'notes.md'])).toBe('pasted-context-2.txt');
expect(nextPastedContextFilename(['PASTED-CONTEXT-2.TXT'])).toBe('pasted-context-1.txt');
});
});
@@ -144,6 +144,20 @@ export const assignImageAttachmentFilenames = (
});
};
/** Next unused `pasted-context-N.txt` name for a large text paste attachment. */
export const nextPastedContextFilename = (existingFilenames: string[]): string => {
const used = new Set(existingFilenames.map(normalizeFilenameKey));
for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) {
const candidate = `pasted-context-${index}.txt`;
if (!used.has(normalizeFilenameKey(candidate))) {
return candidate;
}
}
return `pasted-context-${Date.now()}.txt`;
};
export const buildAttachmentCitationText = (filenames: string[]): string => (
filenames.map((filename) => `[${filename}]`).join(' ')
);
@@ -28,6 +28,18 @@ existing mobile fixed-position rules unchanged.
| `attachments/` | Files: paths, drop payloads |
| `ui/` | Presentation |
| `text.ts` | How inserted text meets the text already there |
| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files |
| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) |
`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown
links, clipboard images (attach + citation), and large plain-text pastes.
Large pastes (about 2,000 characters or 25 lines) follow the composer setting
`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an
in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket
citation, and sends it through the same attachment pipeline as a manually
picked `.txt` file. Ask-toast actions read live composer/attachment state so
typing or other attaches between paste and choice stay consistent. Short text,
images, and URL wraps keep their existing paths.
## The prompt language
@@ -179,8 +191,8 @@ hardware.
The package has no DOM test environment, so coverage stops at the state and
logic layers: the language, the submit assembly, path and drop handling, text
splicing, message history, and the CodeMirror language extension at the
`EditorState` level.
splicing, large-paste detection, paste-offer invalidation, message history, and
the CodeMirror language extension at the `EditorState` level.
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
tests** and are verified by hand. Do not report a change to them as validated
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import {
LARGE_TEXT_PASTE_CHAR_THRESHOLD,
LARGE_TEXT_PASTE_LINE_THRESHOLD,
createPastedContextFile,
isLargePlainTextPaste,
} from '../largeTextPaste';
describe('large text paste helpers', () => {
test('treats short text as not large', () => {
expect(isLargePlainTextPaste('hello world')).toBe(false);
expect(isLargePlainTextPaste('line1\nline2\nline3')).toBe(false);
});
test('treats empty and whitespace-only pastes as not large', () => {
expect(isLargePlainTextPaste('')).toBe(false);
expect(isLargePlainTextPaste(' \n\t ')).toBe(false);
});
test('detects pastes at the character threshold', () => {
const text = 'a'.repeat(LARGE_TEXT_PASTE_CHAR_THRESHOLD);
expect(isLargePlainTextPaste(text)).toBe(true);
expect(isLargePlainTextPaste(text.slice(0, -1))).toBe(false);
});
test('detects pastes at the line threshold', () => {
const lines = Array.from({ length: LARGE_TEXT_PASTE_LINE_THRESHOLD }, (_, index) => `line ${index}`);
expect(isLargePlainTextPaste(lines.join('\n'))).toBe(true);
expect(isLargePlainTextPaste(lines.slice(0, -1).join('\n'))).toBe(false);
});
test('honors custom thresholds', () => {
expect(isLargePlainTextPaste('abcdef', { charThreshold: 5 })).toBe(true);
expect(isLargePlainTextPaste('a\nb\nc', { lineThreshold: 3 })).toBe(true);
expect(isLargePlainTextPaste('a\nb', { lineThreshold: 3, charThreshold: 100 })).toBe(false);
});
test('creates a text/plain file with the given name', async () => {
const file = createPastedContextFile('architecture notes', 'pasted-context-1.txt');
expect(file.name).toBe('pasted-context-1.txt');
expect(file.type.startsWith('text/plain')).toBe(true);
expect(await file.text()).toBe('architecture notes');
});
});
@@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test';
import {
LARGE_TEXT_PASTE_TOAST_CLASSNAME,
beginLargeTextPasteOffer,
resolveLargeTextPasteOffer,
} from '../largeTextPasteOffer';
describe('large text paste offer state', () => {
test('begin allocates the next offer id', () => {
expect(beginLargeTextPasteOffer(0)).toBe(1);
expect(beginLargeTextPasteOffer(3)).toBe(4);
});
test('resolve accepts a matching active offer and invalidates it', () => {
expect(resolveLargeTextPasteOffer(2, 2)).toEqual({
accepted: true,
nextOfferId: 3,
});
});
test('resolve rejects a superseded offer without advancing', () => {
expect(resolveLargeTextPasteOffer(5, 4)).toEqual({
accepted: false,
nextOfferId: 5,
});
});
test('second resolve after accept is rejected (double-apply guard)', () => {
const first = resolveLargeTextPasteOffer(1, 1);
expect(first.accepted).toBe(true);
expect(resolveLargeTextPasteOffer(first.nextOfferId, 1)).toEqual({
accepted: false,
nextOfferId: first.nextOfferId,
});
});
test('begin then resolve of the old id is rejected', () => {
const previous = 2;
const next = beginLargeTextPasteOffer(previous);
expect(resolveLargeTextPasteOffer(next, previous)).toEqual({
accepted: false,
nextOfferId: next,
});
expect(resolveLargeTextPasteOffer(next, next).accepted).toBe(true);
});
test('toast class widens only from the sm breakpoint', () => {
const classes = LARGE_TEXT_PASTE_TOAST_CLASSNAME.split(/\s+/);
expect(classes).toContain('sm:!min-w-[22rem]');
expect(classes).toContain('sm:!w-auto');
expect(classes).toContain('[&_[data-icon]]:!hidden');
expect(classes.includes('!min-w-[22rem]')).toBe(false);
expect(classes.includes('!w-auto')).toBe(false);
});
});
@@ -0,0 +1,55 @@
/**
* Large plain-text paste virtual file attachment helpers.
*
* Detect when clipboard text is large enough that inserting it into the
* composer would clutter the prompt, and build an in-memory text/plain File
* the attachment pipeline can send like any other .txt attachment.
*/
export const LARGE_TEXT_PASTE_CHAR_THRESHOLD = 2000;
export const LARGE_TEXT_PASTE_LINE_THRESHOLD = 25;
const countLines = (text: string): number => {
let lines = 1;
for (let index = 0; index < text.length; index += 1) {
if (text.charCodeAt(index) === 10) {
lines += 1;
}
}
return lines;
};
/**
* Whether pasted plain text should be offered (or auto-handled) as a file
* attachment instead of being inserted into the composer.
*
* Empty / whitespace-only pastes are never large. Thresholds are OR'd:
* character count or line count is enough.
*/
export const isLargePlainTextPaste = (
text: string,
options?: {
charThreshold?: number;
lineThreshold?: number;
},
): boolean => {
if (!text || !text.trim()) {
return false;
}
const charThreshold = options?.charThreshold ?? LARGE_TEXT_PASTE_CHAR_THRESHOLD;
const lineThreshold = options?.lineThreshold ?? LARGE_TEXT_PASTE_LINE_THRESHOLD;
if (text.length >= charThreshold) {
return true;
}
return countLines(text) >= lineThreshold;
};
export const createPastedContextFile = (text: string, filename: string): File => (
new File([text], filename, {
type: 'text/plain',
lastModified: Date.now(),
})
);
@@ -0,0 +1,31 @@
/**
* Offer-id state for the large-text paste ask toast.
*
* The toast can outlive the paste event (duration Infinity), and a second
* large paste can supersede an unanswered offer. These helpers keep that
* invalidation pure so ChatInput only wires toast UI to attach/inline actions.
*/
/** Allocate a new offer id, superseding any unanswered previous offer. */
export const beginLargeTextPasteOffer = (activeOfferId: number): number => (
activeOfferId + 1
);
/**
* Attempt to resolve an offer. Returns whether this call won the race, and the
* next active id. A superseded or already-resolved offer is rejected so
* dismiss/action cannot double-apply.
*/
export const resolveLargeTextPasteOffer = (
activeOfferId: number,
offerId: number,
) => {
if (offerId !== activeOfferId) {
return { accepted: false, nextOfferId: activeOfferId };
}
return { accepted: true, nextOfferId: activeOfferId + 1 };
};
/** Toast chrome: widen on desktop only; leave mobile full-width to Sonner. */
export const LARGE_TEXT_PASTE_TOAST_CLASSNAME =
'[&_[data-icon]]:!hidden sm:!min-w-[22rem] sm:!w-auto';
@@ -212,6 +212,7 @@ const ChatSectionContent: React.FC = () => {
'followUpBehavior',
'persistDraft',
'inputSpellcheck',
'largeTextPaste',
]}
/>
);
@@ -3,7 +3,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, type LargeTextPasteBehavior } from '@/stores/useUIStore';
import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -263,11 +263,26 @@ const FOLLOW_UP_BEHAVIOR_OPTIONS: Option<FollowUpBehavior>[] = [
},
];
const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option<LargeTextPasteBehavior>[] = [
{
id: 'ask',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.ask.label',
},
{
id: 'attach',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.attach.label',
},
{
id: 'inline',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.inline.label',
},
];
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -362,6 +377,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior);
const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior);
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -639,6 +656,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('reasoning')
|| shouldShow('followUpBehavior')
|| shouldShow('persistDraft')
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
|| shouldShow('expandedTools')
|| (!isMobile && shouldShow('inputSpellcheck'));
@@ -661,6 +679,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('dotfiles')
|| shouldShow('fileViewerPreview')
|| shouldShow('persistDraft')
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck'))
@@ -1981,7 +2000,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection>
)}
{(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && (
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && (
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
@@ -2006,6 +2025,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="chat.spellcheck"
/>
)}
{shouldShow('largeTextPaste') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.largeTextPaste')}
info={t('settings.openchamber.visual.field.largeTextPasteHint')}
settingsItem="chat.large-text-paste"
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
{LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS.map((option) => (
<SettingsRadioOption
key={option.id}
selected={largeTextPasteBehavior === option.id}
onSelect={() => setLargeTextPasteBehavior(option.id)}
label={tUnsafe(option.labelKey)}
ariaLabel={t('settings.openchamber.visual.field.largeTextPasteOptionAria', { option: tUnsafe(option.labelKey) })}
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
)}
</SettingsSection>
)}
</>
@@ -2002,6 +2002,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Entwurfsnachrichten speichern',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Als Datei anhängen',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Direkt einfügen',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonyme Nutzungsberichte senden',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Anonyme Nutzungsberichte senden',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Hilft uns zu verstehen, welche App-Versionen aktiv genutzt werden, damit wir Verbesserungen priorisieren können. Es werden nur die App-Version, Plattform und Laufzeit gesammelt - keine persönlichen Daten oder Code.',
+4
View File
@@ -2115,6 +2115,10 @@ export const dict = {
'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.',
'chat.chatInput.toast.noModelSelected': 'Wähle vor dem Senden einen Anbieter und ein Modell aus.',
'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei',
'chat.chatInput.toast.largeTextPaste.title': 'Großer Text erkannt',
'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen',
'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen',
'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt',
'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei',
'chat.chatInput.toast.attachNamedFailed': 'Fehler beim Anhängen von {name}',
@@ -2085,6 +2085,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Persist Draft Messages',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Enable spellcheck in text inputs',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste',
'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Attach as file',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Paste inline',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Send anonymous usage reports',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.',
+4
View File
@@ -2332,6 +2332,10 @@ export const dict = {
'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.',
'chat.chatInput.toast.noModelSelected': 'Select a provider and model before sending.',
'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file',
'chat.chatInput.toast.largeTextPaste.title': 'Large text detected',
'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file',
'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline',
'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)',
'chat.chatInput.toast.attachFileFailed': 'Failed to attach file',
'chat.chatInput.toast.attachNamedFailed': 'Failed to attach {name}',
@@ -2062,6 +2062,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Conservar borradores de mensajes",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Habilitar ortografía en campos de texto",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Habilitar ortografía en campos de texto",
"settings.openchamber.visual.field.largeTextPaste": "Pegado de texto grande",
"settings.openchamber.visual.field.largeTextPasteHint": "Al pegar más de unos 2000 caracteres o 25 líneas, elige si adjuntar el texto como archivo, pegarlo en línea o preguntar cada vez.",
"settings.openchamber.visual.field.largeTextPasteAria": "Comportamiento del pegado de texto grande",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Pegado de texto grande: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Preguntar cada vez",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Adjuntar como archivo",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Pegar en línea",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar informes anónimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.",
+4
View File
@@ -2298,6 +2298,10 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.",
"chat.chatInput.toast.noModelSelected": "Selecciona un proveedor y un modelo antes de enviar.",
"chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles",
"chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo",
"chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado",
"chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo",
"chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea",
"chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo",
"chat.chatInput.toast.attachFileFailed": "No se pudo adjuntar el archivo",
"chat.chatInput.toast.attachNamedFailed": "No se pudo adjuntar {name}",
@@ -1967,6 +1967,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Conserver les brouillons de messages',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Activer la vérification orthographique dans les saisies de texte',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Activer la vérification orthographique dans les entrées de texte',
'settings.openchamber.visual.field.largeTextPaste': 'Collage de texte volumineux',
'settings.openchamber.visual.field.largeTextPasteHint': 'Lors dun collage de plus denviron 2 000 caractères ou 25 lignes, choisir de joindre le texte comme fichier, de le coller en ligne ou de demander à chaque fois.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Comportement du collage de texte volumineux',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Collage de texte volumineux : {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Demander à chaque fois',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Joindre comme fichier',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Coller en ligne',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Envoyer des rapports d\'utilisation anonymes',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de lapplication, la plate-forme et le runtime sont collectés aucune donnée personnelle ni code.',
+4
View File
@@ -2044,6 +2044,10 @@ export const dict = {
'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.',
'chat.chatInput.toast.noModelSelected': 'Sélectionnez un fournisseur et un modèle avant d\'envoyer.',
'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier',
'chat.chatInput.toast.largeTextPaste.title': 'Texte volumineux détecté',
'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier',
'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne',
'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}',
'chat.chatInput.toast.attachFileFailed': 'Impossible de joindre le fichier',
'chat.chatInput.toast.attachNamedFailed': 'Échec de la connexion du {name}',
@@ -2095,6 +2095,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '下書きメッセージを保持',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'テキスト入力のスペルチェックを有効化',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'テキスト入力のスペルチェックを有効化',
'settings.openchamber.visual.field.largeTextPaste': '大きなテキストの貼り付け',
'settings.openchamber.visual.field.largeTextPasteHint': '約 2,000 文字または 25 行を超えるテキストを貼り付けるとき、ファイルとして添付するか、そのまま貼り付けるか、毎回確認するかを選べます。',
'settings.openchamber.visual.field.largeTextPasteAria': '大きなテキスト貼り付けの動作',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大きなテキストの貼り付け: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '毎回確認する',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'ファイルとして添付',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'そのまま貼り付け',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。',
+4
View File
@@ -2328,6 +2328,10 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。',
'chat.chatInput.toast.noModelSelected': '送信する前にプロバイダーとモデルを選択してください。',
'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました',
'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました',
'chat.chatInput.toast.largeTextPaste.title': '大きなテキストを検出',
'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付',
'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け',
'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました',
'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました',
'gitView.commit.aiHighlights.insertAria': '挿入のariaラベル',
@@ -2062,6 +2062,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '초안 메시지 유지',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '텍스트 입력에서 맞춤법 검사 활성화',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '텍스트 입력에서 맞춤법 검사 활성화',
'settings.openchamber.visual.field.largeTextPaste': '긴 텍스트 붙여넣기',
'settings.openchamber.visual.field.largeTextPasteHint': '약 2,000자 또는 25줄을 넘는 텍스트를 붙여넣을 때 파일로 첨부할지, 본문에 붙여넣을지, 매번 물어볼지 선택합니다.',
'settings.openchamber.visual.field.largeTextPasteAria': '긴 텍스트 붙여넣기 동작',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '긴 텍스트 붙여넣기: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '매번 묻기',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '파일로 첨부',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '본문에 붙여넣기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.',
+4
View File
@@ -2332,6 +2332,10 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.',
'chat.chatInput.toast.noModelSelected': '전송하기 전에 제공업체와 모델을 선택하세요.',
'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패',
'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다',
'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 감지됨',
'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부',
'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기',
'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨',
'chat.chatInput.toast.attachFileFailed': '첨부 파일 실패',
'chat.chatInput.toast.attachNamedFailed': '첨부 {name} 실패',
@@ -1066,6 +1066,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Włącz sprawdzanie pisowni w polach tekstowych',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Włącz sprawdzanie pisowni w polach tekstowych',
'settings.openchamber.visual.field.largeTextPaste': 'Wklejanie dużego tekstu',
'settings.openchamber.visual.field.largeTextPasteHint': 'Przy wklejaniu ponad około 2000 znaków lub 25 wierszy wybierz, czy dołączyć tekst jako plik, wkleić go w treści, czy pytać za każdym razem.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Zachowanie przy wklejaniu dużego tekstu',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Wklejanie dużego tekstu: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Pytaj za każdym razem',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dołącz jako plik',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Wklej w treści',
'settings.openchamber.visual.field.fontSizePercentageAria': 'Procentowy rozmiar czcionki',
'settings.openchamber.visual.field.inputBarOffset': 'Przesunięcie paska wpisywania',
'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Podnieś pasek wpisywania, aby uniknąć zasłaniania przez systemowe elementy ekranu, takie jak pasek gestów.',
+4
View File
@@ -1300,6 +1300,10 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.unsupportedAttachmentModalities': 'Model {model} nie obsługuje danych wejściowych {modalities} wymaganych przez {files}. Nadal możesz wysłać wiadomość, ale te załączniki mogą zostać zignorowane.',
'chat.chatInput.toast.attachmentsTooLarge': 'Załączniki są zbyt duże, aby je wysłać. Spróbuj zmniejszyć liczbę lub rozmiar obrazów.',
'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku',
'chat.chatInput.toast.largeTextPaste.title': 'Wykryto duży tekst',
'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik',
'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści',
'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji',
'chat.chatInput.toast.messageSendFailed': 'Nie udało się wysłać wiadomości. Załączniki zostały przywrócone.',
'chat.chatInput.toast.noModelSelected': 'Wybierz dostawcę i model przed wysłaniem.',
@@ -2062,6 +2062,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Manter rascunhos de mensagens",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Ativar ortografia em campos de texto",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Ativar ortografia em campos de texto",
"settings.openchamber.visual.field.largeTextPaste": "Colagem de texto grande",
"settings.openchamber.visual.field.largeTextPasteHint": "Ao colar mais de cerca de 2.000 caracteres ou 25 linhas, escolha anexar o texto como arquivo, colar no corpo da mensagem ou perguntar sempre.",
"settings.openchamber.visual.field.largeTextPasteAria": "Comportamento da colagem de texto grande",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Colagem de texto grande: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Perguntar sempre",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Anexar como arquivo",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Colar no corpo",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar relatórios anônimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.",
@@ -2298,6 +2298,10 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.",
"chat.chatInput.toast.noModelSelected": "Selecione um provedor e um modelo antes de enviar.",
"chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência",
"chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo",
"chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado",
"chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo",
"chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo",
"chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo",
"chat.chatInput.toast.attachFileFailed": "Não foi possível anexar o arquivo",
"chat.chatInput.toast.attachNamedFailed": "Não foi possível anexar {name}",
@@ -2000,6 +2000,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.queueMessagesByDefaultTooltip': 'Etkinleştirildiğinde Enter mesajları kuyruğa ekler. Göndermek için {modifier}+Enter kullanın.',
'settings.openchamber.visual.field.persistDraftMessagesAria': 'Taslak mesajları kalıcı olarak sakla',
'settings.openchamber.visual.field.persistDraftMessages': 'Taslak mesajları kalıcı olarak sakla',
'settings.openchamber.visual.field.largeTextPaste': 'Büyük metin yapıştırma',
'settings.openchamber.visual.field.largeTextPasteHint': 'Yaklaşık 2.000 karakterden veya 25 satırdan fazlasını yapıştırırken metnin dosya olarak eklenmesini mi, satır içi yapıştırılmasını mı yoksa her seferinde sorulmasını mı istediğinizi seçin.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Büyük metin yapıştırma davranışı',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Büyük metin yapıştırma: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Her seferinde sor',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dosya olarak ekle',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Satır içi yapıştır',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Metin girişlerinde yazım denetimini etkinleştir',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Metin girişlerinde yazım denetimini etkinleştir',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonim kullanım raporları gönder',
+4
View File
@@ -2272,6 +2272,10 @@ export const dict = {
'chat.chatInput.toast.sendAttachmentsFailed': 'Ekler gönderilemedi. Daha az dosya veya daha küçük görseller deneyin.',
'chat.chatInput.toast.messageSendFailed': 'Mesaj gönderilemedi. Ekler geri yüklendi.',
'chat.chatInput.toast.clipboardAttachFailed': 'Panodan görsel eklenemedi',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Yapıştırılan metin dosya olarak eklenemedi',
'chat.chatInput.toast.largeTextPaste.title': 'Büyük metin algılandı',
'chat.chatInput.toast.largeTextPaste.attach': 'Dosya olarak ekle',
'chat.chatInput.toast.largeTextPaste.inline': 'Satır içi yapıştır',
'chat.chatInput.toast.addedFileMentions': '{count} dosya bahsi eklendi',
'chat.chatInput.toast.attachFileFailed': 'Dosya eklenemedi',
'chat.chatInput.toast.attachNamedFailed': '{name} eklenemedi',
@@ -2062,6 +2062,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Зберігати чернетки повідомлень",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Увімкнути перевірку орфографії під час введення тексту",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Увімкнути перевірку орфографії в текстових полях",
"settings.openchamber.visual.field.largeTextPaste": "Вставлення великого тексту",
"settings.openchamber.visual.field.largeTextPasteHint": "Під час вставлення понад приблизно 2000 символів або 25 рядків виберіть, чи долучити текст як файл, вставити його в повідомлення чи запитувати щоразу.",
"settings.openchamber.visual.field.largeTextPasteAria": "Поведінка вставлення великого тексту",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Вставлення великого тексту: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Запитувати щоразу",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Долучити як файл",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Вставити в повідомлення",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.",
+4
View File
@@ -2298,6 +2298,10 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.",
"chat.chatInput.toast.noModelSelected": "Виберіть постачальника та модель перед надсиланням.",
"chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну",
"chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл",
"chat.chatInput.toast.largeTextPaste.title": "Виявлено великий текст",
"chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл",
"chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення",
"chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}",
"chat.chatInput.toast.attachFileFailed": "Не вдалося прикріпити файл",
"chat.chatInput.toast.attachNamedFailed": "Не вдалося прикріпити {name}",
@@ -2062,6 +2062,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '保留草稿消息',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文本输入框启用拼写检查',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文本输入框启用拼写检查',
'settings.openchamber.visual.field.largeTextPaste': '粘贴大段文本',
'settings.openchamber.visual.field.largeTextPasteHint': '粘贴超过约 2000 个字符或 25 行时,可选择附加为文件、直接粘贴到输入框,或每次询问。',
'settings.openchamber.visual.field.largeTextPasteAria': '大段文本粘贴行为',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文本粘贴:{option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次询问',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加为文件',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接粘贴',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。',
@@ -2298,6 +2298,10 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。',
'chat.chatInput.toast.noModelSelected': '发送前请先选择提供商和模型。',
'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败',
'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件',
'chat.chatInput.toast.largeTextPaste.title': '检测到大段文本',
'chat.chatInput.toast.largeTextPaste.attach': '附加为文件',
'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴',
'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及',
'chat.chatInput.toast.attachFileFailed': '附加文件失败',
'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失败',
@@ -1969,6 +1969,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '保留草稿訊息',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文字輸入方塊啟用拼寫檢查',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文字輸入方塊啟用拼寫檢查',
'settings.openchamber.visual.field.largeTextPaste': '貼上大段文字',
'settings.openchamber.visual.field.largeTextPasteHint': '貼上超過約 2000 個字元或 25 行時,可選擇附加為檔案、直接貼到輸入框,或每次詢問。',
'settings.openchamber.visual.field.largeTextPasteAria': '大段文字貼上行為',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文字貼上:{option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次詢問',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加為檔案',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接貼上',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。',
@@ -2302,6 +2302,10 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。',
'chat.chatInput.toast.noModelSelected': '傳送前請先選擇提供者與模型。',
'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗',
'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案',
'chat.chatInput.toast.largeTextPaste.title': '偵測到大段文字',
'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案',
'chat.chatInput.toast.largeTextPaste.inline': '直接貼上',
'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及',
'chat.chatInput.toast.attachFileFailed': '附加檔案失敗',
'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失敗',
+8 -1
View File
@@ -351,7 +351,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
id: 'chat.composer',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.composer',
keywords: ['input', 'draft', 'spellcheck'],
keywords: ['input', 'draft', 'spellcheck', 'paste'],
},
{
id: 'chat.spellcheck',
@@ -360,6 +360,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['spelling', 'input'],
isAvailable: (ctx) => !ctx.isMobile,
},
{
id: 'chat.large-text-paste',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.largeTextPaste',
descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint',
keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'],
},
{
id: 'sessions.default-model',
page: 'sessions',
+18
View File
@@ -25,6 +25,16 @@ export type WeekStartPreference = 'auto' | 'sunday' | 'monday';
export type DesktopWindowControlsPosition = 'left' | 'right';
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
export type FileEditorKeymap = 'default' | 'vim';
export type LargeTextPasteBehavior = 'ask' | 'attach' | 'inline';
export const DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR: LargeTextPasteBehavior = 'ask';
export const normalizeLargeTextPasteBehavior = (value: unknown): LargeTextPasteBehavior => {
if (value === 'attach' || value === 'inline' || value === 'ask') {
return value;
}
return DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR;
};
function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
return value === 'vim' ? 'vim' : 'default';
@@ -822,6 +832,7 @@ interface UIStore {
/** Active tab of the project context panel (notes/todos/plans). */
projectContextTab: string;
inputSpellcheckEnabled: boolean;
largeTextPasteBehavior: LargeTextPasteBehavior;
wideChatLayoutEnabled: boolean;
codeBlockLineWrap: boolean;
showToolFileIcons: boolean;
@@ -996,6 +1007,7 @@ interface UIStore {
setProjectContextSidebarWidth: (width: number) => void;
setProjectContextTab: (value: string) => void;
setInputSpellcheckEnabled: (value: boolean) => void;
setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void;
setWideChatLayoutEnabled: (value: boolean) => void;
setCodeBlockLineWrap: (value: boolean) => void;
setShowToolFileIcons: (value: boolean) => void;
@@ -1158,6 +1170,7 @@ export const useUIStore = create<UIStore>()(
projectContextSidebarWidth: 168,
projectContextTab: 'notes',
inputSpellcheckEnabled: false,
largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR,
wideChatLayoutEnabled: false,
codeBlockLineWrap: true,
showToolFileIcons: true,
@@ -2373,6 +2386,9 @@ export const useUIStore = create<UIStore>()(
setInputSpellcheckEnabled: (value) => {
set({ inputSpellcheckEnabled: value });
},
setLargeTextPasteBehavior: (value) => {
set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) });
},
setWideChatLayoutEnabled: (value) => {
set({ wideChatLayoutEnabled: value });
},
@@ -2681,6 +2697,7 @@ export const useUIStore = create<UIStore>()(
}
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
if (typeof state.autoSaveEnabled !== 'boolean') {
state.autoSaveEnabled = true;
@@ -2778,6 +2795,7 @@ export const useUIStore = create<UIStore>()(
agentMemoryViewedAt: state.agentMemoryViewedAt,
projectContextSidebarWidth: state.projectContextSidebarWidth,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
largeTextPasteBehavior: state.largeTextPasteBehavior,
wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap,
showToolFileIcons: state.showToolFileIcons,
+1 -1
View File
@@ -56,7 +56,7 @@ So:
| `selection-store.ts` | Model/agent/variant selections | App UI state |
| `voice-store.ts` | Voice state | App UI state |
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. Large plain-text clipboard pastes can become in-memory `text/plain` attachments named `pasted-context-N.txt` through the composer paste path; they use the same normalization and send pipeline as manually attached `.txt` files.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 500,000 characters so compact but dense Office files cannot consume an entire model context window. XLSX dense rows are serialized as quoted TSV under a single source range instead of repeating every cell address; highly sparse rows retain explicit cell coordinates so distant cells do not generate vast empty TSV spans. Confirmed Office/OpenDocument `@file` mentions are loaded through the runtime filesystem route before submit and use this same extraction pipeline instead of being forwarded as `text/plain` `file://` parts that OpenCode rejects as binary. A failed mention load or extraction leaves the composer intact, and a runtime switch discards preparation from the previous runtime. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.