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
@@ -75,7 +75,7 @@ class ChatErrorBoundaryView extends React.Component<ChatErrorBoundaryViewProps,
|
||||
{this.state.error && (
|
||||
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
|
||||
<pre className="mt-2 overflow-x-auto">
|
||||
<pre className="mt-2 max-h-48 overflow-auto">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -814,11 +814,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const sendableAttachedFiles = attachedFiles;
|
||||
|
||||
const knownAgentNames = React.useMemo(
|
||||
() => new Set(agents.map((agent) => agent.name.toLowerCase())),
|
||||
[agents]
|
||||
);
|
||||
const knownAgentNamesRef = React.useRef(knownAgentNames);
|
||||
knownAgentNamesRef.current = knownAgentNames;
|
||||
|
||||
const hasInlineMentionForHighlight = React.useMemo(() => {
|
||||
if (!message || !message.includes('@') || inputMode === 'shell') {
|
||||
return false;
|
||||
}
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mentionRegex.exec(message)) !== null) {
|
||||
@@ -839,7 +845,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [agents, inputMode, message]);
|
||||
}, [inputMode, message, knownAgentNames]);
|
||||
|
||||
const highlightedComposerContent = React.useMemo(() => {
|
||||
if (!hasInlineMentionForHighlight) {
|
||||
@@ -847,7 +853,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -880,7 +885,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [agents, hasInlineMentionForHighlight, message]);
|
||||
}, [hasInlineMentionForHighlight, message, knownAgentNames]);
|
||||
|
||||
const sanitizeAttachmentsForSend = React.useCallback(
|
||||
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
||||
@@ -900,7 +905,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const seenPaths = new Set<string>();
|
||||
const attachments: AttachedFile[] = [];
|
||||
|
||||
@@ -923,7 +927,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownAgentNames.has(mentionPath.toLowerCase())) {
|
||||
if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -970,7 +974,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sanitizedText: rawText,
|
||||
attachments,
|
||||
};
|
||||
}, [agents, chatSearchDirectory]);
|
||||
}, [chatSearchDirectory]);
|
||||
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
@@ -1670,7 +1674,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const selectionStart = textarea?.selectionStart ?? message.length;
|
||||
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
||||
const hasCollapsedSelection = selectionStart === selectionEnd;
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
|
||||
if (hasCollapsedSelection) {
|
||||
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
||||
@@ -1688,7 +1691,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const token = message.slice(tokenStart, tokenEnd);
|
||||
const mentionContent = token.slice(1);
|
||||
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
||||
&& !knownAgentNames.has(mentionContent.toLowerCase())
|
||||
&& !knownAgentNamesRef.current.has(mentionContent.toLowerCase())
|
||||
&& isConfirmedFilePath(mentionContent);
|
||||
|
||||
if (looksLikeFileMention) {
|
||||
|
||||
@@ -56,8 +56,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
|
||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
|
||||
const { skills, loadSkills: refreshSkills } = useSkillsStore();
|
||||
const commandsWithMetadata = useCommandsStore((s) => s.commands);
|
||||
const refreshCommands = useCommandsStore((s) => s.loadCommands);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -4,6 +4,23 @@ import { cn } from '@/lib/utils';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { parseDiffToUnified } from './message/toolRenderers';
|
||||
|
||||
const DIFF_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
};
|
||||
|
||||
const DIFF_CODE_TAG_PROPS = {
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties,
|
||||
};
|
||||
|
||||
interface DiffPreviewProps {
|
||||
diff: string;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
@@ -46,21 +63,8 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, fil
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||
}}
|
||||
customStyle={DIFF_CUSTOM_STYLE}
|
||||
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -104,21 +108,8 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||
}}
|
||||
customStyle={DIFF_CUSTOM_STYLE}
|
||||
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||
>
|
||||
{line || ' '}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
@@ -522,7 +522,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={file.url || `${fileName}-${index}`}
|
||||
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
@@ -542,7 +542,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -575,7 +575,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -646,7 +646,7 @@ export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryPr
|
||||
<div className={cn("grid gap-2", getGridCols())}>
|
||||
{urls.map((url, index) => (
|
||||
<button
|
||||
key={index}
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => onShowPopup?.({
|
||||
open: true,
|
||||
|
||||
@@ -506,7 +506,7 @@ interface TurnBlockProps {
|
||||
activeStreamingPhase?: StreamPhase | null;
|
||||
}
|
||||
|
||||
const TurnBlock: React.FC<TurnBlockProps> = ({
|
||||
const TurnBlock = React.memo(({
|
||||
turn,
|
||||
isLastTurn,
|
||||
sessionIsWorking,
|
||||
@@ -522,7 +522,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
|
||||
onUserAnimationConsumed,
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
}) => {
|
||||
}: TurnBlockProps) => {
|
||||
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||
const handleToggleTurnGroup = React.useCallback(() => {
|
||||
onToggleTurnGroup(turn.turnId);
|
||||
@@ -783,7 +783,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
|
||||
return (
|
||||
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
TurnBlock.displayName = 'TurnBlock';
|
||||
|
||||
@@ -800,7 +800,7 @@ interface UngroupedMessageRowProps {
|
||||
activeStreamingPhase?: StreamPhase | null;
|
||||
}
|
||||
|
||||
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
||||
const UngroupedMessageRow = React.memo(({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
@@ -811,7 +811,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
||||
onUserAnimationConsumed,
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
}) => {
|
||||
}: UngroupedMessageRowProps) => {
|
||||
return (
|
||||
<MessageRow
|
||||
message={message}
|
||||
@@ -826,7 +826,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
||||
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
|
||||
|
||||
@@ -859,7 +859,7 @@ const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | unde
|
||||
return turn.assistantMessages.some((assistant) => assistant.info.id === messageId);
|
||||
};
|
||||
|
||||
const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
||||
const MessageListEntry = React.memo(({
|
||||
entry,
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
@@ -874,7 +874,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
||||
onUserAnimationConsumed,
|
||||
activeStreamingMessageId,
|
||||
activeStreamingPhase,
|
||||
}) => {
|
||||
}: MessageListEntryProps) => {
|
||||
if (entry.kind === 'ungrouped') {
|
||||
return (
|
||||
<UngroupedMessageRow
|
||||
@@ -911,7 +911,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MessageListEntry.displayName = 'MessageListEntry';
|
||||
|
||||
|
||||
@@ -119,9 +119,10 @@ function useSessionGrouping(
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const processedSessions = React.useMemo(() => {
|
||||
const sessionIds = new Set(sessions.map((s) => s.id));
|
||||
const topLevel = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string }).parentID;
|
||||
return !parentID || !new Set(sessions.map((s) => s.id)).has(parentID);
|
||||
return !parentID || !sessionIds.has(parentID);
|
||||
});
|
||||
|
||||
const running: SessionWithStatus[] = [];
|
||||
|
||||
@@ -2057,20 +2057,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Filter models based on search query
|
||||
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
||||
if (!query.trim()) return true;
|
||||
return (
|
||||
matchesModelSearch(modelName, query) ||
|
||||
matchesModelSearch(providerName, query)
|
||||
);
|
||||
};
|
||||
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
|
||||
|
||||
const modelSelectorData = React.useMemo(() => {
|
||||
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
||||
if (!query.trim()) return true;
|
||||
return (
|
||||
matchesModelSearch(modelName, query) ||
|
||||
matchesModelSearch(providerName, query)
|
||||
);
|
||||
};
|
||||
|
||||
const renderModelSelector = () => {
|
||||
const normalizedDesktopQuery = desktopModelQuery.trim();
|
||||
const forceExpandProviders = normalizedDesktopQuery.length > 0;
|
||||
|
||||
// Filter favorites
|
||||
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
||||
const provider = providers.find(p => p.id === providerID);
|
||||
const providerName = provider?.name || providerID;
|
||||
@@ -2078,7 +2078,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return filterByQuery(modelName, providerName, desktopModelQuery);
|
||||
});
|
||||
|
||||
// Filter recents
|
||||
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
|
||||
const provider = providers.find(p => p.id === providerID);
|
||||
const providerName = provider?.name || providerID;
|
||||
@@ -2086,7 +2085,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return filterByQuery(modelName, providerName, desktopModelQuery);
|
||||
});
|
||||
|
||||
// Filter providers and their models
|
||||
const filteredProviders = visibleProviders
|
||||
.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
@@ -2115,8 +2113,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
filteredRecents.length > 0 ||
|
||||
filteredProviders.length > 0;
|
||||
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
|
||||
const flatModelList: FlatModelItem[] = [];
|
||||
|
||||
filteredFavorites.forEach(({ model, providerID, modelID }) => {
|
||||
@@ -2131,6 +2127,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
return { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders };
|
||||
}, [desktopModelQuery, favoriteModelsList, recentModelsList, visibleProviders, providers, collapsedProviderSet, matchesModelSearch]);
|
||||
|
||||
const renderModelSelector = () => {
|
||||
const { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders } = modelSelectorData;
|
||||
|
||||
const totalItems = flatModelList.length;
|
||||
|
||||
// Check if currently highlighted model supports thinking variants
|
||||
|
||||
@@ -12,6 +12,36 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'visible',
|
||||
};
|
||||
|
||||
const PERMISSION_BASH_CODE_TAG_PROPS = {
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PERMISSION_JSON_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
};
|
||||
|
||||
interface PermissionCardProps {
|
||||
permission: PermissionRequest;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
@@ -66,7 +96,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
const { t } = useI18n();
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const respondToPermission = sessionActions.respondToPermission;;
|
||||
const respondToPermission = sessionActions.respondToPermission;
|
||||
const sessions = useSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const isFromSubagent = React.useMemo(() => {
|
||||
@@ -84,7 +114,9 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch { /* ignored */ } finally {
|
||||
} catch (error) {
|
||||
console.error('[PermissionCard] Failed to respond to permission:', error);
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
@@ -140,25 +172,8 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
language="bash"
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}}
|
||||
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
|
||||
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{command}
|
||||
@@ -235,14 +250,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem'
|
||||
}}
|
||||
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{JSON.stringify(headers, null, 2)}
|
||||
@@ -257,14 +265,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
<SyntaxHighlighter
|
||||
language={typeof body === 'object' ? 'json' : 'text'}
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem'
|
||||
}}
|
||||
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
const { t } = useI18n();
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const respondToPermission = sessionActions.respondToPermission;;
|
||||
const respondToPermission = sessionActions.respondToPermission;
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
@@ -26,7 +26,9 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch { /* ignored */ } finally {
|
||||
} catch (error) {
|
||||
console.error('[PermissionRequest] Failed to respond to permission:', error);
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,7 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const { skills, loadSkills } = useSkillsStore();
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
||||
|
||||
@@ -5,6 +5,12 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||
|
||||
const STATUS_CHIP_STYLE = {
|
||||
height: '28px',
|
||||
maxHeight: '28px',
|
||||
minHeight: '28px',
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
@@ -42,11 +48,7 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
|
||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
height: '28px',
|
||||
maxHeight: '28px',
|
||||
minHeight: '28px',
|
||||
}}
|
||||
style={STATUS_CHIP_STYLE}
|
||||
title={fullLabel}
|
||||
>
|
||||
<span className="shrink-0">{agentLabel}</span>
|
||||
|
||||
@@ -24,6 +24,8 @@ import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||
|
||||
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||
in_progress: {
|
||||
textClassName: "text-foreground",
|
||||
@@ -292,7 +294,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={{ containerType: "inline-size", containerName: "status-row" }}>
|
||||
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
|
||||
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: Abort status or Working placeholder or leftAccessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
|
||||
|
||||
@@ -120,41 +120,15 @@ export const useChatTimelineController = ({
|
||||
|
||||
const historySignalsRef = React.useRef(historySignals);
|
||||
|
||||
React.useEffect(() => {
|
||||
turnModelRef.current = turnWindowModel;
|
||||
}, [turnWindowModel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
turnStartRef.current = turnStart;
|
||||
}, [turnStart]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isPinnedRef.current = isPinned;
|
||||
}, [isPinned]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isLoadingOlderRef.current = isLoadingOlder;
|
||||
}, [isLoadingOlder]);
|
||||
|
||||
React.useEffect(() => {
|
||||
pendingRevealWorkRef.current = pendingRevealWork;
|
||||
}, [pendingRevealWork]);
|
||||
|
||||
React.useEffect(() => {
|
||||
historySignalsRef.current = historySignals;
|
||||
}, [historySignals]);
|
||||
|
||||
React.useEffect(() => {
|
||||
sessionIdRef.current = sessionId;
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
React.useEffect(() => {
|
||||
historyMetaRef.current = historyMeta;
|
||||
}, [historyMeta]);
|
||||
turnModelRef.current = turnWindowModel;
|
||||
turnStartRef.current = turnStart;
|
||||
isPinnedRef.current = isPinned;
|
||||
isLoadingOlderRef.current = isLoadingOlder;
|
||||
pendingRevealWorkRef.current = pendingRevealWork;
|
||||
historySignalsRef.current = historySignals;
|
||||
sessionIdRef.current = sessionId;
|
||||
messagesRef.current = messages;
|
||||
historyMetaRef.current = historyMeta;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (initializedSessionRef.current === sessionId) {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const useStreamingTextThrottle = ({
|
||||
}: UseStreamingTextThrottleInput): string => {
|
||||
const [throttledText, setThrottledText] = React.useState(text);
|
||||
const latestTextRef = React.useRef(text);
|
||||
const throttledTextRef = React.useRef(throttledText);
|
||||
|
||||
const stateRef = React.useRef<StreamingThrottleState>({
|
||||
timer: null,
|
||||
@@ -47,6 +48,10 @@ export const useStreamingTextThrottle = ({
|
||||
latestTextRef.current = text;
|
||||
}, [text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
throttledTextRef.current = throttledText;
|
||||
}, [throttledText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const state = stateRef.current;
|
||||
clearTimer(state);
|
||||
@@ -58,7 +63,8 @@ export const useStreamingTextThrottle = ({
|
||||
React.useEffect(() => {
|
||||
const state = stateRef.current;
|
||||
state.pendingText = text;
|
||||
const stableText = isStreaming && throttledText.length > text.length ? throttledText : text;
|
||||
const currentThrottled = throttledTextRef.current;
|
||||
const stableText = isStreaming && currentThrottled.length > text.length ? currentThrottled : text;
|
||||
|
||||
if (!isStreaming) {
|
||||
clearTimer(state);
|
||||
@@ -92,7 +98,7 @@ export const useStreamingTextThrottle = ({
|
||||
return () => {
|
||||
clearTimer(state);
|
||||
};
|
||||
}, [isStreaming, text, throttleMs, throttledText]);
|
||||
}, [isStreaming, text, throttleMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const state = stateRef.current;
|
||||
|
||||
@@ -44,6 +44,9 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -320,7 +323,7 @@ const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
|
||||
revealedToolIdsByMessage.set(messageId, new Set(value));
|
||||
};
|
||||
|
||||
const UserMessageBody: React.FC<{
|
||||
const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
|
||||
messageId: string;
|
||||
parts: Part[];
|
||||
isMobile: boolean;
|
||||
@@ -334,7 +337,7 @@ const UserMessageBody: React.FC<{
|
||||
onFork?: () => void;
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -519,7 +522,7 @@ const UserMessageBody: React.FC<{
|
||||
return (
|
||||
<div
|
||||
className="relative w-full group/message"
|
||||
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
||||
style={CONTAIN_LAYOUT_STYLE}
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
<div
|
||||
@@ -572,9 +575,9 @@ const UserMessageBody: React.FC<{
|
||||
{actionsBlock}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
const AssistantMessageBody = React.memo(({
|
||||
sessionId,
|
||||
messageId,
|
||||
parts,
|
||||
@@ -599,7 +602,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
showReasoningTraces = false,
|
||||
turnGroupingContext,
|
||||
errorMessage,
|
||||
}) => {
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t } = useI18n();
|
||||
const streamPhase = _streamPhase;
|
||||
void _allowAnimation;
|
||||
@@ -1564,13 +1567,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
className={cn(
|
||||
'relative w-full group/message'
|
||||
)}
|
||||
style={{
|
||||
contain: 'layout',
|
||||
transform: 'translateZ(0)',
|
||||
}}
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
<TextSelectionMenu containerRef={messageContentRef} />
|
||||
style={CONTAIN_LAYOUT_STYLE}
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
<TextSelectionMenu containerRef={messageContentRef} />
|
||||
<SaveProjectPlanDialog
|
||||
open={isPlanDialogOpen}
|
||||
onOpenChange={setIsPlanDialogOpen}
|
||||
@@ -1601,7 +1601,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
{shouldShowFooter && (
|
||||
<div
|
||||
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
|
||||
style={{ containerType: 'inline-size', containerName: 'message-footer' }}
|
||||
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{footerButtons}
|
||||
@@ -1642,9 +1642,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
||||
const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
@@ -1667,6 +1667,6 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
||||
}
|
||||
|
||||
return <AssistantMessageBody {...props} />;
|
||||
};
|
||||
});
|
||||
|
||||
export default MessageBody;
|
||||
|
||||
@@ -211,7 +211,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -351,7 +351,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!selection || !container) {
|
||||
if (!isDragging) {
|
||||
if (!isDraggingRef.current) {
|
||||
hideMenu();
|
||||
}
|
||||
return;
|
||||
@@ -361,7 +361,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
// Only show if we have text and the selection is within our container
|
||||
if (!text) {
|
||||
if (!isDragging) {
|
||||
if (!isDraggingRef.current) {
|
||||
hideMenu();
|
||||
}
|
||||
return;
|
||||
@@ -371,7 +371,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
if (!isDragging) {
|
||||
if (!isDraggingRef.current) {
|
||||
hideMenu();
|
||||
}
|
||||
return;
|
||||
@@ -388,10 +388,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
};
|
||||
|
||||
// Only show menu if we're not currently dragging
|
||||
if (!isDragging) {
|
||||
if (!isDraggingRef.current) {
|
||||
showMenu();
|
||||
}
|
||||
}, [containerRef, hideMenu, showMenu, isDragging]);
|
||||
}, [containerRef, hideMenu, showMenu]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -399,13 +399,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
// Track when dragging starts
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
isDraggingRef.current = true;
|
||||
hideMenu();
|
||||
};
|
||||
|
||||
// Track when dragging stops
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
// Check if we have a pending selection to show
|
||||
if (pendingSelectionRef.current) {
|
||||
// Small delay to ensure selection is finalized
|
||||
|
||||
@@ -95,6 +95,10 @@ const MERMAID_DIALOG_HEADER_HEIGHT = 40;
|
||||
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
|
||||
const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||
|
||||
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||
|
||||
type PierreThemeConfig = {
|
||||
theme: { light: string; dark: string };
|
||||
themeType: 'light' | 'dark';
|
||||
@@ -966,12 +970,7 @@ const MermaidPreviewDialog: React.FC<{
|
||||
variant="tool"
|
||||
allowMermaidWheelZoom
|
||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||
mermaidControls={{
|
||||
download: false,
|
||||
copy: false,
|
||||
fullscreen: false,
|
||||
panZoom: true,
|
||||
}}
|
||||
mermaidControls={MERMAID_CONTROLS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1059,7 +1058,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('command')!}
|
||||
@@ -1129,7 +1128,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -1184,7 +1183,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -1215,7 +1214,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -243,6 +243,8 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean
|
||||
}
|
||||
};
|
||||
|
||||
const GREP_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 };
|
||||
|
||||
export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
@@ -287,7 +289,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
||||
}
|
||||
return (
|
||||
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={GREP_DOT_STYLE} />
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
{match.lineNum && (
|
||||
<span className="text-muted-foreground font-mono whitespace-nowrap">
|
||||
@@ -312,6 +314,8 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
||||
}
|
||||
};
|
||||
|
||||
const GLOB_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 };
|
||||
|
||||
export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const paths = output.trim().split('\n').filter(Boolean);
|
||||
@@ -350,7 +354,7 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: {
|
||||
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||
{groups[dir].sort().map((filename) => (
|
||||
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={GLOB_DOT_STYLE} />
|
||||
<span className="text-foreground font-mono truncate">{filename}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -388,12 +392,11 @@ export const renderTodoOutput = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const todosByStatus = {
|
||||
in_progress: todos.filter((t) => t.status === 'in_progress'),
|
||||
pending: todos.filter((t) => t.status === 'pending'),
|
||||
completed: todos.filter((t) => t.status === 'completed'),
|
||||
cancelled: todos.filter((t) => t.status === 'cancelled'),
|
||||
};
|
||||
const todosByStatus = todos.reduce((acc, t) => {
|
||||
const status = t.status as keyof typeof acc;
|
||||
if (status in acc) acc[status].push(t);
|
||||
return acc;
|
||||
}, { in_progress: [] as Todo[], pending: [] as Todo[], completed: [] as Todo[], cancelled: [] as Todo[] });
|
||||
|
||||
const getPriorityDot = (priority?: string) => {
|
||||
const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
|
||||
|
||||
Reference in New Issue
Block a user