feat(ui): attach large text pastes as virtual files (#2619)
feat(ui): attach large text pastes as virtual files
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user