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:
Islam Nofl
2026-04-26 16:24:07 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 632e6cc97b
commit 4523e9c486
87 changed files with 1918 additions and 703 deletions
@@ -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,