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
@@ -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>
+12 -9
View File
@@ -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);
+21 -30
View File
@@ -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';
@@ -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}
@@ -743,7 +743,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
) : (
<>
{setupCommands.map((command, index) => (
<div key={index} className="flex gap-1.5">
<div key={`${command}-${index}`} className="flex gap-1.5">
<Input
value={command}
onChange={(e) => {
@@ -5,6 +5,7 @@ import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { useDirectorySync } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
@@ -184,7 +185,23 @@ const buildPermissionConfigWithGlobal = (
export const AgentsPage: React.FC = () => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
const {
selectedAgentName,
getAgentByName,
createAgent,
updateAgent,
agents,
agentDraft,
setAgentDraft,
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
getAgentByName: s.getAgentByName,
createAgent: s.createAgent,
updateAgent: s.updateAgent,
agents: s.agents,
agentDraft: s.agentDraft,
setAgentDraft: s.setAgentDraft,
})));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
@@ -19,6 +19,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -116,7 +117,15 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
createAgent,
deleteAgent,
loadAgents,
} = useAgentsStore();
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
agents: s.agents,
setSelectedAgent: s.setSelectedAgent,
setAgentDraft: s.setAgentDraft,
createAgent: s.createAgent,
deleteAgent: s.deleteAgent,
loadAgents: s.loadAgents,
})));
React.useEffect(() => {
loadAgents();
@@ -2,6 +2,7 @@ import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -73,7 +74,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [selectedIndex, setSelectedIndex] = React.useState(0);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const itemRefs = React.useRef<(HTMLElement | null)[]>([]);
const allowedProviderSet = React.useMemo(() => {
if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) {
@@ -176,14 +177,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
return (
<div
<DropdownMenuItem
key={`${keyPrefix}-${provID}-${modID}`}
ref={(el) => { itemRefs.current[flatIndex] = el; }}
className={cn(
"typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50"
"group flex items-center gap-2",
isHighlighted && "bg-interactive-selection"
)}
onClick={() => handleProviderAndModelChange(provID, modID)}
onSelect={() => handleProviderAndModelChange(provID, modID)}
onMouseEnter={() => setSelectedIndex(flatIndex)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
@@ -223,7 +224,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
)}
</button>
</div>
</div>
</DropdownMenuItem>
);
};
@@ -613,19 +614,18 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<div className="p-1">
{/* Not selected option */}
<div
<DropdownMenuItem
className={cn(
"typography-meta flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
"hover:bg-interactive-hover/50"
"flex items-center gap-2",
)}
onClick={() => handleProviderAndModelChange('', '')}
onSelect={() => handleProviderAndModelChange('', '')}
>
<RiCloseLine className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">{placeholder || t('settings.agents.modelSelector.notSelected')}</span>
{!providerId && !modelId && (
<RiCheckLine className="h-4 w-4 text-primary ml-auto" />
)}
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { useShallow } from 'zustand/react/shallow';
import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector';
@@ -19,7 +20,23 @@ import { useI18n } from '@/lib/i18n';
export const CommandsPage: React.FC = () => {
const { t } = useI18n();
const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore();
const {
selectedCommandName,
getCommandByName,
createCommand,
updateCommand,
commands,
commandDraft,
setCommandDraft,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
getCommandByName: s.getCommandByName,
createCommand: s.createCommand,
updateCommand: s.updateCommand,
commands: s.commands,
commandDraft: s.commandDraft,
setCommandDraft: s.setCommandDraft,
})));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
@@ -20,6 +20,7 @@ import {
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
@@ -46,8 +47,17 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
createCommand,
deleteCommand,
loadCommands,
} = useCommandsStore();
const { skills, loadSkills } = useSkillsStore();
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
commands: s.commands,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
const skills = useSkillsStore((s) => s.skills);
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
loadCommands();
@@ -58,12 +58,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
importData,
}) => {
const { t } = useI18n();
const {
getProfileById,
createProfile,
updateProfile,
deleteProfile,
} = useGitIdentitiesStore();
const getProfileById = useGitIdentitiesStore((s) => s.getProfileById);
const createProfile = useGitIdentitiesStore((s) => s.createProfile);
const updateProfile = useGitIdentitiesStore((s) => s.updateProfile);
const deleteProfile = useGitIdentitiesStore((s) => s.deleteProfile);
const selectedProfile = React.useMemo(() =>
profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
@@ -29,6 +29,7 @@ import {
RiShieldKeyholeLine,
} from '@remixicon/react';
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
@@ -66,7 +67,18 @@ export const GitPage: React.FC = () => {
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
} = useGitIdentitiesStore(useShallow((s) => ({
profiles: s.profiles,
globalIdentity: s.globalIdentity,
defaultGitIdentityId: s.defaultGitIdentityId,
deleteProfile: s.deleteProfile,
loadProfiles: s.loadProfiles,
loadGlobalIdentity: s.loadGlobalIdentity,
loadDiscoveredCredentials: s.loadDiscoveredCredentials,
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
setDefaultGitIdentityId: s.setDefaultGitIdentityId,
getUnimportedCredentials: s.getUnimportedCredentials,
})));
const [editorOpen, setEditorOpen] = React.useState(false);
const [editorProfileId, setEditorProfileId] = React.useState<string | null>(null);
@@ -13,6 +13,7 @@ import {
type McpDraft,
type McpScope,
} from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import {
parseImportedMcpSnippet,
applyImportedMcpToDraft,
@@ -607,7 +608,17 @@ export const McpPage: React.FC = () => {
createMcp,
updateMcp,
deleteMcp,
} = useMcpConfigStore();
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
getMcpByName: s.getMcpByName,
createMcp: s.createMcp,
updateMcp: s.updateMcp,
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/dialog';
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { isMobileDeviceViaCSS } from '@/lib/device';
@@ -64,7 +65,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore();
useMcpConfigStore(useShallow((s) => ({
mcpServers: s.mcpServers,
selectedMcpName: s.selectedMcpName,
setSelectedMcp: s.setSelectedMcp,
setMcpDraft: s.setMcpDraft,
loadMcpConfigs: s.loadMcpConfigs,
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
@@ -1,6 +1,7 @@
import React from 'react';
import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo } from '@/lib/device';
import { toast } from '@/components/ui';
@@ -16,7 +17,19 @@ export const AboutSettings: React.FC = () => {
const { t } = useI18n();
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const [showChecking, setShowChecking] = React.useState(false);
const updateStore = useUpdateStore();
const updateStore = useUpdateStore(useShallow((s) => ({
info: s.info,
checking: s.checking,
available: s.available,
error: s.error,
downloading: s.downloading,
downloaded: s.downloaded,
progress: s.progress,
runtimeType: s.runtimeType,
checkForUpdates: s.checkForUpdates,
downloadUpdate: s.downloadUpdate,
restartToUpdate: s.restartToUpdate,
})));
const { isMobile } = useDeviceInfo();
const currentVersion = updateStore.info?.currentVersion || 'unknown';
@@ -107,11 +107,11 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{actions.map((action, index) => {
{actions.map((action) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={index}
key={action.label}
onClick={(e) => {
e.stopPropagation();
action.onClick();
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
@@ -39,17 +40,27 @@ const SkillsCatalogStandalone: React.FC = () => (
const SkillsInstalledPage: React.FC = () => {
const { t } = useI18n();
const {
selectedSkillName,
getSkillByName,
const {
selectedSkillName,
getSkillByName,
getSkillDetail,
createSkill,
updateSkill,
skills,
skillDraft,
createSkill,
updateSkill,
skills,
skillDraft,
setSkillDraft,
setSelectedSkill,
} = useSkillsStore();
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
getSkillByName: s.getSkillByName,
getSkillDetail: s.getSkillDetail,
createSkill: s.createSkill,
updateSkill: s.updateSkill,
skills: s.skills,
skillDraft: s.skillDraft,
setSkillDraft: s.setSkillDraft,
setSelectedSkill: s.setSelectedSkill,
})));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
@@ -19,6 +19,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
@@ -45,7 +46,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
createSkill,
deleteSkill,
getSkillDetail,
} = useSkillsStore();
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
createSkill: s.createSkill,
deleteSkill: s.deleteSkill,
getSkillDetail: s.getSkillDetail,
})));
// Skills are loaded by the Settings shell when this page is active.
@@ -79,7 +79,9 @@ interface AddCatalogDialogProps {
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
const { t } = useI18n();
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
const scanRepo = useSkillsCatalogStore((s) => s.scanRepo);
const loadCatalog = useSkillsCatalogStore((s) => s.loadCatalog);
const isScanning = useSkillsCatalogStore((s) => s.isScanning);
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
@@ -46,7 +46,10 @@ type IdentityOption = { id: string; name: string };
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
const { t } = useI18n();
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
const scanRepo = useSkillsCatalogStore((s) => s.scanRepo);
const installSkills = useSkillsCatalogStore((s) => s.installSkills);
const isScanning = useSkillsCatalogStore((s) => s.isScanning);
const isInstalling = useSkillsCatalogStore((s) => s.isInstalling);
const installedSkills = useSkillsStore((s) => s.skills);
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
@@ -39,7 +39,8 @@ interface InstallSkillDialogProps {
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
const { t } = useI18n();
const { installSkills, isInstalling } = useSkillsCatalogStore();
const installSkills = useSkillsCatalogStore((s) => s.installSkills);
const isInstalling = useSkillsCatalogStore((s) => s.isInstalling);
const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
const projects = useProjectsStore((s) => s.projects);
@@ -23,6 +23,7 @@ import {
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
@@ -81,7 +82,21 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
loadedSourceIds,
clawdhubHasMoreBySource,
lastCatalogError,
} = useSkillsCatalogStore();
} = useSkillsCatalogStore(useShallow((s) => ({
sources: s.sources,
itemsBySource: s.itemsBySource,
selectedSourceId: s.selectedSourceId,
setSelectedSource: s.setSelectedSource,
loadCatalog: s.loadCatalog,
loadSource: s.loadSource,
loadMoreClawdHub: s.loadMoreClawdHub,
isLoadingCatalog: s.isLoadingCatalog,
isLoadingSource: s.isLoadingSource,
isLoadingMore: s.isLoadingMore,
loadedSourceIds: s.loadedSourceIds,
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
lastCatalogError: s.lastCatalogError,
})));
const [search, setSearch] = React.useState('');
const [addCatalogOpen, setAddCatalogOpen] = React.useState(false);
@@ -38,8 +38,11 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
onOpenChange,
}) => {
const { t } = useI18n();
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { addProject, getActiveProject } = useProjectsStore();
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const isHomeReady = useDirectoryStore((s) => s.isHomeReady);
const addProject = useProjectsStore((s) => s.addProject);
const getActiveProject = useProjectsStore((s) => s.getActiveProject);
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
const [pathInputValue, setPathInputValue] = React.useState('');
const [hasUserSelection, setHasUserSelection] = React.useState(false);
@@ -72,8 +72,12 @@ export const SessionDialogs: React.FC = () => {
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { projects, addProject, activeProjectId } = useProjectsStore();
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const isHomeReady = useDirectoryStore((s) => s.isHomeReady);
const projects = useProjectsStore((s) => s.projects);
const addProject = useProjectsStore((s) => s.addProject);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const { requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
@@ -45,6 +45,7 @@ import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
@@ -290,7 +291,18 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const updateStore = useUpdateStore();
const updateStore = useUpdateStore(useShallow((s) => ({
checkForUpdates: s.checkForUpdates,
available: s.available,
runtimeType: s.runtimeType,
info: s.info,
downloading: s.downloading,
downloaded: s.downloaded,
progress: s.progress,
error: s.error,
downloadUpdate: s.downloadUpdate,
restartToUpdate: s.restartToUpdate,
})));
const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
@@ -165,26 +165,32 @@ export function SessionGroupSection(props: Props): React.ReactNode {
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
);
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
const scopeFolders = React.useMemo(
() => folderScopeKey ? getFoldersForScope(folderScopeKey) : [],
[folderScopeKey, getFoldersForScope]
);
const nodeBySessionId = new Map<string, SessionNode>();
const collectNodeLookup = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
nodeBySessionId.set(node.session.id, node);
if (node.children.length > 0) {
collectNodeLookup(node.children);
}
});
};
collectNodeLookup(sourceGroupNodes);
const nodeBySessionId = React.useMemo(() => {
const map = new Map<string, SessionNode>();
const collectNodeLookup = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
map.set(node.session.id, node);
if (node.children.length > 0) {
collectNodeLookup(node.children);
}
});
};
collectNodeLookup(sourceGroupNodes);
return map;
}, [sourceGroupNodes]);
const allFoldersForGroupBase = scopeFolders.map((folder) => {
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
const nodes = folder.sessionIds
.map((sid) => nodeBySessionId.get(sid))
.filter((n): n is SessionNode => Boolean(n))
.sort(compareSessionNodes);
return { folder, nodes };
});
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
const allFoldersForGroup = React.useMemo(() => {
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
@@ -238,9 +244,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds));
const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id));
const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId);
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
@@ -35,7 +35,10 @@ import {
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore } from '@/sync/viewport-store';
@@ -174,8 +177,20 @@ const areEqual = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false;
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
if (prev.editingId !== next.editingId) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if (prev.editTitle !== next.editTitle) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
@@ -267,6 +282,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const liveSession = useSession(session.id);
const resolvedSession = liveSession ?? session;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null);
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined);
const sync = useSync();
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore(
React.useCallback((state) => state.selectedIds.has(session.id), [session.id]),
@@ -285,15 +306,14 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
walk(root);
return out;
}, []);
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true);
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null);
const isZombie = useViewportStore(
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
);
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined);
const sync = useSync();
const sessionStatus = useGlobalSessionStatus(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
@@ -312,7 +332,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const handleExportSession = React.useCallback(async () => {
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
const collectChildExports = React.useCallback(async (children: SessionNode[]): Promise<{ children: ChildSessionExport[]; skipped: number }> => {
const results: ChildSessionExport[] = [];
let skipped = 0;
for (const child of children) {
try {
await sync.syncSession(child.session.id);
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
const childAgent = (child.session as Session & { agent?: string }).agent;
const grandChildren = await collectChildExports(child.children);
skipped += grandChildren.skipped;
results.push({
title: childTitle,
agent: childAgent,
records: childRecords,
children: grandChildren.children,
});
} catch {
skipped += collectNodeDescendantIds(child).length + 1;
}
}
return { children: results, skipped };
}, [collectNodeDescendantIds, directoryStore, sync, t]);
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
if (count <= 0) return;
toast.warning(count === 1
? t('sessions.sidebar.session.export.skippedSubtaskSingle', { count })
: t('sessions.sidebar.session.export.skippedSubtaskMany', { count }));
}, [t]);
const doExportSession = React.useCallback(async (includeSubtasks: boolean) => {
if (!sessionDirectory) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
@@ -326,7 +380,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return;
}
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null);
let childExports: ChildSessionExport[] | undefined;
let skippedSubtaskCount = 0;
if (includeSubtasks && node.children.length > 0) {
const collected = await collectChildExports(node.children);
childExports = collected.children;
skippedSubtaskCount = collected.skipped;
}
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null, childExports);
const filename = buildExportFilename(resolvedSession.title ?? null);
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
@@ -343,12 +405,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
},
},
});
showSkippedSubtasksWarning(skippedSubtaskCount);
return;
}
downloadAsMarkdown(markdown, filename);
toast.success(t('sessions.sidebar.session.export.success'));
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync, t]);
showSkippedSubtasksWarning(skippedSubtaskCount);
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
const handleExportSession = React.useCallback(async () => {
if (node.children.length > 0) {
setExportIncludeSubtasks(true);
setExportDialogOpen(true);
return;
}
await doExportSession(false);
}, [doExportSession, node.children.length]);
if (editingId === session.id) {
return (
@@ -808,6 +880,47 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
: null}
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>{t('sessions.sidebar.session.export.dialog.title')}</DialogTitle>
<DialogDescription>
{descendantCount === 1
? t('sessions.sidebar.session.export.dialog.descriptionSingle', { count: descendantCount })
: t('sessions.sidebar.session.export.dialog.descriptionMany', { count: descendantCount })}
</DialogDescription>
</DialogHeader>
<label className="flex items-center gap-2 typography-ui-label cursor-pointer">
<input
type="checkbox"
checked={exportIncludeSubtasks}
onChange={(e) => setExportIncludeSubtasks(e.target.checked)}
className="h-4 w-4 rounded border-border accent-primary"
/>
{t('sessions.sidebar.session.export.dialog.includeSubtasks')}
</label>
<DialogFooter>
<Button
type="button"
onClick={() => setExportDialogOpen(false)}
variant="outline"
size="sm"
>
{t('sessions.sidebar.dialogs.cancel')}
</Button>
<Button
type="button"
onClick={() => {
setExportDialogOpen(false);
void doExportSession(exportIncludeSubtasks);
}}
size="sm"
>
{t('sessions.sidebar.session.export.dialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</React.Fragment>
);
}
@@ -93,7 +93,7 @@ class InnerErrorBoundary extends React.Component<InnerErrorBoundaryProps, ErrorB
{this.state.error && (
<details className="text-xs font-mono bg-muted p-3 rounded">
<summary className="cursor-pointer hover:bg-interactive-hover/80">{strings.detailsSummary}</summary>
<pre className="mt-2 overflow-x-auto">
<pre className="mt-2 max-h-48 overflow-auto">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack ? `\n\n${strings.componentStackLabel}${this.state.errorInfo.componentStack}` : ''}
</pre>
+2 -2
View File
@@ -250,14 +250,14 @@ export const HelpDialog: React.FC = () => {
{t(section.categoryKey)}
</h3>
<div className="space-y-1">
{section.items.map((shortcut, index) => {
{section.items.map((shortcut) => {
const displayKeys = shortcut.id
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
return (
<div
key={index}
key={shortcut.id || shortcut.descriptionKey}
className="flex items-center justify-between py-1 px-2"
>
<div className="flex items-center gap-2">
@@ -42,6 +42,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
const frameRef = React.useRef<number | null>(null);
const metricsFrameRef = React.useRef<number | null>(null);
const isDraggingRef = React.useRef(false);
const isHoveringRef = React.useRef(false);
const lastUserIntentAtRef = React.useRef(0);
const dragStartRef = React.useRef<{
pointerX: number;
@@ -123,6 +124,10 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
// Don't schedule hide if hovering over the thumb
if (isHoveringRef.current) {
return;
}
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
}, [hideDelayMs]);
@@ -287,6 +292,21 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
scheduleHide();
};
const handleThumbMouseEnter = React.useCallback(() => {
isHoveringRef.current = true;
// Cancel any pending hide when hovering
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const handleThumbMouseLeave = React.useCallback(() => {
isHoveringRef.current = false;
// Schedule hide when leaving the thumb
scheduleHide();
}, [scheduleHide]);
const showVertical = vertical.length > 0;
const showHorizontal = horizontal.length > 0;
if (!showVertical && !showHorizontal) return null;
@@ -311,6 +331,8 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onMouseEnter={handleThumbMouseEnter}
onMouseLeave={handleThumbMouseLeave}
/>
)}
{showHorizontal && (
@@ -325,6 +347,8 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onMouseEnter={handleThumbMouseEnter}
onMouseLeave={handleThumbMouseLeave}
/>
)}
</div>
+1 -1
View File
@@ -105,7 +105,7 @@ function DialogContent({
data-slot="dialog-content"
data-state-slot="dialog"
className={cn(
"bg-background text-foreground fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-hidden pwa-dialog-content",
"bg-background text-foreground fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-lg max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-y-auto pwa-dialog-content",
className
)}
{...props}
@@ -1761,6 +1761,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
}, [loadDirectory, root, toggleExpandedPath]);
const fileRowPermissions = React.useMemo(
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
[canRename, canCreateFile, canCreateFolder, canDelete, canReveal]
);
function renderTree(dirPath: string, depth: number): React.ReactNode {
const nodes = childrenByDir[dirPath] ?? [];
@@ -1788,7 +1793,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
isMobile={isMobile}
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}
+9 -1
View File
@@ -4,6 +4,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { copyTextToClipboard } from '@/lib/clipboard';
import {
@@ -272,7 +273,14 @@ export const GitView: React.FC = () => {
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
useGitIdentitiesStore();
useGitIdentitiesStore(useShallow((s) => ({
profiles: s.profiles,
globalIdentity: s.globalIdentity,
defaultGitIdentityId: s.defaultGitIdentityId,
loadProfiles: s.loadProfiles,
loadGlobalIdentity: s.loadGlobalIdentity,
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
})));
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
const status = useGitStatus(currentDirectory ?? null);
@@ -100,17 +100,16 @@ export const TerminalView: React.FC = () => {
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
const effectiveDirectory = useEffectiveDirectory() ?? null;
const terminalStore = useTerminalStore();
const terminalSessions = terminalStore.sessions;
const terminalHydrated = terminalStore.hasHydrated;
const ensureDirectory = terminalStore.ensureDirectory;
const createTab = terminalStore.createTab;
const setActiveTab = terminalStore.setActiveTab;
const closeTab = terminalStore.closeTab;
const setTabSessionId = terminalStore.setTabSessionId;
const setTabLifecycle = terminalStore.setTabLifecycle;
const setConnecting = terminalStore.setConnecting;
const appendToBuffer = terminalStore.appendToBuffer;
const terminalSessions = useTerminalStore((s) => s.sessions);
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
const createTab = useTerminalStore((s) => s.createTab);
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
const closeTab = useTerminalStore((s) => s.closeTab);
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
const setConnecting = useTerminalStore((s) => s.setConnecting);
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
const directoryTerminalState = React.useMemo(() => {
if (!effectiveDirectory) return undefined;
@@ -456,11 +456,14 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:bg-[var(--interactive-hover)] rounded-md px-1 -mx-1 transition-colors">
<p className="typography-ui-label font-medium text-foreground">
{t('agentManager.empty.setupCommands.label')}
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
<span className="font-normal text-muted-foreground/70">
{' '}({t('agentManager.empty.setupCommands.configured', { count: setupCommands.filter(cmd => cmd.trim()).length })})
</span>
)}
{(() => {
const trimmedCommandCount = setupCommands.filter(cmd => cmd.trim()).length;
return trimmedCommandCount > 0 ? (
<span className="font-normal text-muted-foreground/70">
{' '}({t('agentManager.empty.setupCommands.configured', { count: trimmedCommandCount })})
</span>
) : null;
})()}
</p>
<RiArrowDownSLine className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
@@ -45,7 +45,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
</div>
<ul className="space-y-1">
{highlights.map((highlight, index) => (
<li key={index} className="typography-meta text-foreground">
<li key={`${highlight}-${index}`} className="typography-meta text-foreground">
{highlight}
</li>
))}
@@ -204,9 +204,9 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
</div>
<div className="bg-[var(--surface-elevated)] rounded-lg p-3 max-h-40 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
{displayFiles.map((file, index) => (
{displayFiles.map((file) => (
<li
key={index}
key={file}
className="typography-micro text-foreground font-mono truncate block"
title={file}
>
@@ -46,14 +46,14 @@ function getChangeTypeColor(changeType: string) {
}
}
export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
export const HistoryCommitRow = React.memo(({
entry,
isExpanded,
onToggle,
files,
isLoadingFiles,
onCopyHash,
}) => {
}: HistoryCommitRowProps) => {
const { t } = useI18n();
return (
<li>
@@ -159,4 +159,4 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
)}
</li>
);
};
});