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
@@ -738,10 +738,10 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
|
||||
/**
|
||||
* Inline reasoning text block — rendered as dimmed italic markdown.
|
||||
*/
|
||||
const InlineReasoningBlock: React.FC<{
|
||||
const InlineReasoningBlock = React.memo(({ activity, onContentChange }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
}> = ({ activity, onContentChange }) => {
|
||||
}) => {
|
||||
return (
|
||||
<ReasoningPart
|
||||
part={activity.part}
|
||||
@@ -749,15 +749,15 @@ const InlineReasoningBlock: React.FC<{
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline justification text block — rendered as normal assistant text between tools.
|
||||
*/
|
||||
const InlineJustificationBlock: React.FC<{
|
||||
const InlineJustificationBlock = React.memo(({ activity, onContentChange }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
}> = ({ activity, onContentChange }) => {
|
||||
}) => {
|
||||
return (
|
||||
<JustificationBlock
|
||||
part={activity.part}
|
||||
@@ -765,7 +765,7 @@ const InlineJustificationBlock: React.FC<{
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
parts,
|
||||
|
||||
@@ -104,7 +104,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
return;
|
||||
}
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, isExpanded, text]);
|
||||
}, [onContentChange, text]);
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return null;
|
||||
@@ -191,11 +191,11 @@ type ReasoningPartProps = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
||||
const ReasoningPart = React.memo(({
|
||||
part,
|
||||
onContentChange,
|
||||
messageId,
|
||||
}) => {
|
||||
}: ReasoningPartProps) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text || partWithText.content || '';
|
||||
@@ -225,7 +225,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||
|
||||
@@ -758,17 +758,8 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
style={syntaxTheme}
|
||||
language={outputLanguage}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
...toolDisplayStyles.getCollapsedStyles(),
|
||||
padding: 0,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{renderedOutput}
|
||||
@@ -1233,6 +1224,19 @@ const TOOL_DIFF_METRICS = {
|
||||
fileGap: 0,
|
||||
};
|
||||
|
||||
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
|
||||
...toolDisplayStyles.getCollapsedStyles(),
|
||||
padding: 0,
|
||||
overflow: 'visible',
|
||||
};
|
||||
|
||||
const CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent' } };
|
||||
|
||||
const TOOL_ERROR_ICON_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
||||
const TOOL_NORMAL_ICON_STYLE: React.CSSProperties = { color: 'var(--tools-icon)' };
|
||||
const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
||||
const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' };
|
||||
|
||||
type DiffPatchEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -1370,24 +1374,29 @@ const getDiffPatchEntries = (
|
||||
};
|
||||
|
||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
||||
const options = React.useMemo(
|
||||
() => ({
|
||||
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
|
||||
diffIndicators: 'none' as const,
|
||||
hunkSeparators: 'line-info-basic' as const,
|
||||
lineDiffType: 'none' as const,
|
||||
disableFileHeader: true,
|
||||
maxLineDiffLength: 1000,
|
||||
expansionLineCount: 20,
|
||||
overflow: 'wrap' as const,
|
||||
theme: pierreTheme,
|
||||
themeType: pierreThemeType,
|
||||
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
||||
}),
|
||||
[diffViewMode, pierreTheme, pierreThemeType]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="typography-code px-1 pb-1 pt-0">
|
||||
<PatchDiff
|
||||
patch={diff}
|
||||
metrics={TOOL_DIFF_METRICS}
|
||||
options={{
|
||||
diffStyle: diffViewMode === 'side-by-side' ? 'split' : 'unified',
|
||||
diffIndicators: 'none',
|
||||
hunkSeparators: 'line-info-basic',
|
||||
lineDiffType: 'none',
|
||||
disableFileHeader: true,
|
||||
maxLineDiffLength: 1000,
|
||||
expansionLineCount: 20,
|
||||
overflow: 'wrap',
|
||||
theme: pierreTheme,
|
||||
themeType: pierreThemeType,
|
||||
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
||||
}}
|
||||
options={options}
|
||||
className="block w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1797,14 +1806,17 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
|
||||
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
||||
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldNotifyStructuralChange) {
|
||||
return;
|
||||
}
|
||||
if (typeof isExpanded === 'boolean') {
|
||||
onContentChange?.('structural');
|
||||
onContentChangeRef.current?.('structural');
|
||||
}
|
||||
}, [isExpanded, onContentChange, shouldNotifyStructuralChange]);
|
||||
}, [isExpanded, shouldNotifyStructuralChange]);
|
||||
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
@@ -2425,6 +2437,9 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
handleMainClick(event);
|
||||
};
|
||||
|
||||
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||
|
||||
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
|
||||
return null;
|
||||
}
|
||||
@@ -2455,7 +2470,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }}
|
||||
style={iconStyle}
|
||||
>
|
||||
{getToolIcon(normalizedPartTool || part.tool)}
|
||||
</div>
|
||||
@@ -2476,7 +2491,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
className="typography-meta font-medium flex-shrink-0"
|
||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
||||
style={titleStyle}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
@@ -2490,7 +2505,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
className="typography-meta font-medium flex-shrink-0"
|
||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
||||
style={titleStyle}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { AgentMentionInfo } from '../types';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { RiArrowUpSLine } from '@remixicon/react';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -33,14 +34,12 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const CLAMP_LINES = 2;
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
|
||||
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -67,13 +66,6 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
if (!isExpanded) {
|
||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(el);
|
||||
const lineHeight = parseFloat(styles.lineHeight);
|
||||
const fontSize = parseFloat(styles.fontSize);
|
||||
const fallbackLineHeight = isFinite(fontSize) ? fontSize * 1.4 : 20;
|
||||
const resolvedLineHeight = isFinite(lineHeight) ? lineHeight : fallbackLineHeight;
|
||||
setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES)));
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
@@ -84,7 +76,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [textContent, isExpanded]);
|
||||
|
||||
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleClick = React.useCallback(() => {
|
||||
const element = textRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -94,18 +86,15 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
if (isTruncated) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
return;
|
||||
if (!isExpanded && isTruncated) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasActiveSelectionInElement, isExpanded, isTruncated]);
|
||||
|
||||
const clickY = event.clientY - element.getBoundingClientRect().top;
|
||||
if (clickY <= collapseZoneHeight) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
|
||||
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}, []);
|
||||
|
||||
const processedMarkdownContent = React.useMemo(() => {
|
||||
let content = textContent;
|
||||
@@ -153,9 +142,20 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
|
||||
return (
|
||||
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
||||
{isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
className="absolute top-0 right-0 flex items-center justify-center rounded-sm p-0.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] transition-colors"
|
||||
aria-label="Collapse"
|
||||
>
|
||||
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"break-words font-sans typography-markdown",
|
||||
isExpanded && "pb-3",
|
||||
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
||||
!isExpanded && "line-clamp-2",
|
||||
isTruncated && !isExpanded && "cursor-pointer"
|
||||
|
||||
@@ -61,6 +61,10 @@ export function WorkingPlaceholder({
|
||||
}: WorkingPlaceholderProps) {
|
||||
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
||||
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
||||
const displayedTextRef = React.useRef(displayedText);
|
||||
const displayedPermissionRef = React.useRef(displayedPermission);
|
||||
displayedTextRef.current = displayedText;
|
||||
displayedPermissionRef.current = displayedPermission;
|
||||
|
||||
const statusShownAtRef = React.useRef<number>(0);
|
||||
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
|
||||
@@ -141,12 +145,12 @@ export function WorkingPlaceholder({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayedText) {
|
||||
if (!displayedTextRef.current) {
|
||||
showStatus(incomingText, incomingPermission);
|
||||
return;
|
||||
}
|
||||
|
||||
if (incomingText === displayedText && incomingPermission === displayedPermission) {
|
||||
if (incomingText === displayedTextRef.current && incomingPermission === displayedPermissionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,8 +173,6 @@ export function WorkingPlaceholder({
|
||||
isGenericStatus,
|
||||
isWaitingForPermission,
|
||||
retryInfo,
|
||||
displayedText,
|
||||
displayedPermission,
|
||||
clearTimers,
|
||||
showStatus,
|
||||
scheduleQueueProcess,
|
||||
|
||||
Reference in New Issue
Block a user