perf: reduce re-renders, fix mobile keyboard handling, add chunk load recovery, and improve PATH management (#1028)
* fix: exclude file content from reverted prompt text Revert and fork now restore only the user's original prompt, not server-injected file content Uses existing isSyntheticPart helper for type-safe filtering * fix: keep scrollbar visible when hovering over thumb * fix: prevent ESC abort from triggering when terminal is focused * fix: pass directory to permission/question reply calls so approvals actually resolve * fix: default model selection not responding after Base UI migration * fix: prevent modal content from shifting and clipping footer buttons * fix: improve session switching performance and add sub-agent export with prompt collapse Defer viewport anchor saving to eliminate ~800ms UI freeze when switching sessions Add export dialog to include sub-agent tasks recursively in markdown export Add collapse chevron button for expanded user prompts in sticky header * fix: resolve sidebar scroll and TDZ crash in session sidebar * perf: reduce CPU overhead and re-renders across chat, layout, and settings * fix: position collapse button at top of message and prevent ESC abort in terminal * fix: position collapse button at top and add padding only when expanded * refactor: extract shared PATH utilities and mobile keyboard hook * refactor: import shared path-utils in electron, use module-level style constants - Electron now imports pathLooksUserConfigured/mergePathValues from shared path-utils.js instead of inline duplication - ToolPart collapsedCustomStyle moved from useMemo([]) to module const * fix: resolve remaining merge conflicts and type errors - Remove duplicate variable declarations in SessionNodeItem - Remove orphaned export callback body from conflict resolution - Fix HelpDialog description -> descriptionKey (i18n rename) * fix: resolve type-check and lint errors in session-actions.test.ts - Added missing bun:test type declarations (beforeEach, mock, mock.module) - Removed unused State import - Replaced 'as any' casts with proper OpencodeClient and ChildStoreManager types - Added eslint-disable for unused _ parameter in mock function * fix PR 1028 export and PATH edge cases * fix startup retry exhaustion state * remove opencode package lock change * fix sub-session rename cancellation --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
632e6cc97b
commit
4523e9c486
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user