Merge main

This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:26:12 +03:00
186 changed files with 5727 additions and 561 deletions
+27 -33
View File
@@ -35,7 +35,8 @@ import {
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -104,10 +105,12 @@ import {
type ComposerEditorHandle,
} from './composer/editor/ComposerEditor';
import { createComposerEditorViewStore } from './composer/editor/viewStore';
import { composerAutoCorrect } from './composer/editor/autocorrect';
import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from './composer/text';
@@ -338,6 +341,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
[btwDirectory, btwSessionId, currentSessionId],
);
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
// A session promoted out of `/btw` keeps the boundary instructions in its
// transcript — there is no way to delete a message part — so it has to say
// they no longer apply.
const isPromotedBtwSession = wasPromotedBtwSession(btwPanel.parentSession);
const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity(
@@ -1010,6 +1017,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!providerIdToSend || !modelIdToSend) {
console.warn('Cannot send message: provider or model not selected');
toast.error(t('chat.chatInput.toast.noModelSelected'));
return;
}
@@ -1126,7 +1134,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
composerAttachments: attachedFiles,
inlineComments: drafts,
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
// btw mode: the boundary rides with every send, not just the
// first one, so the inherited transcript stays reference material
// for the whole side conversation.
syntheticTexts: [
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
...(syntheticParts?.map((part) => part.text) ?? []),
],
linkedIssue: linkedIssue
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
: null,
@@ -1620,39 +1635,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const selEnd = ta?.getSelection().end ?? -1;
if (ta && selStart >= 0) {
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
if (edit) {
e.preventDefault();
setMessage(next);
composerRef.current?.setSelection(caretStart, caretEnd);
updateAutocompleteState(next, caretEnd);
};
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
const WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
'"': ['"', '"'], "'": ["'", "'"],
};
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
const [open, close] = WRAP_PAIRS[e.key];
const selected = message.slice(selStart, selEnd);
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
applyEdit(next, selStart + open.length, selEnd + open.length);
ta.replaceRange(
edit.from,
edit.to,
edit.insert,
edit.selectionStart,
edit.selectionEnd,
);
return;
}
// Typing the third backtick at line start expands into a fenced
// code block with the caret on the empty middle line (Slack-like).
if (e.key === '`' && selStart === selEnd) {
const before = message.slice(0, selStart);
if (/(^|\n)``$/.test(before)) {
const after = message.slice(selEnd);
const next = `${before}\`\n\n\`\`\`${after}`;
const caret = before.length + 2; // after the completed ``` and first newline
applyEdit(next, caret, caret);
return;
}
}
}
}
@@ -2851,7 +2845,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={isMobile}
autoCorrect={composerAutoCorrect({ isMobile })}
autoCapitalize={isMobile ? 'sentences' : 'none'}
spellCheck={isMobile || inputSpellcheckEnabled}
fillContainer={isComposerExpanded}
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts, normalizeParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -702,40 +702,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageTextContent = React.useMemo(() => {
if (isUser) {
const shellOutputs = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
.map((part) => {
const output = part.shellAction?.output;
return typeof output === 'string' ? output.trim() : '';
})
.filter((output) => output.length > 0);
if (shellOutputs.length > 0) {
return shellOutputs.join('\n\n');
}
const shellCommands = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
.map((part) => {
const command = part.shellAction?.command;
return typeof command === 'string' ? command.trim() : '';
})
.filter((command) => command.length > 0);
if (shellCommands.length > 0) {
return shellCommands.join('\n');
}
const textParts = displayParts
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
.map((part) => {
const text = part.text || part.content || '';
return text.trim();
})
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
return flattenUserTextParts(displayParts);
}
if (assistantErrorText && assistantErrorText.trim().length > 0) {
@@ -1,7 +1,6 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -66,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}, ref) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
const hasSession = Boolean(currentSessionId);
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const canStartSessionCommand = hasSession || hasNewSessionDraft;
@@ -140,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}));
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -200,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
];
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
const allowInitCommand = !hasMessagesInCurrentSession;
const filtered = (searchQuery
const filtered = searchQuery
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: allCommands;
filtered.sort((a, b) => {
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
@@ -216,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setCommands(filtered);
} catch {
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -277,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
),
];
const filtered = (searchQuery
const filtered = searchQuery
? builtInCommands.filter(cmd =>
fuzzyMatch(cmd.name, searchQuery) ||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
)
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: builtInCommands;
setCommands(filtered);
} finally {
@@ -291,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -47,8 +47,12 @@ export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof Ma
</React.Suspense>
);
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
fallbackContent?: React.ReactNode;
};
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
@@ -2283,7 +2283,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
: 'Default';
return (
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
<span className={cn(
'typography-micro whitespace-nowrap',
isHighlighted
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
)}>
Thinking: {displayLabel}
</span>
);
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
import { QuestionMarkdown } from './QuestionMarkdown';
interface QuestionCardProps {
question: QuestionRequest;
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
</div>
) : activeQuestion ? (
<>
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
<QuestionMarkdown
content={activeQuestion.question}
size="meta"
className="font-medium text-foreground mb-1.5"
/>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
import { QuestionMarkdown } from './QuestionMarkdown';
describe('QuestionMarkdown', () => {
test('delegates exact content to the tool markdown renderer', () => {
const content = 'Choose **one** from `mode`: [details](https://example.com)';
const element = QuestionMarkdown({ content, size: 'meta' });
expect(element.type).toBe(SimpleMarkdownRenderer);
expect(element.props.content).toBe(content);
expect(element.props.variant).toBe('tool');
expect(element.props.fallbackContent.props.children).toBe(content);
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
});
test('preserves question typography size and caller classes', () => {
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
});
});
@@ -0,0 +1,23 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
interface QuestionMarkdownProps {
content: string;
size: 'meta' | 'micro';
className?: string;
}
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
return (
<SimpleMarkdownRenderer
content={content}
variant="tool"
className={classes}
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
/>
);
}
@@ -5,6 +5,8 @@ import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetada
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The session the composer is in — the one `/btw` would fork. */
parentSession: Session | null;
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
@@ -40,6 +42,7 @@ export function useBtwPanelState(
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
parentSession: parentSession ?? null,
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
@@ -112,6 +112,14 @@ token: themes define `--interactive-selection` with its own alpha, so mixing it
with transparent again is nearly invisible. The iOS system overlay owns its
visible selection fill.
The content element keeps the existing correction policy: on in the mobile UI,
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
Android's insert-period-on-double-space only when its value is exactly `off`.
`editor/autocorrect.ts` uses the HTML standard's
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
to keep desktop word correction off while avoiding that CodeMirror-only
revert. Its platform checks deliberately match CodeMirror's own browser flags.
`composerLanguage.ts` retokenizes the whole document on every change. The
composer holds a prompt, not a source file: it is short enough that a full pass
is cheaper and far simpler than incremental mapping, and it keeps the editor
@@ -4,6 +4,7 @@ import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from '../text';
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
});
});
describe('getMarkdownAutoPairEdit', () => {
test('completes a fenced block with the caret on the middle line', () => {
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
from: 2,
to: 2,
insert: '`\n\n```',
selectionStart: 4,
selectionEnd: 4,
});
});
test('completes a fence at the start of any line', () => {
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
from: 8,
to: 8,
insert: '`\n\n```',
selectionStart: 10,
selectionEnd: 10,
});
});
test('does not complete two backticks in the middle of a line', () => {
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
});
test('wraps selected text and keeps the text selected', () => {
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
from: 1,
to: 4,
insert: '*ell*',
selectionStart: 2,
selectionEnd: 5,
});
});
});
@@ -34,6 +34,7 @@ import {
import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme';
@@ -63,8 +64,8 @@ export interface ComposerEditorHandle {
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Replace a range; selection defaults to a caret after the inserted text. */
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
@@ -89,8 +90,11 @@ export interface ComposerEditorProps {
placeholder?: string;
editable?: boolean;
spellCheck?: boolean;
/** Mobile keyboards; ignored on desktop. */
autoCorrect?: boolean;
/**
* The content element's autocorrect keyword. See `autocorrect.ts` for the
* case-sensitive CodeMirror workaround.
*/
autoCorrect?: ComposerAutoCorrect;
autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
@@ -157,7 +161,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCorrect = 'off',
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
@@ -287,7 +291,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocorrect: handlersRef.current.autoCorrect ?? 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
@@ -454,7 +458,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocorrect', autoCorrect);
content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]);
@@ -516,12 +520,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const anchor = selectionStart ?? from + text.length;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
selection: { anchor, head: selectionEnd ?? anchor },
userEvent: 'input.type',
});
},
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
const platform = (overrides: Partial<Navigator>): Navigator => ({
maxTouchPoints: 0,
platform: '',
userAgent: '',
vendor: '',
...overrides,
} as Navigator);
const codeMirrorKeepsDoubleSpacePeriod = (
autoCorrect: ComposerAutoCorrect,
): boolean => autoCorrect !== 'off';
const affectedPlatforms: Array<[string, Navigator]> = [
['macOS', platform({ platform: 'MacIntel' })],
['iPhone', platform({
platform: 'iPhone',
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
vendor: 'Apple Computer, Inc.',
})],
['iPadOS touch detection', platform({
maxTouchPoints: 5,
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
vendor: 'Apple Computer, Inc.',
})],
['Android', platform({
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
})],
];
const unaffectedPlatforms: Array<[string, Navigator]> = [
['Windows', platform({ platform: 'Win32' })],
['Linux', platform({ platform: 'Linux x86_64' })],
];
describe('composerAutoCorrect', () => {
test('matches the pinned CodeMirror period-revert guard', () => {
const source = readFileSync(
fileURLToPath(import.meta.resolve('@codemirror/view')),
'utf8',
);
const semantics = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, '');
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
expect(semantics).toContain(
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
);
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
});
for (const [name, navigator] of affectedPlatforms) {
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
expect(autoCorrect.toLowerCase()).toBe('off');
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
});
}
for (const [name, navigator] of unaffectedPlatforms) {
test(`leaves desktop correction off on ${name}`, () => {
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
});
}
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
expect(composerAutoCorrect({
isMobile: false,
navigator: platform({
platform: 'Linux x86_64',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
}),
})).toBe('off');
});
test('preserves the existing mobile autocorrect policy', () => {
expect(composerAutoCorrect({
isMobile: true,
navigator: platform({ platform: 'Win32' }),
})).toBe('on');
});
});
@@ -0,0 +1,24 @@
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
type PlatformNavigator = Pick<Navigator,
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
>;
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
export function composerAutoCorrect(options: {
isMobile: boolean;
navigator?: PlatformNavigator;
}): ComposerAutoCorrect {
if (options.isMobile) return 'on';
const nav = options.navigator
?? (typeof navigator === 'undefined'
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
: navigator);
// These must match CodeMirror's flags because its revert checks exact "off".
const ios = /Apple Computer/.test(nav.vendor)
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
? 'Off'
: 'off';
}
@@ -20,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-focused': { outline: 'none' },
'.cm-content': {
padding: '0',
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
paddingInlineStart: '1px',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
&& selected.trim().length > 0
&& !selected.includes('](');
}
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'],
'*': ['*', '*'],
'_': ['_', '_'],
'~': ['~', '~'],
'(': ['(', ')'],
'[': ['[', ']'],
'{': ['{', '}'],
'"': ['"', '"'],
"'": ["'", "'"],
};
/**
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
* The returned text change and selection belong to one editor transaction so
* the caret cannot be applied against the previous document.
*/
export function getMarkdownAutoPairEdit(
value: string,
key: string,
selectionStart: number,
selectionEnd: number,
): {
from: number;
to: number;
insert: string;
selectionStart: number;
selectionEnd: number;
} | null {
const pair = MARKDOWN_WRAP_PAIRS[key];
if (selectionEnd > selectionStart && pair) {
const selected = value.slice(selectionStart, selectionEnd);
const [open, close] = pair;
return {
from: selectionStart,
to: selectionEnd,
insert: `${open}${selected}${close}`,
selectionStart: selectionStart + open.length,
selectionEnd: selectionEnd + open.length,
};
}
if (key === '`' && selectionStart === selectionEnd) {
const before = value.slice(0, selectionStart);
if (/(^|\n)``$/.test(before)) {
return {
from: selectionStart,
to: selectionEnd,
insert: '`\n\n```',
selectionStart: selectionStart + 2,
selectionEnd: selectionStart + 2,
};
}
}
return null;
}
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
@@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
row.setAttribute('data-md-code-line', '');
const number = document.createElement('span');
number.setAttribute('data-md-code-line-number', '');
number.setAttribute('data-md-code-line-number', String(index + 1));
number.setAttribute('aria-hidden', 'true');
number.textContent = String(index + 1);
const content = document.createElement('span');
content.setAttribute('data-md-code-line-content', '');
@@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
} else {
content.textContent = sourceLine;
}
row.append(number, content);
fragment.appendChild(row);
if (index < sourceLines.length - 1 || hasTrailingNewline) {
@@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
}
};
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
const element = node.nodeType === 1 ? node as Element : node.parentElement;
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
};
const getMarkdownCodeSelectionText = (range: Range): string | null => {
const code = getContainingMarkdownCode(range.startContainer);
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
// Line numbers are CSS-generated, so the DOM range is already the exact
// source selection, including boundaries between rows and empty lines.
return range.toString();
};
type MarkdownCopyState = {
registrations: number;
handler: (event: ClipboardEvent) => void;
menuHandler: (event: Event) => void;
};
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
let state = markdownCopyStates.get(doc);
if (!state) {
const getSelectedText = (): string | null => {
const selection = doc.getSelection();
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
};
const handler = (event: ClipboardEvent) => {
if (!event.clipboardData) return;
const text = getSelectedText();
if (text === null) return;
event.preventDefault();
event.stopPropagation();
event.clipboardData.setData('text/plain', text);
};
const menuHandler = (event: Event) => {
const text = getSelectedText();
if (text === null) return;
event.preventDefault();
void copyTextToClipboard(text);
};
state = { registrations: 0, handler, menuHandler };
markdownCopyStates.set(doc, state);
doc.addEventListener('copy', handler, true);
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
}
state.registrations += 1;
return () => {
const current = markdownCopyStates.get(doc);
if (!current) return;
current.registrations -= 1;
if (current.registrations > 0) return;
doc.removeEventListener('copy', current.handler, true);
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
markdownCopyStates.delete(doc);
};
};
/**
* Attach a single delegated click listener for all in-markdown actions: code
* copy, table copy/download menus, mermaid copy/download, loopback preview.
@@ -552,6 +611,7 @@ export const attachMarkdownInteractions = (
container: HTMLElement,
ctx: DecorateContext,
): (() => void) => {
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
const handleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
@@ -658,5 +718,8 @@ export const attachMarkdownInteractions = (
};
container.addEventListener('click', handleClick);
return () => container.removeEventListener('click', handleClick);
return () => {
unregisterCodeCopy();
container.removeEventListener('click', handleClick);
};
};
@@ -279,3 +279,30 @@ describe('Markdown images', () => {
expect(html).not.toContain('data-openchamber-markdown-image');
});
});
describe('CJK-aware link parsing', () => {
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
expect(hrefOf(html)).toBe('https://example.com/docs');
});
test('bare URL followed by CJK punctuation trims the punctuation', () => {
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
'https://example.com/guide',
);
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
});
test('correct links are unaffected', () => {
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
'https://docs.example.com',
);
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
'https://dl.example.com/安装包(正式版)',
);
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
});
});
@@ -1,4 +1,5 @@
import { Marked, marked, type Tokens } from 'marked';
import markedLinkifyIt from 'marked-linkify-it';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
@@ -331,10 +332,15 @@ const blockMathExtension = {
},
};
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
// Plain CJK characters right after a URL are still consumed, matching GitHub.
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
markedLinkifyIt({ fuzzyLink: false }),
{
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
renderer: {
// Assistant output is untrusted. Markdown constructs still render as HTML,
// but raw HTML must remain visible text so it cannot introduce active DOM
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
import { tryParseJsonOutput } from '../toolRenderers';
import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers';
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
import { getToolDescriptionFallback } from './toolRenderUtils';
@@ -42,6 +42,29 @@ describe('getToolOutput', () => {
});
});
describe('parseDiffToUnified', () => {
test('handles a streamed diff with a bare Index header', () => {
expect(parseDiffToUnified('Index:')).toEqual([]);
expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([
{
file: 'file',
oldStart: 1,
newStart: 1,
lines: [
{ type: 'removed', lineNumber: 1, content: 'old' },
{ type: 'added', lineNumber: 1, content: 'new' },
],
},
]);
});
test('preserves spaces when extracting the indexed filename', () => {
const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new');
expect(hunk?.file).toBe('my file.ts');
});
});
describe('renderTerminalOutput', () => {
test('renders carriage-return progress updates as their latest value', () => {
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
@@ -4,6 +4,7 @@ import { useMobileAppActions } from '@/apps/mobileAppContext';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { QuestionMarkdown } from '../../QuestionMarkdown';
import { MessageFilesDisplay } from '../../FileAttachment';
import { getToolMetadata } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
@@ -31,6 +32,7 @@ import {
renderTodoOutput,
tryParseJsonOutput,
coerceToText,
capToolOutputText,
} from '../toolRenderers';
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
import { JsonSummaryView } from './JsonSummaryView';
@@ -605,11 +607,15 @@ const getToolOutputText = (
part: ToolPartType,
metadata: Record<string, unknown> | undefined,
): string => {
// Cap oversized payloads before JSON.parse / syntax highlighting / DOM work
// so a single huge tool output can't trigger a V8 Zone-allocation OOM that
// hard-crashes the renderer (issue #2265).
const capped = capToolOutputText(output);
if (part.tool === 'bash') {
return output;
return capped;
}
return formatEditOutput(output, part.tool, metadata);
return formatEditOutput(capped, part.tool, metadata);
};
const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
@@ -1407,7 +1413,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
<div className="space-y-2">
{parsedQA.map((qa, index) => (
<div key={index} className="space-y-0.5">
<div className="typography-micro text-muted-foreground">{qa.question}</div>
<QuestionMarkdown content={qa.question} size="micro" className="text-muted-foreground" />
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
</div>
))}
@@ -1444,7 +1450,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{q.header ? (
<div className="typography-micro text-muted-foreground">{coerceToText(q.header)}</div>
) : null}
<div className="typography-meta text-foreground">{coerceToText(q.question)}</div>
<QuestionMarkdown content={coerceToText(q.question)} size="meta" className="text-foreground" />
{Array.isArray(q.options) && q.options.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-0.5">
{q.options.map((opt) => (
@@ -1965,6 +1971,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return null;
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
if (!runtime?.editor) {
@@ -2037,6 +2044,61 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
handleMainClick(event);
};
// Quick-open target for the file-link icon in the tool header. Resolves the
// primary file path (and, for diff tools, the first changed line + diff) so
// the user can open the file in the side panel (web/desktop) or editor
// (VS Code) without expanding the tool card. Reuses the same path helpers as
// handleMainClick above; the difference is the web fallback — handleMainClick
// only opens when runtime.editor is available, this icon also falls back to
// useUIStore.openContextFile{AtLine} so the file opens in the right pane.
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
if (isTaskTool) return null;
const toolName = normalizedPartTool || part.tool;
const filePath = getPrimaryToolPath(toolName, input, metadata);
if (typeof filePath !== 'string') return null;
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
let line: number | undefined;
let toolDiff: string | undefined;
if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') {
line = getFirstChangedLineFromMetadata(toolName, metadata, filePath);
toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath);
}
return { absolutePath, line, toolDiff, toolName };
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
const openQuickTarget = () => {
if (!quickOpenTarget) return;
const { absolutePath, line, toolDiff, toolName } = quickOpenTarget;
if (runtime?.editor) {
if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) {
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff });
return;
}
runtime.editor.openFile(absolutePath, line);
return;
}
const uiStore = useUIStore.getState();
if (typeof line === 'number' && Number.isFinite(line)) {
uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1);
} else {
uiStore.openContextFile(currentDirectory, absolutePath);
}
mobileActions?.openFiles();
};
const handleQuickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
openQuickTarget();
};
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
openQuickTarget();
};
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
@@ -2130,7 +2192,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="flex items-center gap-1 min-w-0 flex-1">
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
@@ -2140,6 +2202,22 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
>
{displayName}
</MinDurationShineText>
{quickOpenTarget ? (
<button
type="button"
onClick={handleQuickOpen}
onKeyDown={handleQuickOpenKeyDown}
className={cn(
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
)}
style={{ color: 'var(--tools-icon)' }}
title={t('chat.toolPart.openFile')}
aria-label={t('chat.toolPart.openFile')}
>
<Icon name="external-link" className="h-3 w-3" />
</button>
) : null}
</div>
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
<span className={cn('flex-shrink-0 tabular-nums text-muted-foreground/80', TOOL_ROW_DESCRIPTION_CLASS)}>
@@ -0,0 +1,67 @@
import { describe, test, expect } from 'bun:test';
import { capToolOutputText, TOOL_OUTPUT_MAX_CHARS } from './toolRenderers';
// Regression coverage for issue #2265: the desktop renderer hard-crashes with a
// V8 "Zone Allocation failed" OOM when a tool returns oversized external content
// (e.g. a fetched Google Slides page with full-resolution base64 images inlined),
// because the whole payload previously flowed through JSON.parse / syntax
// highlighting / DOM rendering as a single unbounded JS string. capToolOutputText
// is the bounded size guard that runs before any of that work.
describe('capToolOutputText (issue #2265 renderer OOM guard)', () => {
test('exposes a sane positive default cap', () => {
expect(typeof TOOL_OUTPUT_MAX_CHARS).toBe('number');
expect(TOOL_OUTPUT_MAX_CHARS).toBeGreaterThan(0);
});
test('returns short output unchanged', () => {
const output = 'hello world';
expect(capToolOutputText(output)).toBe(output);
});
test('returns output at exactly the cap unchanged', () => {
const output = 'a'.repeat(TOOL_OUTPUT_MAX_CHARS);
expect(capToolOutputText(output)).toBe(output);
expect(capToolOutputText(output).length).toBe(TOOL_OUTPUT_MAX_CHARS);
});
test('caps oversized output and never emits the full string', () => {
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 10_000);
const capped = capToolOutputText(oversized);
// The pathological full-size string must not survive to the renderer.
expect(capped.length).toBeLessThan(oversized.length);
// Head of the payload is preserved for the user.
expect(capped.startsWith('x'.repeat(1000))).toBe(true);
// A truncation notice is appended so the truncation is visible.
expect(capped).toContain('output truncated');
expect(capped).toContain('10000 more characters');
});
test('honors a custom cap', () => {
const output = 'abcdefghij'; // 10 chars
const capped = capToolOutputText(output, 4);
expect(capped.startsWith('abcd')).toBe(true);
expect(capped).toContain('output truncated');
// Only the first 4 chars of the original body are retained.
expect(capped).not.toContain('efghij');
});
test('simulated large webfetch payload is bounded well below original size', () => {
// ~6MB single string, matching the 5MB-20MB Zone-allocation trigger range
// described in the issue (a Slides page with embedded base64 images).
const base64Blob = 'QUJD'.repeat(1_500_000); // 6,000,000 chars
const capped = capToolOutputText(base64Blob);
expect(base64Blob.length).toBeGreaterThan(5_000_000);
expect(capped.length).toBeLessThan(TOOL_OUTPUT_MAX_CHARS + 256);
expect(capped).toContain('renderer from running out of memory');
});
test('non-string input is returned unchanged (defensive)', () => {
// @ts-expect-error verifying runtime robustness against non-string inputs
expect(capToolOutputText(undefined)).toBeUndefined();
// @ts-expect-error verifying runtime robustness against non-string inputs
expect(capToolOutputText(null)).toBeNull();
});
});
@@ -22,6 +22,28 @@ export const coerceToText = (value: unknown, fallback = ''): string => {
}
};
// Guards the renderer process against V8 "Zone Allocation failed" OOM crashes
// (issue #2265). When a tool returns oversized external content — e.g. a fetched
// web page with full-resolution base64 images inlined — the entire payload flows
// through this module as a single JS string that is JSON.parsed, syntax
// highlighted, and attached to the DOM. A large enough single string exceeds
// V8's Zone allocator and hard-crashes the renderer before any virtualization or
// CSS clip can help. Capping the string length before that work happens keeps a
// useful head of the output while preventing the pathological allocation.
export const TOOL_OUTPUT_MAX_CHARS = 512 * 1024;
export const capToolOutputText = (
output: string,
maxChars: number = TOOL_OUTPUT_MAX_CHARS,
): string => {
if (typeof output !== 'string' || output.length <= maxChars) {
return output;
}
const omitted = output.length - maxChars;
const notice = `\n\n… [output truncated: ${omitted} more characters not shown to prevent the renderer from running out of memory]`;
return output.slice(0, maxChars) + notice;
};
const hasLspDiagnostics = (output: string): boolean => {
if (!output) return false;
return output.includes('<diagnostics')
@@ -575,7 +597,7 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
if (line.startsWith('Index:')) {
currentFile = line.split(' ')[1].split('/').pop() || 'file';
currentFile = line.slice('Index:'.length).trim().split('/').pop() || 'file';
}
i++;
continue;