perf: reduce re-renders, fix mobile keyboard handling, add chunk load recovery, and improve PATH management (#1028)
* fix: exclude file content from reverted prompt text Revert and fork now restore only the user's original prompt, not server-injected file content Uses existing isSyntheticPart helper for type-safe filtering * fix: keep scrollbar visible when hovering over thumb * fix: prevent ESC abort from triggering when terminal is focused * fix: pass directory to permission/question reply calls so approvals actually resolve * fix: default model selection not responding after Base UI migration * fix: prevent modal content from shifting and clipping footer buttons * fix: improve session switching performance and add sub-agent export with prompt collapse Defer viewport anchor saving to eliminate ~800ms UI freeze when switching sessions Add export dialog to include sub-agent tasks recursively in markdown export Add collapse chevron button for expanded user prompts in sticky header * fix: resolve sidebar scroll and TDZ crash in session sidebar * perf: reduce CPU overhead and re-renders across chat, layout, and settings * fix: position collapse button at top of message and prevent ESC abort in terminal * fix: position collapse button at top and add padding only when expanded * refactor: extract shared PATH utilities and mobile keyboard hook * refactor: import shared path-utils in electron, use module-level style constants - Electron now imports pathLooksUserConfigured/mergePathValues from shared path-utils.js instead of inline duplication - ToolPart collapsedCustomStyle moved from useMemo([]) to module const * fix: resolve remaining merge conflicts and type errors - Remove duplicate variable declarations in SessionNodeItem - Remove orphaned export callback body from conflict resolution - Fix HelpDialog description -> descriptionKey (i18n rename) * fix: resolve type-check and lint errors in session-actions.test.ts - Added missing bun:test type declarations (beforeEach, mock, mock.module) - Removed unused State import - Replaced 'as any' casts with proper OpencodeClient and ChildStoreManager types - Added eslint-disable for unused _ parameter in mock function * fix PR 1028 export and PATH edge cases * fix startup retry exhaustion state * remove opencode package lock change * fix sub-session rename cancellation --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
632e6cc97b
commit
4523e9c486
@@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useMobileKeyboardManager } from '@/hooks/useMobileKeyboardManager';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
@@ -353,254 +354,7 @@ export const MainLayout: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
let stickyKeyboardInset = 0;
|
||||
let ignoreOpenUntilZero = false;
|
||||
let previousHeight = 0;
|
||||
let maxObservedLayoutHeight = 0;
|
||||
let previousOrientation = '';
|
||||
let keyboardAvoidTarget: HTMLElement | null = null;
|
||||
|
||||
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
|
||||
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
|
||||
const isAndroid = /Android/i.test(userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(userAgent);
|
||||
|
||||
const clearKeyboardAvoidTarget = () => {
|
||||
if (!keyboardAvoidTarget) {
|
||||
return;
|
||||
}
|
||||
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
|
||||
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
|
||||
keyboardAvoidTarget = null;
|
||||
};
|
||||
|
||||
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id');
|
||||
if (explicitTargetId) {
|
||||
const explicitTarget = document.getElementById(explicitTargetId);
|
||||
if (explicitTarget instanceof HTMLElement) {
|
||||
return explicitTarget;
|
||||
}
|
||||
}
|
||||
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
||||
if (markedTarget) {
|
||||
// data-keyboard-avoid="none" opts out of translateY avoidance entirely.
|
||||
// Used by components with their own scroll (e.g. CodeMirror).
|
||||
if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') {
|
||||
return null;
|
||||
}
|
||||
return markedTarget;
|
||||
}
|
||||
if (active.classList.contains('overlay-scrollbar-container')) {
|
||||
const parent = active.parentElement;
|
||||
if (parent instanceof HTMLElement) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
return active;
|
||||
};
|
||||
|
||||
const forceKeyboardClosed = () => {
|
||||
stickyKeyboardInset = 0;
|
||||
ignoreOpenUntilZero = true;
|
||||
root.style.setProperty('--oc-keyboard-inset', '0px');
|
||||
setKeyboardOpen(false);
|
||||
};
|
||||
|
||||
let rafId = 0;
|
||||
|
||||
const updateVisualViewport = () => {
|
||||
const viewport = window.visualViewport;
|
||||
|
||||
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
|
||||
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
|
||||
const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait';
|
||||
|
||||
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
|
||||
root.style.setProperty('--oc-visual-viewport-height', `${height}px`);
|
||||
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const tagName = active?.tagName;
|
||||
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
||||
const isTextTarget = isInput || Boolean(active?.isContentEditable);
|
||||
|
||||
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
||||
if (previousOrientation !== orientation) {
|
||||
previousOrientation = orientation;
|
||||
maxObservedLayoutHeight = layoutHeight;
|
||||
} else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) {
|
||||
maxObservedLayoutHeight = layoutHeight;
|
||||
}
|
||||
const viewportSum = height + offsetTop;
|
||||
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
||||
const rawAndroidResizeInset = isAndroid
|
||||
? Math.max(0, maxObservedLayoutHeight - layoutHeight)
|
||||
: 0;
|
||||
|
||||
const openThreshold = isTextTarget ? 120 : 180;
|
||||
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
|
||||
const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold
|
||||
? rawAndroidResizeInset
|
||||
: 0;
|
||||
const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset);
|
||||
|
||||
if (ignoreOpenUntilZero) {
|
||||
if (effectiveMeasuredInset === 0) {
|
||||
ignoreOpenUntilZero = false;
|
||||
}
|
||||
stickyKeyboardInset = 0;
|
||||
} else if (stickyKeyboardInset === 0) {
|
||||
if (effectiveMeasuredInset > 0 && isTextTarget) {
|
||||
stickyKeyboardInset = effectiveMeasuredInset;
|
||||
setKeyboardOpen(true);
|
||||
}
|
||||
} else {
|
||||
const closingByHeight = !isTextTarget && height > previousHeight + 6;
|
||||
|
||||
if (effectiveMeasuredInset === 0) {
|
||||
stickyKeyboardInset = 0;
|
||||
setKeyboardOpen(false);
|
||||
} else if (closingByHeight) {
|
||||
forceKeyboardClosed();
|
||||
} else if (effectiveMeasuredInset > 0 && isTextTarget) {
|
||||
stickyKeyboardInset = effectiveMeasuredInset;
|
||||
setKeyboardOpen(true);
|
||||
} else if (effectiveMeasuredInset > stickyKeyboardInset) {
|
||||
stickyKeyboardInset = effectiveMeasuredInset;
|
||||
setKeyboardOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
|
||||
previousHeight = height;
|
||||
|
||||
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
|
||||
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
|
||||
|
||||
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
|
||||
|
||||
if (!isMobile || !avoidTarget || !active) {
|
||||
clearKeyboardAvoidTarget();
|
||||
} else {
|
||||
if (avoidTarget !== keyboardAvoidTarget) {
|
||||
clearKeyboardAvoidTarget();
|
||||
keyboardAvoidTarget = avoidTarget;
|
||||
}
|
||||
const viewportBottom = offsetTop + height;
|
||||
const rect = active.getBoundingClientRect();
|
||||
const overlap = rect.bottom - viewportBottom;
|
||||
const clearance = 8;
|
||||
const keyboardInset = Math.max(stickyKeyboardInset, effectiveMeasuredInset);
|
||||
const avoidOffset = overlap > clearance && keyboardInset > 0
|
||||
? Math.min(overlap, keyboardInset)
|
||||
: 0;
|
||||
const target = keyboardAvoidTarget;
|
||||
if (target) {
|
||||
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
|
||||
target.setAttribute('data-keyboard-avoid-active', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile && isTextTarget) {
|
||||
const scroller = document.scrollingElement;
|
||||
if (scroller && scroller.scrollTop !== 0) {
|
||||
scroller.scrollTop = 0;
|
||||
}
|
||||
if (window.scrollY !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleVisualViewportUpdate = () => {
|
||||
if (rafId) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = 0;
|
||||
updateVisualViewport();
|
||||
});
|
||||
};
|
||||
|
||||
updateVisualViewport();
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||
viewport?.addEventListener('scroll', scheduleVisualViewportUpdate);
|
||||
window.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||
window.addEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||
const isTextInputTarget = (element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const tagName = element.tagName;
|
||||
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
||||
return isInput || element.isContentEditable;
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (isTextInputTarget(target)) {
|
||||
ignoreOpenUntilZero = false;
|
||||
}
|
||||
scheduleVisualViewportUpdate();
|
||||
};
|
||||
document.addEventListener('focusin', handleFocusIn, true);
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!isTextInputTarget(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const related = event.relatedTarget as HTMLElement | null;
|
||||
if (isTextInputTarget(related)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (isTextInputTarget(document.activeElement as HTMLElement | null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentViewport = window.visualViewport;
|
||||
const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight;
|
||||
const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0;
|
||||
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
||||
const viewportSum = height + offsetTop;
|
||||
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
||||
|
||||
if (rawInset > 0) {
|
||||
updateVisualViewport();
|
||||
return;
|
||||
}
|
||||
|
||||
forceKeyboardClosed();
|
||||
updateVisualViewport();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('focusout', handleFocusOut, true);
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
viewport?.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||
viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate);
|
||||
window.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||
window.removeEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||
document.removeEventListener('focusin', handleFocusIn, true);
|
||||
document.removeEventListener('focusout', handleFocusOut, true);
|
||||
clearKeyboardAvoidTarget();
|
||||
};
|
||||
}, [isMobile]);
|
||||
useMobileKeyboardManager(isMobile);
|
||||
|
||||
const secondaryView = React.useMemo(() => {
|
||||
switch (activeMainTab) {
|
||||
|
||||
@@ -157,7 +157,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
||||
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,6 +374,11 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
const canDelete = Boolean(files.delete);
|
||||
const canReveal = Boolean(files.revealPath);
|
||||
|
||||
const fileRowPermissions = React.useMemo(
|
||||
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
|
||||
[canRename, canCreateFile, canCreateFolder, canDelete, canReveal]
|
||||
);
|
||||
|
||||
const handleRevealPath = React.useCallback((targetPath: string) => {
|
||||
if (!files.revealPath) return;
|
||||
void files.revealPath(targetPath).catch(() => {
|
||||
@@ -787,7 +792,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
isActive={isActive}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
||||
permissions={fileRowPermissions}
|
||||
downloadFile={files.downloadFile}
|
||||
contextMenuPath={contextMenuPath}
|
||||
setContextMenuPath={setContextMenuPath}
|
||||
|
||||
Reference in New Issue
Block a user