Merge remote-tracking branch 'origin/main' into port-2877

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:48:50 +03:00
140 changed files with 4633 additions and 428 deletions
@@ -339,6 +339,25 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
}
if (action === 'browser.capture') {
// A user may close the panel after browser.open. Chromium then removes
// the zero-width webview's composited surface and capturePage() fails
// with UnknownVizError. Reveal this existing browser tab again and let
// the layout paint before asking Electron for the image.
useUIStore.getState().openContextBrowser(directory, webview.getURL());
const surfaceDeadline = Date.now() + 1_200;
let previousWidth = 0;
let stableSamples = 0;
while (stableSamples < 2 && Date.now() < surfaceDeadline) {
const width = webview.getBoundingClientRect().width;
stableSamples = width >= 2 && Math.abs(width - previousWidth) < 0.5
? stableSamples + 1
: 0;
previousWidth = width;
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
// Wait for a settled page first: a screenshot of a half-painted layout is
// worse than none, because it looks like a finished one.
await waitForIdle();
@@ -450,7 +469,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
await waitForIdle();
}
return result;
}, [annotationHost, loadUrl, waitForIdle]);
}, [annotationHost, directory, loadUrl, waitForIdle]);
React.useEffect(
() => registerBrowserController({ run: runControlAction }),
+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 }) => {
@@ -633,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
];
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
const explicitAgentSwitchRef = React.useRef<string | null>(null);
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
@@ -1051,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
prevAgentNameRef.current = currentAgentName;
if (currentAgentName && currentSessionId) {
const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName;
explicitAgentSwitchRef.current = null;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 50);
abortController.signal.addEventListener('abort', () => {
@@ -1066,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
const selectedAgent = shouldPreferAgentModel
? agents.find((agent) => agent.name === currentAgentName)
: undefined;
if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) {
const result = tryApplyModelSelection(
selectedAgent.model.providerID,
selectedAgent.model.modelID,
currentAgentName,
);
if (result === 'applied' || result === 'provider-missing') {
if (result === 'applied') {
saveSessionModelSelection(
currentSessionId,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
saveAgentModelForSession(
currentSessionId,
currentAgentName,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
}
return;
}
}
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
if (persistedChoice) {
@@ -1118,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
abortController.abort();
};
}, [
agents,
currentAgentName,
currentSessionId,
getAgentModelForSession,
saveAgentModelForSession,
saveSessionModelSelection,
tryApplyModelSelection,
contextHydrated,
]);
@@ -1212,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
try {
explicitAgentSwitchRef.current = agentName;
setAgent(agentName);
addRecentAgent(agentName);
if (options?.closeModelSelector ?? true) {
@@ -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(' ')
);
@@ -18,13 +18,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
data-scroll-spy-id={turn.turnId}
>
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
@@ -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
@@ -60,6 +72,15 @@ copy.
exactly what gets sent, so nothing downstream serializes a rich document model
back into a prompt.
The document is not, however, the string it was given: CodeMirror normalizes
line endings, so a `\r\n` pair becomes one break and the document ends up
shorter than the inserted string. **Never derive a caret position from the
length of text you are inserting** — a caret past the end makes `dispatch`
throw, the transaction never applies, and the un-normalized text stays in React
state to crash again on the next restore. Every edit that moves the caret goes
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
change instead of the string.
The composer previously painted a transparent `<textarea>` over a mirror
`<div>`. That restricted highlighting to styles which do not change glyph
advance width — colour, background, underline — because anything else made the
@@ -170,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);
});
});
@@ -36,6 +36,7 @@ import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import { replaceWithCaret } from './documentEdits';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
@@ -351,17 +352,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
// A stale value echo can differ from CodeMirror's newer document,
// and replacing it would interrupt the IME session and move the caret.
if (view.compositionStarted) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was replaced.
// Every rewrite that reaches here appends or replaces wholesale;
// keeping the old caret instead left it stranded before the
// inserted text, and the next insertion or keystroke landed inside
// the previous one.
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
@@ -515,18 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
...replaceWithCaret(view.state, from, to, text),
userEvent: 'input.type',
});
},
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const anchor = selectionStart ?? from + text.length;
const caret = selectionStart === undefined
? undefined
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor, head: selectionEnd ?? anchor },
...replaceWithCaret(view.state, from, to, text, caret),
userEvent: 'input.type',
});
},
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { replaceWithCaret } from '../documentEdits';
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
const state = EditorState.create({ doc });
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
return { text: next.doc.toString(), selection: next.selection.main };
};
describe('replaceWithCaret', () => {
test('puts the caret at the end of a wholesale replacement', () => {
const { text, selection } = apply('old', 0, 3, 'a new draft');
expect(text).toBe('a new draft');
expect(selection.anchor).toBe(11);
expect(selection.head).toBe(11);
});
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
// taken from the JS string length falls outside the document and dispatch
// throws `RangeError: Selection points outside of document`.
test('keeps the caret inside the document when CRLF is normalized away', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
test('survives a draft made only of CRLF breaks', () => {
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
expect(text).toBe('\n\n\n');
expect(selection.anchor).toBe(3);
});
test('places the caret after text inserted at the selection', () => {
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
expect(text).toBe('hello,\n there world');
expect(selection.anchor).toBe(13);
});
test('honours an explicit caret', () => {
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
expect(selection.anchor).toBe(2);
expect(selection.head).toBe(4);
});
test('clamps an explicit caret that the normalized document cannot hold', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
});
@@ -19,7 +19,7 @@ describe('composer value writeback composition guard (issue #2527)', () => {
const effect = writebackEffect();
const equalityCheck = effect.indexOf('if (current === value) return;');
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
const dispatch = effect.indexOf('view.dispatch({');
const dispatch = effect.indexOf('view.dispatch(');
expect(equalityCheck).toBeGreaterThan(-1);
expect(compositionGuard).toBeGreaterThan(equalityCheck);
@@ -0,0 +1,33 @@
import type { EditorState, TransactionSpec } from '@codemirror/state';
/**
* Replace a document range and leave the caret inside the resulting document.
*
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
* line break, so the inserted string is longer than the text it produces. A
* caret derived from the JavaScript string therefore lands past the end of the
* document and `dispatch` throws `RangeError: Selection points outside of
* document`. The transaction never applies, so the un-normalized text stays in
* React state, gets persisted as a draft, and crashes the chat again on every
* restore (issue #3013).
*
* Deriving the caret from the change set instead keeps it correct for whatever
* CodeMirror actually inserted, without this module having to know the
* normalization rules.
*/
export const replaceWithCaret = (
state: EditorState,
from: number,
to: number,
insert: string,
caret?: { anchor: number; head: number },
): TransactionSpec => {
const changes = state.changes({ from, to, insert });
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
// What CodeMirror inserted, measured on the document rather than on the
// string: the new length minus everything the change left untouched.
const insertedLength = changes.newLength - (state.doc.length - (to - from));
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
const head = caret ? clamp(caret.head) : anchor;
return { changes, selection: { anchor, head } };
};
@@ -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';
@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test';
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
const key = (
k: string,
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
describe('isFollowReleaseKey', () => {
test('upward navigation keys release follow', () => {
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
});
test('downward keys, plain space, and modified shortcuts do not', () => {
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
expect(isFollowReleaseKey(key(k))).toBe(false);
}
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
});
});
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
class FakeElement extends EventTarget {
scrollTop = 0;
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
super();
}
closest(selector: string): FakeElement | null {
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
if (this.scrollable) return this;
return this.parent?.closest(selector) ?? null;
}
}
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
// registering the fakes under those globals keeps the narrowing honest in bun.
const installDomGlobals = () => {
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
return () => Object.assign(globalThis, previous);
};
// With the globals above installed, FakeElement IS the HTMLElement the helpers
// narrow to; reading it back through the global bridges the static type without
// asserting anything the runtime does not hold.
const asRoot = (element: FakeElement): HTMLElement => {
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
return element;
};
describe('nested scroller handling', () => {
test('an upward wheel over a nested scroller with room above stays there', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const box = new FakeElement(true, root);
const inner = new FakeElement(false, box);
box.scrollTop = 40;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
box.scrollTop = 0;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
} finally {
restore();
}
});
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const row = new FakeElement(false, root);
const box = new FakeElement(true, root);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
} finally {
restore();
}
});
});
@@ -0,0 +1,41 @@
// Gesture classification for the chat timeline's follow opt-out.
//
// The timeline releases live follow on REAL upward gestures only. Wheel and
// touch carry their direction; this module answers the same question for the
// inputs that do not: which keys mean "scroll up", when a middle-button press
// starts a pan, and when an upward wheel belongs to a nested scroller (a tool
// output box) that can still consume it. Pure functions, no DOM ownership,
// so the rules are testable without a renderer.
// A nested scroller inside the timeline marks itself with this attribute
// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for
// as long as the box has room above.
const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]';
export const isFollowReleaseKey = (
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
): boolean => {
// Modified keys are shortcuts, not navigation.
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (event.key === ' ') return event.shiftKey;
return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home';
};
const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
const nested = target.closest(NESTED_SCROLLABLE_SELECTOR);
return nested instanceof HTMLElement && nested !== root ? nested : null;
};
// An upward wheel over a nested scroller that still has content above stays
// with that scroller; the timeline must not treat it as leaving the end.
export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => {
const nested = nestedScrollable(root, target);
return nested !== null && nested.scrollTop > 0;
};
// Middle-button press starts the platform's autoscroll pan (Windows/Linux
// Chromium); the pan then scrolls without wheel events, so the press itself is
// the gesture. Inside a nested scroller the pan belongs to that scroller.
export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean =>
event.button === 1 && nestedScrollable(root, event.target) === null;
@@ -97,6 +97,16 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
input.assistantMessages.forEach((message) => {
const finish = getMessageFinish(message);
const messageHasTool = message.parts.some((part) => part.type === 'tool');
// A turn blocked on a question never reaches finish === 'stop' (the
// user must answer first). Treating the text the model produced
// before the question as 'justification' would bury it inside the
// collapsible Activity group — the context stays invisible until the
// turn completes (OPE-199). Keep it inline like OpenCode.
const messageHasQuestion = message.parts.some((part) => (
part.type === 'tool'
&& typeof part.tool === 'string'
&& part.tool === 'question'
));
const messageIsCompactionSummary = isCompactionSummaryMessage(message);
message.parts.forEach((part, partIndex) => {
@@ -137,6 +147,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
input.showTextJustificationActivity
&& part.type === 'text'
&& text
&& !messageHasQuestion
&& (
messageIsCompactionSummary
|| (
@@ -221,4 +221,34 @@ describe('projectTurnRecords', () => {
const finalActivity = turn?.activityParts.find((activity) => activity.messageId === 'a2');
expect(finalActivity).toBe(undefined);
});
test('keeps text inline (not justification) when a message is blocked on a pending question', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
user.parts = [{ id: 'p1', type: 'text', text: 'prompt' } as Part];
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
// The turn is blocked waiting for the user's answer: no finish and a
// pending question tool part, with context text before the question.
assistant.parts = [
{ id: 'ap1', type: 'text', text: 'context before the question' } as Part,
{
id: 'ap2',
type: 'tool',
callID: 'c1',
tool: 'question',
state: { status: 'pending' },
} as Part,
];
const projection = projectTurnRecords([user, assistant], {
showTextJustificationActivity: true,
});
const turn = projection.turns[0];
expect(turn).toBeDefined();
const textActivity = turn?.activityParts.find((activity) => activity.partIndex === 0);
expect(textActivity?.kind).not.toBe('justification');
// The question tool itself still participates in the activity group.
const questionActivity = turn?.activityParts.find((activity) => activity.partIndex === 1);
expect(questionActivity?.kind).toBe('tool');
});
});
@@ -1,6 +1,10 @@
/// <reference lib="webworker" />
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
import { bundledLanguages, createHighlighter, type BundledLanguage, type LanguageRegistration, type ThemedToken } from 'shiki';
import {
isTemplateCallLanguageId,
sanitizeTemplateCallGrammar,
} from '../../../lib/shiki/sanitizeTemplateCallGrammar';
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
@@ -60,11 +64,30 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
type Instance = Awaited<ReturnType<typeof createHighlighter>>;
type BundledLanguageModule = { default: LanguageRegistration[] };
/**
* Load a language, neutralizing the catastrophic JS/TS `template-call` rule
* before it reaches the Oniguruma scanner (see sanitizeTemplateCallGrammar).
*/
const loadLanguageSafe = async (instance: Instance, lang: BundledLanguage): Promise<void> => {
if (!isTemplateCallLanguageId(lang)) {
await instance.loadLanguage(bundledLanguages[lang]);
return;
}
// SAFETY: every Shiki bundled-language module default-exports its grammar
// array; `lang` is narrowed to a bundled id above.
const mod = (await bundledLanguages[lang]()) as BundledLanguageModule;
const grammars = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar));
await instance.loadLanguage(...grammars);
};
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
let lang = requested in bundledLanguages ? requested : 'text';
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
try {
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
await loadLanguageSafe(instance, lang as BundledLanguage);
} catch {
lang = 'text';
}
@@ -0,0 +1,6 @@
/**
* Safety-net budget for a single Shiki worker tokenize request.
* Healthy files finish well under this; catastrophic Oniguruma backtracking
* must not run unbounded (openchamber/openchamber#2587).
*/
export const HIGHLIGHT_REQUEST_TIMEOUT_MS = 5_000;
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
describe('markdown-worker hang safety', () => {
test('exposes a finite highlight timeout budget', () => {
// Catastrophic Oniguruma backtracking must not run unbounded; the main
// thread terminates the worker after this budget (openchamber/openchamber#2587).
expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeGreaterThan(0);
expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeLessThan(15_001);
});
});
@@ -7,12 +7,20 @@ import {
utf16Bytes,
} from './highlightResultCache';
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
// Main-thread client for the markdown Shiki Web Worker. Moves syntax tokenization
// off the UI thread: a closed code block is shipped to the worker, which returns
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
// tokenization error) the promise resolves to `null` and the caller keeps the
// escaped plain-text code — highlighting never falls back onto the main thread.
// tokenization error, or hang timeout) the promise resolves to `null` and the
// caller keeps the escaped plain-text code — highlighting never falls back onto
// the main thread.
//
// The per-request timeout exists because TextMate grammars can enter catastrophic
// backtracking on the Oniguruma WASM engine (openchamber/openchamber#2587).
// Matching is synchronous inside the worker, so the only way to reclaim its heap
// is to terminate it from this thread once a request exceeds the budget. A timed
// out request resolves `null` like any other failure, so nothing is memoized.
//
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
// content must not re-enter the worker — that was the sustained ~40 msg/s
@@ -31,6 +39,11 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
type PendingEntry = {
resolve: PendingResolver;
timer: ReturnType<typeof setTimeout>;
};
type CachedHighlight =
| { type: 'highlight'; html: string }
| { type: 'highlightLines'; lines: string[] }
@@ -50,11 +63,15 @@ let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
const pending = new Map<number, PendingEntry>();
// Theme names whose full definition we've already shipped to the live worker, so
// repeat tokenization sends only the name (not the whole theme object) again.
const sentThemes = new Set<string>();
const clearPendingTimers = (): void => {
pending.forEach((entry) => clearTimeout(entry.timer));
};
const entryBytes = (key: string, value: CachedHighlight): number => {
const keyBytes = utf16Bytes(key);
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
@@ -67,7 +84,8 @@ const entryBytes = (key: string, value: CachedHighlight): number => {
};
const failAll = (): void => {
pending.forEach((resolve) => resolve(null));
clearPendingTimers();
pending.forEach((entry) => entry.resolve(null));
pending.clear();
sentThemes.clear();
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
@@ -95,10 +113,11 @@ const createWorker = async (): Promise<Worker | undefined> => {
const instance = new Worker(workerUrl, { type: 'module' });
worker = instance;
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
const entry = pending.get(event.data.id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(event.data.id);
resolve(event.data);
entry.resolve(event.data);
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
@@ -127,7 +146,14 @@ const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
pending.set(id, resolve);
const timer = setTimeout(() => {
if (!pending.has(id)) return;
// Hung tokenize (e.g. catastrophic backtracking): kill the worker so the
// WASM heap is freed instead of growing until the renderer OOMs.
console.warn(`Shiki worker highlight timed out after ${HIGHLIGHT_REQUEST_TIMEOUT_MS}ms; terminating worker`);
failAll();
}, HIGHLIGHT_REQUEST_TIMEOUT_MS);
pending.set(id, { resolve, timer });
instance.postMessage(payload(id));
});
};
@@ -1630,7 +1630,16 @@ const AssistantMessageBody = React.memo(({
&& hasAnchoredActivitySegments
&& Boolean(toggleActivityGroup);
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
// A message that asked a question is blocked until the user answers — it
// never reaches finish === 'stop', so the normal "defer text until final
// output" rule would hide the context the model produced before the
// question indefinitely (OPE-199). Render such messages' text inline,
// matching OpenCode's display.
const hasQuestionTool = React.useMemo(() => {
return toolParts.some((toolPart) => toolPart.tool === 'question');
}, [toolParts]);
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish && !hasQuestionTool;
const showErrorMessage = Boolean(errorMessage);
const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
@@ -20,6 +20,12 @@ import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } fro
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
import {
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
DESKTOP_MENU_FALLBACK_WIDTH_PX,
getDesktopClampedX,
getDesktopClampedY,
} from './selectionMenuPosition';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -43,8 +49,6 @@ const normalizeDistilledInsight = (insight: string): string => (
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
);
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
@@ -103,6 +107,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
const menuHeightRef = React.useRef(DESKTOP_MENU_FALLBACK_HEIGHT_PX);
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
const openRafRef = React.useRef<number | null>(null);
const mouseUpTimeoutRef = React.useRef<number | null>(null);
@@ -196,23 +201,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
isMenuVisibleRef.current = false;
}, []);
const getDesktopClampedX = React.useCallback((anchorX: number) => {
if (typeof window === 'undefined') {
return anchorX;
}
const getClampedX = React.useCallback((anchorX: number) => (
getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
), []);
const viewportWidth = window.innerWidth;
const menuWidth = menuWidthRef.current;
const halfWidth = menuWidth / 2;
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
if (minX > maxX) {
return viewportWidth / 2;
}
return Math.min(Math.max(anchorX, minX), maxX);
}, []);
const getClampedY = React.useCallback((anchorY: number) => (
getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
), []);
const addMarkdownToChat = React.useCallback((markdownText: string) => {
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
@@ -241,8 +236,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Position menu above the selection
const menuX = isMobile
? rect.left + rect.width / 2
: getDesktopClampedX(rect.left + rect.width / 2);
const menuY = rect.top - 10;
: getClampedX(rect.left + rect.width / 2);
const menuY = isMobile
? rect.top - 10
: getClampedY(rect.top - 10);
setSelectedText(plainText);
setSelectedTextMarkdown(markdownText);
@@ -264,7 +261,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
openRafRef.current = null;
});
}
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
}, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]);
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
@@ -272,16 +269,25 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}
const measuredWidth = menuRef.current.offsetWidth;
if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
const measuredHeight = menuRef.current.offsetHeight;
const widthChanged = Number.isFinite(measuredWidth) && measuredWidth > 0 && measuredWidth !== menuWidthRef.current;
const heightChanged = Number.isFinite(measuredHeight) && measuredHeight > 0 && measuredHeight !== menuHeightRef.current;
if (!widthChanged && !heightChanged) {
return;
}
menuWidthRef.current = measuredWidth;
if (widthChanged) {
menuWidthRef.current = measuredWidth;
}
if (heightChanged) {
menuHeightRef.current = measuredHeight;
}
setPosition((prev) => ({
...prev,
x: getDesktopClampedX(prev.x),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
}, [getDesktopClampedX, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
// The desktop popup hangs above its anchor, so a tall comment box near the
// top of the chat can climb over the app header. On the desktop shell the
@@ -310,7 +316,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleViewportResize = () => {
setPosition((prev) => ({
...prev,
x: getDesktopClampedX(prev.x),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
};
@@ -318,7 +325,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return () => {
window.removeEventListener('resize', handleViewportResize);
};
}, [getDesktopClampedX, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => {
// While the comment input is open, clicking or typing in it collapses the
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test';
import {
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
DESKTOP_MENU_FALLBACK_WIDTH_PX,
DESKTOP_MENU_SIDE_MARGIN_PX,
getDesktopClampedX,
getDesktopClampedY,
} from '../selectionMenuPosition';
const VIEWPORT_WIDTH = 1024;
const VIEWPORT_HEIGHT = 768;
const MENU_WIDTH = DESKTOP_MENU_FALLBACK_WIDTH_PX;
const MENU_HEIGHT = DESKTOP_MENU_FALLBACK_HEIGHT_PX;
// Regression coverage for issue #2257: selecting a long assistant response
// across a scroll boundary makes range.getBoundingClientRect().top negative,
// and the unclamped anchor (rect.top - 10) placed the menu above the viewport.
describe('getDesktopClampedY (issue #2257)', () => {
test('keeps the menu on screen when the selection starts above the viewport', () => {
const clamped = getDesktopClampedY(-210, VIEWPORT_HEIGHT, MENU_HEIGHT);
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT);
});
test('keeps the menu fully visible for selections near the top edge', () => {
// The menu renders with translate(-50%, -100%), so it extends upward from
// the anchor; anchors smaller than margin + menu height clip the menu.
const clamped = getDesktopClampedY(5, VIEWPORT_HEIGHT, MENU_HEIGHT);
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT);
});
test('clamps anchors below the viewport back to the bottom margin', () => {
const clamped = getDesktopClampedY(VIEWPORT_HEIGHT + 500, VIEWPORT_HEIGHT, MENU_HEIGHT);
expect(clamped).toBe(VIEWPORT_HEIGHT - DESKTOP_MENU_SIDE_MARGIN_PX);
});
test('leaves in-viewport anchors unchanged', () => {
expect(getDesktopClampedY(300, VIEWPORT_HEIGHT, MENU_HEIGHT)).toBe(300);
expect(getDesktopClampedY(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX, VIEWPORT_HEIGHT, MENU_HEIGHT))
.toBe(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX);
});
test('falls back to the viewport middle when the viewport is shorter than the menu', () => {
const tinyViewportHeight = MENU_HEIGHT;
expect(getDesktopClampedY(10, tinyViewportHeight, MENU_HEIGHT)).toBe(tinyViewportHeight / 2);
});
});
describe('getDesktopClampedX', () => {
test('clamps anchors past the left edge to the left margin', () => {
const clamped = getDesktopClampedX(-500, VIEWPORT_WIDTH, MENU_WIDTH);
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_WIDTH / 2);
});
test('clamps anchors past the right edge to the right margin', () => {
const clamped = getDesktopClampedX(VIEWPORT_WIDTH + 500, VIEWPORT_WIDTH, MENU_WIDTH);
expect(clamped).toBe(VIEWPORT_WIDTH - DESKTOP_MENU_SIDE_MARGIN_PX - MENU_WIDTH / 2);
});
test('leaves in-viewport anchors unchanged', () => {
expect(getDesktopClampedX(VIEWPORT_WIDTH / 2, VIEWPORT_WIDTH, MENU_WIDTH)).toBe(VIEWPORT_WIDTH / 2);
});
test('falls back to the viewport middle when the viewport is narrower than the menu', () => {
const tinyViewportWidth = MENU_WIDTH / 2;
expect(getDesktopClampedX(10, tinyViewportWidth, MENU_WIDTH)).toBe(tinyViewportWidth / 2);
});
});
@@ -89,6 +89,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
- Reasoning streaming presentation derives from the live stream phase (`streaming`/`cooldown`), never from missing persisted timing: a cached part without `time.end` is not live, and a part whose `time.end` is set never streams (issue #2020).
## "I want to change description for Perplexity" (example recipe)
@@ -1,9 +1,11 @@
import React from 'react';
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Part } from '@opencode-ai/sdk/v2';
import { I18nProvider } from '@/lib/i18n';
import { ReasoningTimelineBlock } from './ReasoningPart';
import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart';
import type { StreamPhase } from '../types';
// A reasoning text whose summary (first 120 chars) fits in the header but
// whose expanded body content should only appear when the disclosure is open.
@@ -113,3 +115,80 @@ describe('ReasoningTimelineBlock', () => {
expect(markup).not.toContain('&lt;!-- --&gt;');
});
});
// Regression tests for issue #2020: a persisted reasoning part must not be
// presented as live streaming just because cached data lacks `time.end` or a
// stream phase. Live activity derives from the live stream phase only.
describe('ReasoningPart streaming gating (issue #2020)', () => {
// Short enough (< 80 chars) that the collapsed header summary contains the
// complete text, letting us assert full content on first paint.
const SHORT_REASONING = 'Persisted reasoning text that is already fully available.';
const BUSY_INDICATOR = 'animate-busy-pulse';
const makeReasoningPart = (time?: { start?: number; end?: number }): Part =>
({
id: 'prt_reasoning_2020',
sessionID: 'ses_2020',
messageID: 'msg_2020',
type: 'reasoning',
text: SHORT_REASONING,
time,
}) as unknown as Part;
// Server rendering reads the UI store's initial state, which is
// chatRenderMode 'live' — the mode in which the streaming presentation is
// reachable and the issue reproduces.
const renderPart = (part: Part, streamPhase?: StreamPhase): string =>
renderToStaticMarkup(
<I18nProvider>
<ReasoningPart part={part} messageId="msg_2020" streamPhase={streamPhase} />
</I18nProvider>,
);
test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => {
// Freshly opened completed session: cached part never received `time.end`
// and no message-level stream phase is available. The full text is already
// local, so the block must render as finished content on first paint.
const markup = renderPart(makeReasoningPart({ start: 1_000 }), undefined);
expect(markup).not.toContain(BUSY_INDICATOR);
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain(SHORT_REASONING);
});
test('reasoning without time.end in a completed message renders complete, not streaming', () => {
const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'completed');
expect(markup).not.toContain(BUSY_INDICATOR);
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain(SHORT_REASONING);
});
test('reasoning with time.end is never treated as streaming, even when the phase claims streaming', () => {
const markup = renderPart(makeReasoningPart({ start: 1_000, end: 2_000 }), 'streaming');
expect(markup).not.toContain(BUSY_INDICATOR);
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain(SHORT_REASONING);
});
test('live in-progress reasoning still renders as streaming', () => {
// Genuinely live: the message-level stream phase reports streaming and the
// part has not ended. The block auto-expands and shows the busy indicator.
const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming');
expect(markup).toContain(BUSY_INDICATOR);
expect(markup).toContain('aria-expanded="true"');
});
test('remounting a completed reasoning part does not re-trigger the streaming presentation', () => {
const part = makeReasoningPart({ start: 1_000 });
const first = renderPart(part, undefined);
const second = renderPart(part, undefined);
expect(second).toBe(first);
expect(second).not.toContain(BUSY_INDICATOR);
expect(second).toContain(SHORT_REASONING);
});
});
@@ -261,7 +261,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
};
}, []);
if (!text || text.trim().length === 0) {
// While genuinely streaming, the busy header must appear as soon as
// reasoning starts even before the block-level reveal (commitStreamedText)
// has committed a first complete line — otherwise "Thinking…" never shows
// for the first moments of a short, single-paragraph response.
if (!isStreaming && (!text || text.trim().length === 0)) {
return null;
}
@@ -430,8 +434,12 @@ const ReasoningPart = React.memo(({
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
const time = partWithText.time;
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
// Live activity derives from the live stream phase, never from the absence
// of persisted timing data: cached parts may lack `time.end` even though
// the message finished long ago (issue #2020). A part that has ended is
// never streaming, even while the rest of the message still streams.
const isLiveStreamPhase = streamPhase === 'streaming' || streamPhase === 'cooldown';
const isStreaming = chatRenderMode === 'live' && isLiveStreamPhase && typeof time?.end !== 'number';
const throttledTextRaw = useStreamingTextThrottle({
text: textContent,
isStreaming,
@@ -441,9 +449,11 @@ const ReasoningPart = React.memo(({
// never mutates in place.
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
// Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content
if (!throttledText || throttledText.trim().length === 0) {
// Show reasoning even if time.end isn't set yet (during streaming).
// While genuinely streaming, keep the block mounted even before the
// block-level reveal commits a first line, so the busy header appears
// immediately instead of waiting on committed text.
if (!isStreaming && (!throttledText || throttledText.trim().length === 0)) {
return null;
}
@@ -0,0 +1,29 @@
export const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
export const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
export const DESKTOP_MENU_FALLBACK_HEIGHT_PX = 38;
export const getDesktopClampedX = (anchorX: number, viewportWidth: number, menuWidth: number): number => {
const halfWidth = menuWidth / 2;
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
if (minX > maxX) {
return viewportWidth / 2;
}
return Math.min(Math.max(anchorX, minX), maxX);
};
// The desktop menu renders with `transform: translate(-50%, -100%)`, so the
// anchor Y marks the menu's bottom edge and the menu extends `menuHeight`
// upward from it. The minimum keeps the whole menu below the top margin.
export const getDesktopClampedY = (anchorY: number, viewportHeight: number, menuHeight: number): number => {
const minY = DESKTOP_MENU_SIDE_MARGIN_PX + menuHeight;
const maxY = viewportHeight - DESKTOP_MENU_SIDE_MARGIN_PX;
if (minY > maxY) {
return viewportHeight / 2;
}
return Math.min(Math.max(anchorY, minY), maxY);
};
+5 -6
View File
@@ -2,8 +2,6 @@
// Do not edit manually. Run the script to update.
export const iconSpriteData = {
"linear": `<g transform="translate(1.5 1.5) scale(0.21)"><path fill="currentColor" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6686-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></g>`,
"cloudflare": `<g transform="translate(0.2 0.2) scale(0.18)"><path fill="currentColor" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="currentColor" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></g>`,
"add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`,
"add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`,
"ai-agent": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 14.7096 20.9205 17.1697 19.1709 18.9697C17.3551 20.8376 14.8124 22 12 22C9.18756 22 6.64488 20.8376 4.8291 18.9697C3.07949 17.1697 2 14.7096 2 12C2 6.47715 6.47715 2 12 2ZM12 16C10.0022 16 8.20124 16.8375 6.9248 18.1816C8.30642 19.3175 10.0724 20 12 20C13.9274 20 15.6927 19.3173 17.0742 18.1816C15.7978 16.8377 13.9975 16 12 16ZM12 4C7.58172 4 4 7.58172 4 12C4 13.7701 4.57462 15.4044 5.54785 16.7295C7.1822 15.0483 9.46797 14 12 14C14.5318 14 16.8169 15.0485 18.4512 16.7295C19.4246 15.4043 20 13.7703 20 12C20 7.58172 16.4183 4 12 4ZM11.5293 5.31934C11.7058 4.89329 12.2943 4.89329 12.4707 5.31934L12.7236 5.93066C13.1556 6.97343 13.9615 7.80622 14.9746 8.25684L15.6924 8.5752C16.1029 8.75796 16.1028 9.35627 15.6924 9.53906L14.9326 9.87695C13.9448 10.3163 13.1534 11.1193 12.7139 12.1279L12.4668 12.6934C12.2864 13.1074 11.7137 13.1074 11.5332 12.6934L11.2871 12.1279C10.8476 11.1193 10.0552 10.3163 9.06738 9.87695L8.30762 9.53906C7.89719 9.35628 7.89717 8.75795 8.30762 8.5752L9.02539 8.25684C10.0385 7.80623 10.8445 6.97345 11.2764 5.93066L11.5293 5.31934Z" fill="currentColor"/>`,
@@ -33,7 +31,6 @@ export const iconSpriteData = {
"book-marked": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z" fill="currentColor"/>`,
"book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`,
"booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`,
"braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`,
"brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`,
"brain-4": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227L12.999 8.42285L15.9639 10.1338L14.9639 11.8662L11 9.57715V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287L11.001 15.5771L8.03613 13.8652L9.03613 12.1338L13.001 14.4229V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227Z" fill="currentColor"/>`,
"brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`,
@@ -61,13 +58,13 @@ export const iconSpriteData = {
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
"cloud": `<path d="M12 2C15.866 2 19 5.13401 19 9C19 9.11351 18.9973 9.22639 18.992 9.33857C21.3265 10.16 23 12.3846 23 15C23 18.3137 20.3137 21 17 21H7C3.68629 21 1 18.3137 1 15C1 12.3846 2.67346 10.16 5.00804 9.33857C5.0027 9.22639 5 9.11351 5 9C5 5.13401 8.13401 2 12 2ZM12 4C9.23858 4 7 6.23858 7 9C7 9.08147 7.00193 9.16263 7.00578 9.24344L7.07662 10.7309L5.67183 11.2252C4.0844 11.7837 3 13.2889 3 15C3 17.2091 4.79086 19 7 19H17C19.2091 19 21 17.2091 21 15C21 12.79 19.21 11 17 11C15.233 11 13.7337 12.1457 13.2042 13.7347L11.3064 13.1021C12.1005 10.7185 14.35 9 17 9C17 6.23858 14.7614 4 12 4Z" fill="currentColor"/>`,
"cloud-off": `<path d="M3.51472 2.10051L22.6066 21.1924L21.1924 22.6066L19.1782 20.5924C18.503 20.8556 17.7684 21 17 21H7C3.68629 21 1 18.3137 1 15C1 12.3846 2.67346 10.16 5.00804 9.33857C5.0027 9.22639 5 9.11351 5 9C5 8.22228 5.12683 7.47418 5.36094 6.77527L2.10051 3.51472L3.51472 2.10051ZM7 9C7 9.08147 7.00193 9.16263 7.00578 9.24344L7.07662 10.7309L5.67183 11.2252C4.0844 11.7837 3 13.2889 3 15C3 17.2091 4.79086 19 7 19H17C17.1858 19 17.3687 18.9873 17.5478 18.9628L7.03043 8.44519C7.01032 8.62736 7 8.81247 7 9ZM12 2C15.866 2 19 5.13401 19 9C19 9.11351 18.9973 9.22639 18.992 9.33857C21.3265 10.16 23 12.3846 23 15C23 16.0883 22.7103 17.1089 22.2037 17.9889L20.7111 16.4955C20.8974 16.0335 21 15.5287 21 15C21 12.79 19.21 11 17 11C16.4711 11 15.9661 11.1027 15.5039 11.2892L14.0111 9.7964C14.8912 9.28978 15.9118 9 17 9C17 6.23858 14.7614 4 12 4C10.9295 4 9.93766 4.33639 9.12428 4.90922L7.69418 3.48056C8.88169 2.55284 10.3763 2 12 2Z" fill="currentColor"/>`,
"cloudflare": `<g transform="translate(0.2 0.2) scale(0.18)"><path fill="currentColor" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="currentColor" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></g>`,
"code": `<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z" fill="currentColor"/>`,
"code-ai": `<path d="M17.7134 10.1281L17.4668 10.6938C17.2864 11.1079 16.7136 11.1079 16.5331 10.6938L16.2866 10.1281C15.8471 9.11947 15.0555 8.31641 14.0677 7.87708L13.308 7.53922C12.8973 7.35653 12.8973 6.75881 13.308 6.57612L14.0252 6.25714C15.0384 5.80651 15.8442 4.97373 16.2761 3.93083L16.5293 3.31953C16.7058 2.89349 17.2942 2.89349 17.4706 3.31953L17.7238 3.93083C18.1558 4.97373 18.9616 5.80651 19.9748 6.25714L20.6919 6.57612C21.1027 6.75881 21.1027 7.35653 20.6919 7.53922L19.9323 7.87708C18.9445 8.31641 18.1529 9.11947 17.7134 10.1281ZM2.82843 12.0001L7.07107 16.2428L5.65685 17.657L0 12.0001L5.65685 6.34326L7.07107 7.75748L2.82843 12.0001ZM18.3429 17.6572L23.9998 12.0003L21.1714 9.17188L19.7571 10.5861L21.1714 12.0003L16.9287 16.2429L18.3429 17.6572Z" fill="currentColor"/>`,
"code-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z" fill="currentColor"/>`,
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
"command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`,
"command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
@@ -87,6 +84,9 @@ export const iconSpriteData = {
"emotion-happy": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 13H9C9 14.6569 10.3431 16 12 16C13.6569 16 15 14.6569 15 13H17C17 15.7614 14.7614 18 12 18C9.23858 18 7 15.7614 7 13ZM8 11C7.17157 11 6.5 10.3284 6.5 9.5C6.5 8.67157 7.17157 8 8 8C8.82843 8 9.5 8.67157 9.5 9.5C9.5 10.3284 8.82843 11 8 11ZM16 11C15.1716 11 14.5 10.3284 14.5 9.5C14.5 8.67157 15.1716 8 16 8C16.8284 8 17.5 8.67157 17.5 9.5C17.5 10.3284 16.8284 11 16 11Z" fill="currentColor"/>`,
"equalizer-2": `<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z" fill="currentColor"/>`,
"error-warning": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z" fill="currentColor"/>`,
"expand-horizontal": `<path d="M0.5 12L5.44975 7.05029L6.86396 8.46451L4.32843 11H10V13H4.32843L6.86148 15.5331L5.44727 16.9473L0.5 12ZM14 13H19.6708L17.1358 15.535L18.55 16.9493L23.5 11.9996L18.5503 7.0498L17.136 8.46402L19.6721 11H14V13Z" fill="currentColor"/>`,
"expand-left": `<path d="M10.071 4.92896L11.4852 6.34317L6.82834 11L16.0002 11.0002L16.0002 13.0002L6.82839 13L11.4852 17.6569L10.071 19.0711L2.99994 12L10.071 4.92896ZM18.0001 19V4.99997H20.0001V19H18.0001Z" fill="currentColor"/>`,
"expand-right": `<path d="M17.1717 11L12.5148 6.34317L13.929 4.92896L21.0001 12L13.929 19.0711L12.5148 17.6569L17.1716 13L7.9998 13.0002L7.99978 11.0002L17.1717 11ZM3.99985 19L3.99985 4.99997H5.99985V19H3.99985Z" fill="currentColor"/>`,
"expand-up-down": `<path d="M18.2072 9.0428 12.0001 2.83569 5.793 9.0428 7.20721 10.457 12.0001 5.66412 16.793 10.457 18.2072 9.0428ZM5.79285 14.9572 12 21.1643 18.2071 14.9572 16.7928 13.543 12 18.3359 7.20706 13.543 5.79285 14.9572Z" fill="currentColor"/>`,
"external-link": `<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z" fill="currentColor"/>`,
"eye": `<path d="M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z" fill="currentColor"/>`,
@@ -153,6 +153,7 @@ export const iconSpriteData = {
"layout-right": `<path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM15 5H4V19H15V5ZM20 5H17V19H20V5Z" fill="currentColor"/>`,
"leaf": `<path d="M20.998 3V5C20.998 14.6274 15.6255 19 8.99805 19L5.24077 18.9999C5.0786 19.912 4.99805 20.907 4.99805 22H2.99805C2.99805 20.6373 3.11376 19.3997 3.34381 18.2682C3.1133 16.9741 2.99805 15.2176 2.99805 13C2.99805 7.47715 7.4752 3 12.998 3C14.998 3 16.998 4 20.998 3ZM12.998 5C8.57977 5 4.99805 8.58172 4.99805 13C4.99805 13.3624 5.00125 13.7111 5.00759 14.0459C6.26198 12.0684 8.09902 10.5048 10.5019 9.13176L11.4942 10.8682C8.6393 12.4996 6.74554 14.3535 5.77329 16.9998L8.99805 17C15.0132 17 18.8692 13.0269 18.9949 5.38766C17.6229 5.52113 16.3481 5.436 14.7754 5.20009C13.6243 5.02742 13.3988 5 12.998 5Z" fill="currentColor"/>`,
"lightbulb": `<path d="M9.97308 18H11V13H13V18H14.0269C14.1589 16.7984 14.7721 15.8065 15.7676 14.7226C15.8797 14.6006 16.5988 13.8564 16.6841 13.7501C17.5318 12.6931 18 11.385 18 10C18 6.68629 15.3137 4 12 4C8.68629 4 6 6.68629 6 10C6 11.3843 6.46774 12.6917 7.31462 13.7484C7.40004 13.855 8.12081 14.6012 8.23154 14.7218C9.22766 15.8064 9.84103 16.7984 9.97308 18ZM10 20V21H14V20H10ZM5.75395 14.9992C4.65645 13.6297 4 11.8915 4 10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10C20 11.8925 19.3428 13.6315 18.2443 15.0014C17.624 15.7748 16 17 16 18.5V21C16 22.1046 15.1046 23 14 23H10C8.89543 23 8 22.1046 8 21V18.5C8 17 6.37458 15.7736 5.75395 14.9992Z" fill="currentColor"/>`,
"linear": `<g transform="translate(1.5 1.5) scale(0.21)"><path fill="currentColor" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6686-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></g>`,
"link-unlink-m": `<path d="M17.657 14.8284L16.2428 13.4142L17.657 12C19.2191 10.4379 19.2191 7.90526 17.657 6.34316C16.0949 4.78106 13.5622 4.78106 12.0001 6.34316L10.5859 7.75737L9.17171 6.34316L10.5859 4.92895C12.9291 2.5858 16.7281 2.5858 19.0712 4.92895C21.4143 7.27209 21.4143 11.0711 19.0712 13.4142L17.657 14.8284ZM14.8286 17.6569L13.4143 19.0711C11.0712 21.4142 7.27221 21.4142 4.92907 19.0711C2.58592 16.7279 2.58592 12.9289 4.92907 10.5858L6.34328 9.17159L7.75749 10.5858L6.34328 12C4.78118 13.5621 4.78118 16.0948 6.34328 17.6569C7.90538 19.219 10.438 19.219 12.0001 17.6569L13.4143 16.2427L14.8286 17.6569ZM14.8286 7.75737L16.2428 9.17159L9.17171 16.2427L7.75749 14.8284L14.8286 7.75737ZM5.77539 2.29291L7.70724 1.77527L8.74252 5.63897L6.81067 6.15661L5.77539 2.29291ZM15.2578 18.3611L17.1896 17.8434L18.2249 21.7071L16.293 22.2248L15.2578 18.3611ZM2.29303 5.77527L6.15673 6.81054L5.63909 8.7424L1.77539 7.70712L2.29303 5.77527ZM18.3612 15.2576L22.2249 16.2929L21.7072 18.2248L17.8435 17.1895L18.3612 15.2576Z" fill="currentColor"/>`,
"list-check-2": `<path d="M11 4H21V6H11V4ZM11 8H17V10H11V8ZM11 14H21V16H11V14ZM11 18H17V20H11V18ZM3 4H9V10H3V4ZM5 6V8H7V6H5ZM3 14H9V20H3V14ZM5 16V18H7V16H5Z" fill="currentColor"/>`,
"list-check-3": `<path d="M8.00008 6V9H5.00008V6H8.00008ZM3.00008 4V11H10.0001V4H3.00008ZM13.0001 4H21.0001V6H13.0001V4ZM13.0001 11H21.0001V13H13.0001V11ZM13.0001 18H21.0001V20H13.0001V18ZM10.7072 16.2071L9.29297 14.7929L6.00008 18.0858L4.20718 16.2929L2.79297 17.7071L6.00008 20.9142L10.7072 16.2071Z" fill="currentColor"/>`,
@@ -187,7 +188,6 @@ export const iconSpriteData = {
"picture-in-picture-2": `<path d="M21 3C21.5523 3 22 3.44772 22 4V11H20V5H4V19H10V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM21 13C21.5523 13 22 13.4477 22 14V20C22 20.5523 21.5523 21 21 21H13C12.4477 21 12 20.5523 12 20V14C12 13.4477 12.4477 13 13 13H21ZM20 15H14V19H20V15ZM6.70711 6.29289L8.95689 8.54289L11 6.5V12H5.5L7.54289 9.95689L5.29289 7.70711L6.70711 6.29289Z" fill="currentColor"/>`,
"pie-chart": `<path d="M9 2.4578V4.58152C6.06817 5.76829 4 8.64262 4 12C4 16.4183 7.58172 20 12 20C15.3574 20 18.2317 17.9318 19.4185 15H21.5422C20.2679 19.0571 16.4776 22 12 22C6.47715 22 2 17.5228 2 12C2 7.52236 4.94289 3.73207 9 2.4578ZM12 2C17.5228 2 22 6.47715 22 12C22 12.3375 21.9833 12.6711 21.9506 13H11V2.04938C11.3289 2.01672 11.6625 2 12 2ZM13 4.06189V11H19.9381C19.4869 7.38128 16.6187 4.51314 13 4.06189Z" fill="currentColor"/>`,
"play": `<path d="M16.3944 12.0001L10 7.7371V16.263L16.3944 12.0001ZM19.376 12.4161L8.77735 19.4818C8.54759 19.635 8.23715 19.5729 8.08397 19.3432C8.02922 19.261 8 19.1645 8 19.0658V4.93433C8 4.65818 8.22386 4.43433 8.5 4.43433C8.59871 4.43433 8.69522 4.46355 8.77735 4.5183L19.376 11.584C19.6057 11.7372 19.6678 12.0477 19.5146 12.2774C19.478 12.3323 19.4309 12.3795 19.376 12.4161Z" fill="currentColor"/>`,
"play-list-add": `<path d="M2 18H12V20H2V18ZM2 11H22V13H2V11ZM2 4H22V6H2V4ZM18 18V15H20V18H23V20H20V23H18V20H15V18H18Z" fill="currentColor"/>`,
"plug": `<path d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6H19C19.5523 6 20 6.44772 20 7V14C20 16.2091 18.2091 18 16 18H13ZM8 16H16C17.1046 16 18 15.1046 18 14V11H6V14C6 15.1046 6.89543 16 8 16ZM18 8H6V9H18V8ZM12 14.5C11.4477 14.5 11 14.0523 11 13.5C11 12.9477 11.4477 12.5 12 12.5C12.5523 12.5 13 12.9477 13 13.5C13 14.0523 12.5523 14.5 12 14.5Z" fill="currentColor"/>`,
"plug-2": `<path d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V7C4 6.44772 4.44772 6 5 6H7V2H9V6H15V2H17V6H19C19.5523 6 20 6.44772 20 7V14C20 16.2091 18.2091 18 16 18H13ZM8 16H16C17.1046 16 18 15.1046 18 14V11H6V14C6 15.1046 6.89543 16 8 16ZM18 8H6V9H18V8ZM12 14.5C11.4477 14.5 11 14.0523 11 13.5C11 12.9477 11.4477 12.5 12 12.5C12.5523 12.5 13 12.9477 13 13.5C13 14.0523 12.5523 14.5 12 14.5ZM11 2H13V5H11V2Z" fill="currentColor"/>`,
"pulse": `<path d="M9 7.53861L15 21.5386L18.6594 13H23V11H17.3406L15 16.4614L9 2.46143L5.3406 11H1V13H6.6594L9 7.53861Z" fill="currentColor"/>`,
@@ -232,7 +232,6 @@ export const iconSpriteData = {
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
"telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`,
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
@@ -3,6 +3,7 @@ import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { DiffViewIcon } from '@/components/icons/DiffIcon';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { PullRequestView } from '@/components/views/PullRequestView';
import { TerminalView } from '@/components/views/TerminalView';
@@ -979,6 +980,50 @@ export const ContextPanel: React.FC = () => {
const isFileTabActive = activeTab?.mode === 'file';
const closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs);
const renderTabContextMenu = React.useCallback(
(args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => {
if (!directoryKey) {
return null;
}
const { id, index, allIds, close } = args;
const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id));
const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index));
const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1));
const closeAll = () => closeContextPanelTabs(directoryKey, allIds);
const hasOthers = allIds.length > 1;
const isFirst = index === 0;
const isLast = index === allIds.length - 1;
return (
<>
<ContextMenuItem onClick={close}>
<Icon name="close" className="mr-2 size-4" />
{t('contextPanel.tab.menu.close')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={closeOthers} disabled={!hasOthers}>
<Icon name="expand-horizontal" className="mr-2 size-4" />
{t('contextPanel.tab.menu.closeOthers')}
</ContextMenuItem>
<ContextMenuItem onClick={closeToLeft} disabled={isFirst}>
<Icon name="expand-left" className="mr-2 size-4" />
{t('contextPanel.tab.menu.closeToLeft')}
</ContextMenuItem>
<ContextMenuItem onClick={closeToRight} disabled={isLast}>
<Icon name="expand-right" className="mr-2 size-4" />
{t('contextPanel.tab.menu.closeToRight')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={closeAll} disabled={!hasOthers}>
<Icon name="close-circle" className="mr-2 size-4" />
{t('contextPanel.tab.menu.closeAll')}
</ContextMenuItem>
</>
);
},
[closeContextPanelTabs, directoryKey, t],
);
const header = (
<header className="flex h-10 items-stretch border-b border-border">
{isMultiInstanceMode ? (
@@ -1005,6 +1050,7 @@ export const ContextPanel: React.FC = () => {
}}
layoutMode="scrollable"
variant="default"
tabContextMenu={renderTabContextMenu}
/>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
@@ -47,6 +47,7 @@ import { isFilesystemError } from '@/lib/api/files-errors';
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
import { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { recordFileTreeDragStart, shouldTreatFileTreeDragEndAsClick } from './fileTreeDragClick';
type FileNode = {
name: string;
@@ -388,12 +389,20 @@ const FileRow: React.FC<FileRowProps> = ({
);
const handleDragStart = React.useCallback((e: React.DragEvent) => {
recordFileTreeDragStart(e);
const path = getRelativePath(root, node.path);
if (!path || path === '.') return;
e.dataTransfer.setData('application/x-openchamber-file-path', path);
e.dataTransfer.effectAllowed = 'copy';
}, [node.path, root]);
const handleDragEnd = React.useCallback((e: React.DragEvent) => {
// A micro-drag suppressed the click this gesture was meant to be (#2368).
if (shouldTreatFileTreeDragEndAsClick(e)) {
handleInteraction();
}
}, [handleInteraction]);
const handleExternalDragOver = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
@@ -434,12 +443,12 @@ const FileRow: React.FC<FileRowProps> = ({
onContextMenu={handleContextMenu}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isDropTarget
? 'bg-interactive-selection ring-2 ring-inset ring-primary'
: (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'),
'cursor-grab active:cursor-grabbing'
: (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40')
)}
>
{isDir ? (
@@ -1423,13 +1432,20 @@ export const SidebarFilesTree: React.FC = () => {
onClick={() => handleOpenFile(node)}
draggable
onDragStart={(e) => {
recordFileTreeDragStart(e);
const path = node.relativePath || getRelativePath(root ?? '', node.path);
if (!path || path === '.') return;
e.dataTransfer.setData('application/x-openchamber-file-path', path);
e.dataTransfer.effectAllowed = 'copy';
}}
onDragEnd={(e) => {
// A micro-drag suppressed the click this gesture was meant to be (#2368).
if (shouldTreatFileTreeDragEndAsClick(e)) {
void handleOpenFile(node);
}
}}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors cursor-grab active:cursor-grabbing',
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
)}
title={node.path}
@@ -15,6 +15,7 @@ import { useUIStore } from '@/stores/useUIStore';
const __dirname = dirname(fileURLToPath(import.meta.url));
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
const browserPaneSource = readFileSync(join(__dirname, '..', '..', 'browser', 'BrowserPane.tsx'), 'utf-8');
const DIRECTORY = '/path/to/repository';
beforeEach(() => {
@@ -40,4 +41,10 @@ describe('issue #3175 browser capture while the context panel is closed', () =>
expect(panel.tabs[0]?.mode).toBe('browser');
expect(panel.tabs[0]?.targetPath).toBe('https://example.com');
});
test('reveals the browser again if it was closed before capture', () => {
expect(browserPaneSource).toContain(
'openContextBrowser(directory, webview.getURL())',
);
});
});
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import {
recordFileTreeDragStart,
resetFileTreeDragClickState,
shouldTreatFileTreeDragEndAsClick,
} from './fileTreeDragClick';
const dragEnd = (clientX: number, clientY: number, dropEffect = 'none') => ({
clientX,
clientY,
dataTransfer: { dropEffect },
});
beforeEach(() => {
resetFileTreeDragClickState();
});
describe('file tree drag-click fallback (#2368)', () => {
test('a micro-drag that ends where it began is recovered as a click', () => {
// Chromium starts a native drag after ~4px of pointer travel and then
// suppresses the click event for the rest of the gesture. On macOS
// trackpads a plain click routinely slips past that threshold, which is
// the "clicking a folder does nothing" symptom of issue #2368.
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(102, 201))).toBe(true);
});
test('a zero-travel drag end is recovered as a click', () => {
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(true);
});
test('a drag released far from its origin is not a click', () => {
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(180, 230))).toBe(false);
});
test('slop boundary: within the radius is a click, beyond it is not', () => {
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(108, 208))).toBe(true);
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(109, 200))).toBe(false);
});
test('a drag dropped onto a target is never a click', () => {
// Dragging a file into the chat input inserts an @mention; a completed
// drop must not additionally toggle or open the row.
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(101, 200, 'copy'))).toBe(false);
});
test('a drag end without a recorded start is ignored', () => {
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(false);
});
test('the recorded origin is consumed by the first drag end', () => {
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(true);
expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(false);
});
test('a missing dataTransfer still recovers a near-origin drag as a click', () => {
recordFileTreeDragStart({ clientX: 100, clientY: 200 });
expect(
shouldTreatFileTreeDragEndAsClick({ clientX: 101, clientY: 201, dataTransfer: null }),
).toBe(true);
});
});
@@ -0,0 +1,70 @@
/**
* Click-reliability fallback for file tree rows that are both clickable and
* draggable (issue #2368).
*
* A native HTML5 drag starts after only a few pixels of pointer travel
* (4px in Chromium), and once `dragstart` fires the browser suppresses the
* `click` event for that gesture entirely. On macOS trackpads and Magic
* Mouse a plain click very often slips past that threshold, so rows that
* carry `draggable` (to drag file references into the chat input) randomly
* ignored clicks: folders neither expanded nor collapsed and files did not
* open.
*
* Arming `draggable` only after a pointer-move threshold is not a fix:
* Chromium decides drag eligibility on the first mouse move after mousedown
* and never re-evaluates, so a drag whose first movement stays below the
* threshold would never start (verified against headless Chromium).
*
* Instead the row stays draggable, and a drag that ends where it began
* within a small slop radius and without dropping onto any target is
* treated as the click it was meant to be. The two paths are mutually
* exclusive: when the browser suppresses `click` it fired `dragstart`, and
* when `click` fires no drag ever started, so the row action runs exactly
* once per gesture.
*
* Module-level state is safe here because the platform allows only one
* native drag at a time.
*/
/**
* Chromium starts a native drag at 4px of travel, so a suppressed click's
* dragstartdragend distance is near zero. The slop only needs to absorb
* the remaining wobble between drag start and release; a deliberate drag
* released mid-flight travels far beyond it.
*/
const DRAG_CLICK_SLOP_PX = 8;
type DragPointerEvent = {
clientX: number;
clientY: number;
};
let pendingDragOrigin: { x: number; y: number } | null = null;
/** Record where a file row drag started. Call from the row's `dragstart`. */
export const recordFileTreeDragStart = (event: DragPointerEvent): void => {
pendingDragOrigin = { x: event.clientX, y: event.clientY };
};
/**
* True when the drag that just ended was an accidental micro-drag that
* swallowed a click: it was never dropped onto a target and it ended within
* `DRAG_CLICK_SLOP_PX` of where it started. Consumes the recorded origin.
*/
export const shouldTreatFileTreeDragEndAsClick = (
event: DragPointerEvent & { dataTransfer: { dropEffect: string } | null },
): boolean => {
const origin = pendingDragOrigin;
pendingDragOrigin = null;
if (!origin) return false;
if (event.dataTransfer && event.dataTransfer.dropEffect !== 'none') return false;
return (
Math.abs(event.clientX - origin.x) <= DRAG_CLICK_SLOP_PX
&& Math.abs(event.clientY - origin.y) <= DRAG_CLICK_SLOP_PX
);
};
/** Reset module state. Intended for tests. */
export const resetFileTreeDragClickState = (): void => {
pendingDragOrigin = null;
};
@@ -450,7 +450,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-16"
className="w-20"
/>
{temperature !== undefined && (
<Button
@@ -488,7 +488,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-16"
className="w-20"
/>
{topP !== undefined && (
<Button
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { reportSettingsSaveState } from '@/lib/persistence';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import {
@@ -375,7 +376,7 @@ export const BehaviorPage: React.FC = () => {
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_ROW_TRIGGER_CLASS, 'max-w-72')}>
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
@@ -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'))
@@ -1198,7 +1217,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={uiFont} onValueChange={(value) => setUiFont(value as UiFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectValue>{UI_FONT_OPTIONS.find((option) => option.id === uiFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1228,7 +1247,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={monoFont} onValueChange={(value) => setMonoFont(value as MonoFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectValue>{CODE_FONT_OPTIONS.find((option) => option.id === monoFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1269,6 +1288,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
@@ -1299,6 +1319,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={52}
step={1}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1328,6 +1349,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={32}
step={1}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1362,6 +1384,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
<Button size="sm"
@@ -1392,6 +1415,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={0}
max={100}
step={5}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1976,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"
@@ -2001,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>
)}
</>
@@ -219,7 +219,7 @@ export const PasskeySettings: React.FC = () => {
{passkeys.map((passkey) => (
<SettingsFieldRow
key={passkey.id}
label={<span className="truncate">{passkey.label}</span>}
label={<span title={passkey.label}>{passkey.label}</span>}
alignEnd={false}
controlClassName="justify-between sm:flex-1"
>
@@ -87,7 +87,7 @@ export const SessionRetentionSettings: React.FC = () => {
max={MAX_DAYS}
step={1}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
className="w-20 tabular-nums"
className="w-24 tabular-nums"
/>
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
<Button
@@ -1051,13 +1051,13 @@ export const VoiceSettings: React.FC = () => {
{/* Speech Rate */}
<SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
</SettingsFieldRow>
{/* Speech Pitch */}
<SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
</SettingsFieldRow>
{/* Speech Volume */}
@@ -2322,7 +2322,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
className="w-20 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2350,7 +2350,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-20 tabular-nums"
className="w-32 tabular-nums"
value={draft.remoteOpenchamber.preferredPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2517,7 +2517,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-20 tabular-nums"
className="w-32 tabular-nums"
value={draft.localForward.preferredLocalPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2775,7 +2775,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-16 tabular-nums"
className="w-32 tabular-nums"
value={forward.localPort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -2817,7 +2817,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-16 tabular-nums"
className="w-32 tabular-nums"
value={forward.remotePort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -310,8 +310,8 @@ export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({
)}
>
<div className="min-w-0 @xl:w-56 @xl:shrink-0">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
<div className="flex min-w-0 items-center gap-1.5">
<div className={cn('min-w-0 truncate', SETTINGS_FIELD_LABEL_CLASS)}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{description != null ? (
@@ -42,9 +42,12 @@ existing data; it is never treated as an authoritative empty list.
Web and desktop show managed Chats before optional Recent activity. Chats use
their shared managed root for folders and never expose worktree actions. Project
display can be all projects or one selected project. VS Code excludes worktrees
and managed Chats, while retaining its workspace-scoped grouped list and inline
archived buckets.
display can be all projects or one selected project. The mobile sessions sheet
(`apps/MobileSessionsSheet.tsx`) partitions the same way through
`partitionSidebarSessions` and lists Chats as a collapsible section above the
project tree, with no Recent projection. VS Code excludes worktrees and managed
Chats, while retaining its workspace-scoped grouped list and inline archived
buckets.
Directory demand always includes known project roots and worktrees. Visibility
only changes priority. Row mounts must not start bootstrap work. Selection and
@@ -71,3 +74,4 @@ make every row observe unrelated streaming updates.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on always-visible-actions rows, which reserve permanent padding and keep the badges shown (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./SessionNodeItem.tsx', import.meta.url), 'utf8');
describe('SessionNodeItem recent-activity timestamp', () => {
test('the recent activity rows render the compact timestamp in the inline metadata slot', () => {
// The right-slot guard must open for recent rows even when no activity,
// goal glyph, or branch marker is present.
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
expect(guard).toBeGreaterThan(-1);
// The recent-only block sits inside that slot…
const guardOpen = source.indexOf("{renderContext === 'recent' ? (", guard);
expect(guardOpen).toBeGreaterThan(guard);
// …and the compact label rendered there is the first one after it.
const label = source.indexOf('{sessionCompactUpdatedLabel}', guardOpen);
expect(label).toBeGreaterThan(guardOpen);
// The only later occurrence is the pre-existing row tooltip (which shows
// the full date), not a second inline render.
const tooltipLabel = source.indexOf('{sessionCompactUpdatedLabel}', label + 1);
expect(tooltipLabel).toBeGreaterThan(label);
expect(source.indexOf('title={sessionUpdatedLabel}', tooltipLabel - 80)).toBeGreaterThan(-1);
});
test('the timestamp shares the hover-fade of the other metadata so revealed actions never overlap it', () => {
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
// The slot content fades out while the row is hovered (hideOnHoverClass)
// and while the row menu is open — the same span that now carries the
// recent timestamp.
const hideOnHover = source.indexOf('hideOnHoverClass', guard);
expect(hideOnHover).toBeGreaterThan(guard);
expect(hideOnHover).toBeLessThan(source.indexOf("{renderContext === 'recent' ? (", guard));
});
test('the compact label uses the existing i18n-backed relative time helper', () => {
// formatSessionCompactDateLabel (already used by touch runtimes and the
// row tooltip) is the source of the label — no new formatting code.
expect(source.indexOf('const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);')).toBeGreaterThan(-1);
expect(source.indexOf('{sessionCompactUpdatedLabel}')).toBeGreaterThan(-1);
});
});
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils';
import type { SessionNode } from '../types';
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -685,6 +685,14 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
const pendingQuestionLabel = pendingQuestionCount === 1
? t('sessions.sidebar.session.status.questionPendingSingle')
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount });
// Actions are permanently visible (with matching permanent padding) only in
// the non-VSCode alwaysShowActions layout; every other layout hover-reveals
// them over the row's right edge, where the badges live (#2284).
const badgeVisibilityClass = selectRowBadgeVisibilityClass({
actionsAlwaysVisible: alwaysShowActions && !isVSCode,
menuOpen: isSessionMenuOpen,
hideOnHoverClass,
});
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
// Both states are the same static dot; only the color separates "running"
@@ -1390,7 +1398,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
</>
)}
</span>
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? (
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
@@ -1414,19 +1422,31 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
{/* The recent activity list shows its compact
timestamp inline (touch runtimes already get
it through the alwaysShowActions branch);
it shares the slot with the goal/branch
metadata and hides on hover exactly like
them, so the revealed row actions never
overlap it. */}
{renderContext === 'recent' ? (
<span className="flex-shrink-0 text-[0.72rem] leading-none text-muted-foreground/75 tabular-nums">
{sessionCompactUpdatedLabel}
</span>
) : null}
</>
)}
</span>
</div>
) : null}
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<span className={cn('inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0', badgeVisibilityClass)} title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<Icon name="shield" className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
) : null}
{pendingQuestionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
<span className={cn('inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0', badgeVisibilityClass)} title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
<Icon name="question" className="h-3 w-3" />
<span className="leading-none">{pendingQuestionCount}</span>
</span>
@@ -9,6 +9,7 @@ import {
nodeHasPinnedMembershipChange,
selectFolderRootNodes,
selectQuestionBadgeSessionScopes,
selectRowBadgeVisibilityClass,
} from './sessionNodeItemUtils';
import type { SessionNode } from '../types';
@@ -166,6 +167,45 @@ describe('selectFolderRootNodes', () => {
});
});
describe('selectRowBadgeVisibilityClass', () => {
const hideOnHoverClass = 'group-hover:opacity-0 group-focus-within:opacity-0';
test('hides the badge while hover-revealed actions are shown, like the date label (#2284)', () => {
const className = selectRowBadgeVisibilityClass({
actionsAlwaysVisible: false,
menuOpen: false,
hideOnHoverClass,
});
expect(className).toContain(hideOnHoverClass);
expect(className).toContain('transition-opacity');
});
test('hides the badge while the row menu keeps the actions visible without hover', () => {
const className = selectRowBadgeVisibilityClass({
actionsAlwaysVisible: false,
menuOpen: true,
hideOnHoverClass,
});
expect(className).toContain('opacity-0');
expect(className).not.toContain('group-hover');
});
test('keeps the badge always visible when actions have reserved permanent padding', () => {
expect(selectRowBadgeVisibilityClass({
actionsAlwaysVisible: true,
menuOpen: false,
hideOnHoverClass,
})).toBe('');
expect(selectRowBadgeVisibilityClass({
actionsAlwaysVisible: true,
menuOpen: true,
hideOnHoverClass,
})).toBe('');
});
});
describe('getSessionWorktreeMenuDisabled', () => {
test('shares the parent trigger disabled contract with the new worktree action', () => {
expect(getSessionWorktreeMenuDisabled({
@@ -333,6 +333,25 @@ export const nodeHasPinnedMembershipChange = (
return visit(prevNode, nextNode);
};
/**
* Visibility classes for the row's right-edge badges (pending permissions /
* questions). The hover actions paint over the row's right edge, and they are
* also forced visible while the row menu is open without hover, so the
* hover reveal padding does not apply and the actions would cover the badges.
* The badges therefore yield exactly like the date/branch metadata label:
* hidden while the actions are hover-revealed or the menu is open. Rows with
* always-visible actions reserve permanent padding instead, so their badges
* never conflict and must stay visible.
*/
export const selectRowBadgeVisibilityClass = (input: {
actionsAlwaysVisible: boolean;
menuOpen: boolean;
hideOnHoverClass: string;
}): string => {
if (input.actionsAlwaysVisible) return '';
return `transition-opacity duration-150 ${input.menuOpen ? 'opacity-0' : input.hideOnHoverClass}`;
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n';
interface DebugPanelProps {
@@ -21,6 +22,17 @@ interface DebugPanelProps {
type DebugTab = 'memory' | 'streaming' | 'requests';
function getDebugTabIcon(tab: DebugTab): IconName {
switch (tab) {
case 'memory':
return 'database-2';
case 'streaming':
return 'bar-chart-box';
case 'requests':
return 'pulse';
}
}
const formatDuration = (durationMs: number): string => {
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
@@ -302,7 +314,7 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Icon
name={activeTab === 'memory' ? 'database-2' : activeTab === 'streaming' ? 'bar-chart-box' : 'pulse'}
name={getDebugTabIcon(activeTab)}
className="h-4 w-4 text-[var(--surface-foreground)]"
/>
<h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{t('memoryDebugPanel.title')}</h3>
@@ -21,6 +21,7 @@ import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { Icon } from "@/components/icon/Icon";
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
export type SortableTabsStripItem = {
id: string;
@@ -49,6 +50,15 @@ type SortableTabsStripProps = {
(e.g. a sliding mobile drawer): creating a composited layer mid-slide
flickers in WKWebView. Tab-switch animation stays (layout transition). */
nonCompositedIndicator?: boolean;
/** Per-tab right-click context menu. Return the menu items for the given tab,
or null/undefined to disable the context menu for that tab. */
tabContextMenu?: (args: {
id: string;
index: number;
isActive: boolean;
allIds: string[];
close: () => void;
}) => React.ReactNode;
className?: string;
};
@@ -106,6 +116,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
animateActivePill,
activePillLowercase = true,
nonCompositedIndicator = false,
tabContextMenu,
className,
}) => {
const { t } = useI18n();
@@ -445,7 +456,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
aria-hidden
/>
) : null}
{items.map((item) => {
{items.map((item, index) => {
const isActive = item.id === activeId;
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
const shouldShowLabel = !showInactiveIconOnly;
@@ -479,9 +490,18 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
}
}
: undefined;
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
<div
const tabMenuItems = !isMobile && tabContextMenu
? tabContextMenu({
id: item.id,
index,
isActive,
allIds: itemIDs,
close: () => onClose?.(item.id),
})
: null;
const tabElement = (
<div
ref={(element) => setTabRef(item.id, element)}
onAuxClick={handleAuxClick}
onMouseDown={handleMouseDown}
@@ -636,6 +656,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
</button>
) : null}
</div>
);
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
{tabMenuItems ? (
<ContextMenu>
<ContextMenuTrigger
render={(triggerProps) => (
<div {...triggerProps} className={cn('flex h-full min-w-0', triggerProps.className)}>
{tabElement}
</div>
)}
/>
<ContextMenuContent className="w-52">{tabMenuItems}</ContextMenuContent>
</ContextMenu>
) : (
tabElement
)}
</Wrapper>
);
})}
+107 -27
View File
@@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -968,6 +969,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
// In-preview find for the rendered Markdown preview (Ctrl/Cmd+F).
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
@@ -2915,6 +2921,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setIsGoToLineOpen(true);
});
// Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown
// preview. In edit mode CodeMirror owns the shortcut, so this handler is
// active only while the preview is shown.
React.useEffect(() => {
if (!isMarkdown || getMdViewMode() !== 'preview') {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) {
return;
}
if (event.key.toLowerCase() !== 'f') {
return;
}
const target = event.target;
if (target instanceof Element && target.closest('[role="dialog"]')) {
return;
}
event.preventDefault();
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [getMdViewMode, isMarkdown]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
const editorExtensions = React.useMemo(() => {
@@ -3366,6 +3400,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isMarkdown && getMdViewMode() === 'preview' && (
withTooltip(t('filesView.editor.findInFile'),
<Button
variant="ghost"
size="sm"
onClick={() => {
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
}}
className="size-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={t('filesView.editor.findInFile')}
>
<Icon name="search" className="size-4" />
</Button>
)
)}
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
<Tooltip>
<TooltipTrigger asChild>
@@ -3842,34 +3893,50 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
</ErrorBoundary>
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
<div className="relative h-full min-h-0">
<div
className="oc-file-preview h-full overflow-auto p-3"
ref={(node) => {
markdownPreviewRef.current = node;
mdPreviewContainerRef.current = node;
}}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
</ErrorBoundary>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
{!isFullscreen && (
<MarkdownPreviewSearch
containerRef={mdPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
/>
)}
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
isHtmlAssetAuthLoading ? (
@@ -4213,7 +4280,20 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : null}
</div>
) : isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
<div
className="oc-file-preview h-full overflow-auto p-4"
ref={(node) => {
markdownPreviewRef.current = node;
mdFullscreenPreviewContainerRef.current = node;
}}
>
<MarkdownPreviewSearch
containerRef={mdFullscreenPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
className="right-4 top-16"
/>
{selectedFile ? (
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { findMatchRanges } from './markdownPreviewFind';
describe('findMatchRanges', () => {
test('returns no ranges for an empty or whitespace-only query', () => {
expect(findMatchRanges('hello world', '')).toEqual([]);
expect(findMatchRanges('hello world', ' ')).toEqual([]);
});
test('returns no ranges when the query does not occur', () => {
expect(findMatchRanges('hello world', 'nope')).toEqual([]);
});
test('finds all non-overlapping occurrences', () => {
expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([
{ start: 0, end: 3 },
{ start: 31, end: 34 },
]);
});
test('matches case-insensitively', () => {
expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([
{ start: 0, end: 5 },
{ start: 6, end: 11 },
{ start: 12, end: 17 },
]);
});
test('scans non-overlapping matches like standard find-in-page', () => {
expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]);
});
test('trims the query before matching', () => {
expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]);
});
test('handles a query longer than the text', () => {
expect(findMatchRanges('abc', 'abcdef')).toEqual([]);
});
});
@@ -0,0 +1,344 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { findMatchRanges } from './markdownPreviewFind';
/**
* In-preview text search for the rendered Markdown file preview.
*
* The preview renders as plain DOM (no iframe/shadow root), so browser-native
* find works on web but the Electron desktop shell has no find-in-page
* implementation at all, and CodeMirror's search only exists in edit mode.
* This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact
* search bar with match highlighting, navigation, and a live count, scoped to
* the preview container.
*
* The rendered DOM is owned by the markdown renderer (block-level morphdom
* reconciliation), so highlights are re-applied whenever the renderer mutates
* the container (theme or content changes) via a MutationObserver; mutations
* produced by this widget itself are ignored.
*/
const MARK_ATTR = 'data-md-find';
const CURRENT_MARK_ATTR = 'data-md-find-current';
const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground';
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground';
/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */
const SEARCH_DEBOUNCE_MS = 120;
const isMarkElement = (node: Node): boolean => {
return node instanceof Element && node.hasAttribute(MARK_ATTR);
};
const clearHighlights = (container: HTMLElement): void => {
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
parent.normalize();
});
};
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
clearHighlights(container);
const normalized = query.trim().toLowerCase();
if (!normalized) {
return [];
}
const marks: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) {
return NodeFilter.FILTER_REJECT;
}
// Skipping svg (mermaid) keeps the highlight pass from corrupting
// diagram rendering; script/style content is never visible anyway.
if (parent.closest('svg, script, style')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes: Text[] = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node instanceof Text) {
textNodes.push(node);
}
}
for (const node of textNodes) {
const text = node.nodeValue ?? '';
if (!text) {
continue;
}
const ranges = findMatchRanges(text, normalized);
if (ranges.length === 0) {
continue;
}
const parent = node.parentNode;
if (!parent) {
continue;
}
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, range.start)));
}
const mark = document.createElement('mark');
mark.setAttribute(MARK_ATTR, '');
mark.className = MARK_CLASS;
mark.textContent = text.slice(range.start, range.end);
fragment.appendChild(mark);
marks.push(mark);
cursor = range.end;
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
parent.replaceChild(fragment, node);
}
return marks;
};
type MarkdownPreviewSearchProps = {
/** The scrollable preview container whose rendered text is searched. */
containerRef: React.RefObject<HTMLDivElement | null>;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Bumped every time the find shortcut is pressed to re-focus the input. */
focusNonce: number;
/** Layout overrides for the floating bar (position, offsets). */
className?: string;
};
export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
containerRef,
open,
onOpenChange,
focusNonce,
className,
}) => {
const { t } = useI18n();
const [query, setQuery] = React.useState('');
const [total, setTotal] = React.useState(0);
const [index, setIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const marksRef = React.useRef<HTMLElement[]>([]);
const queryRef = React.useRef(query);
queryRef.current = query;
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Focus returns here when the bar closes, so Escape does not strand focus.
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const runSearch = React.useCallback((nextQuery: string) => {
const container = containerRef.current;
if (!container) {
marksRef.current = [];
setTotal(0);
setIndex(0);
return;
}
marksRef.current = applySearch(container, nextQuery);
setTotal(marksRef.current.length);
setIndex(0);
}, [containerRef]);
const scheduleSearch = React.useCallback((nextQuery: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
runSearch(nextQuery);
}, SEARCH_DEBOUNCE_MS);
}, [runSearch]);
React.useEffect(() => () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}, []);
const close = React.useCallback(() => {
onOpenChange(false);
const target = returnFocusRef.current;
returnFocusRef.current = null;
if (target?.isConnected) {
target.focus();
}
}, [onOpenChange]);
// Re-apply highlights when the renderer re-morphs the container (theme or
// content changes), ignoring mutations this widget produces itself. Only
// active while the bar is open; closing clears the highlights.
React.useEffect(() => {
const container = containerRef.current;
if (!open || !container) {
return;
}
const observer = new MutationObserver((records) => {
const fromUs = records.some((record) => {
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
return true;
}
return [...record.addedNodes].some((node) => isMarkElement(node));
});
if (fromUs) {
return;
}
runSearch(queryRef.current);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
clearHighlights(container);
};
}, [containerRef, open, runSearch]);
// Focus the input when the bar opens, remembering what to restore on close.
React.useEffect(() => {
if (!open) {
return;
}
const previous = document.activeElement;
if (previous instanceof HTMLElement && !returnFocusRef.current) {
returnFocusRef.current = previous;
}
inputRef.current?.focus();
}, [open]);
// Pressing the find shortcut again re-focuses and re-selects the query.
React.useEffect(() => {
if (open && focusNonce > 0) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [open, focusNonce]);
// Keep the current-match highlight and scroll it into view.
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => {
mark.removeAttribute(CURRENT_MARK_ATTR);
mark.className = MARK_CLASS;
});
if (total === 0) {
return;
}
const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)];
if (!current) {
return;
}
current.setAttribute(CURRENT_MARK_ATTR, '');
current.className = CURRENT_MARK_CLASS;
current.scrollIntoView({ block: 'nearest' });
}, [containerRef, index, total]);
const goToNext = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current + 1) % total));
}, [total]);
const goToPrevious = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total));
}, [total]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
goToPrevious();
} else {
goToNext();
}
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
}, [close, goToNext, goToPrevious]);
if (!open) {
return null;
}
return (
<div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}>
<Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
scheduleSearch(event.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={t('filesView.preview.find.placeholder')}
aria-label={t('filesView.preview.find.placeholder')}
className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56"
/>
<span
className="min-w-12 px-1 text-center typography-micro text-muted-foreground tabular-nums"
aria-live="polite"
aria-label={total > 0
? t('filesView.preview.find.countAria', { current: index + 1, total })
: undefined}
>
{query.trim() && total === 0
? t('filesView.preview.find.noMatches')
: total > 0
? `${index + 1}/${total}`
: ''}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToPrevious}
title={t('filesView.preview.find.previousAria')}
aria-label={t('filesView.preview.find.previousAria')}
disabled={total === 0}
>
<Icon name="arrow-up" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToNext}
title={t('filesView.preview.find.nextAria')}
aria-label={t('filesView.preview.find.nextAria')}
disabled={total === 0}
>
<Icon name="arrow-down" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={close}
title={t('filesView.preview.find.closeAria')}
aria-label={t('filesView.preview.find.closeAria')}
>
<Icon name="close" className="size-3.5" />
</Button>
</div>
);
};
@@ -0,0 +1,23 @@
/**
* Case-insensitive substring match ranges over a single text string, using
* the same non-overlapping `String.prototype.indexOf` scan semantics as
* standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]).
*/
export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => {
const normalized = query.trim().toLowerCase();
const ranges: Array<{ start: number; end: number }> = [];
if (!normalized) {
return ranges;
}
const lower = text.toLowerCase();
let cursor = 0;
while (true) {
const index = lower.indexOf(normalized, cursor);
if (index === -1) {
break;
}
ranges.push({ start: index, end: index + normalized.length });
cursor = index + normalized.length;
}
return ranges;
};