perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs (#1674)

* perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs

Performance fixes for the right sidebar (git/files/context tabs).

== Correctness / leak fixes (P0)

* RightSidebar: drop dead useEffect that re-nulled refs the resize
  handler already nulled; collapse the redundant width/minWidth/maxWidth
  triple into width + the existing --oc-right-sidebar-width variable.
* useUIStore: clamp setRightSidebarWidth to [MIN, MAX]; simplify
  setRightSidebarOpen (22 lines -> 12).
* RightSidebarTabs: useRightSidebarGitSync now takes the right tab and
  main tab and only polls when the right git tab is the visible consumer
  and the browser is online + visible. Replaces a global poll that
  fired for the lifetime of the open sidebar.
* GitView: commit-files fetch refactored to cancelled + Promise.all
  (was a per-hash loop that could setState after unmount); getRemoteUrl
  and refreshRemotes gated on cancelled/mountedRef; new module-scoped
  mountedRef guards setIsSettingIdentity from firing after unmount.
* GitView + useGitmojiList: extract gitmoji fetch/cache into a hook
  with module-level inflight promise + subscribers Set; stale-while-
  revalidate from localStorage; ensureLoaded() for call-site-initiated
  hydration; cancelled flag on setIsLoading to avoid the React
  setState-after-unmount race.
* ProjectNotesTodoPanel: 400 ms notes debounce now cancels on blur
  (was double-saving); persistProjectData chained per project through
  a module-level Map<projectId, Promise> so a fast todo toggle racing
  the debounced save no longer hits the server in parallel; resize
  auto-adjust guards against same-value pings.

== Render fanout (P1)

* RightSidebarTabs: all three tab content components are now always
  mounted with the hidden attribute. State and cache survive tab
  switches. When activeMainTab === 'git' (or 'context') the matching
  right tab is filtered out of the tab strip and a redirect effect
  snaps any persisted-but-now-hidden right tab to 'files'. onSelect is
  now a type-guarded handler instead of `as RightTab`.
* GitView: 13 separate useGitStore action selectors collapsed into one
  useShallow block (one re-evaluation per store change instead of 13).
* GitView: new isGitViewActive flag (true when this instance is the
  visible consumer) gates the 7 live effects — load identities, fetch
  remote URL, refresh remotes, ensureAll, sessionEvents.onGitRefreshHint,
  worktree bootstrap poll, default-identity auto-apply. Hidden
  GitView instances no longer run these.
* GitView: gitViewSnapshots module-level Map is now backed by an
  LRU wrapper (cap 20) so per-directory draft snapshots cannot leak
  across hundreds of project switches. Removed the dead `unique.set`
  dedup in changeEntries — GitStatus.files is already unique by path.
* SidebarFilesTree: statusByPath Map<path, FileStatus> and
  badgeByDir Map<dirPath, { modified, added }> are precomputed once
  per gitStatus change. Tree render is O(1) per node instead of O(N)
  per node via the previous per-row find/scan. badgeByDir walks each
  file's path segments and increments counters for every ancestor
  dir, so total cost is O(N + total_dirs_in_files) per gitStatus
  change.
* SidebarFilesTree: FileRow wrapped in React.memo with a custom
  comparator. Context-menu open state moved INTO FileRow as local
  state — opening a menu in one row no longer re-renders siblings.
* SidebarFilesTree: loadDirectory accepts an isCancelled predicate;
  the batch-load effect for expandedPaths passes a stable predicate
  so per-dir fetches stop touching state once the effect tears down.
* SidebarFilesTree: module-level fileTreeCacheByRoot Map (LRU,
  cap 8 roots) hydrates childrenByDir / loadErrorsByDir /
  loadedDirsRef on mount or root change. Mirror effects write state
  back to the cache. Survives close-and-reopen of the right sidebar;
  populated entries are dropped on unmount only when they had no
  data.

== Result

Net diff: 6 files modified, 1 new (useGitmojiList.ts), 682 insertions,
283 deletions. Existing test suite baseline preserved (537 pass / 58
fail / 1 error) — no new regressions. The 58 pre-existing failures are
in unrelated chat/streaming tests and were verified via git stash on
the same branch.

Architecture assumptions, verified by manual review:
- P1.1's redirect effect snaps rightSidebarTab to 'files' whenever
  activeMainTab === 'git', so the right and main GitView instances
  are mutually exclusive — isGitViewActive cannot be true for both.
- The 7 gated effects plus the useRightSidebar GitSync poll cover all
  cases where git state should advance: visible consumer fetches; the
  poll keeps the store warm when only the right git tab is visible.
- The aborted loadDirectory predicate is sufficient because
  inFlightDirsRef and loadedDirsRef dedup at the call site before
  any network IO is initiated.

* fix(sidebar): always clean up inFlightDirsRef regardless of cancellation

* refactor(sidebar): deduplicate RIGHT_SIDEBAR_MIN/MAX_WIDTH constants, export from useUIStore

* docs: split right sidebar perf plan into standalone file, clean up merged master status from chat plan

* fix(git): gate GitView effects by instance visibility

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
bashrusakh
2026-06-16 13:52:05 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy Bohdan Triapitsyn
parent e982bd9388
commit 9199798a14
10 changed files with 1273 additions and 291 deletions
@@ -50,6 +50,13 @@ import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
const TODO_PANEL_MIN_ITEMS = 5;
const TODO_PANEL_MAX_ITEMS = 15;
// Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent
// saves await the previous one so a fast todo toggle or blur that lands
// while the debounced notes save is still on the wire is appended, not
// racing against it. The chain is module-scoped so it survives remounts
// (e.g. when the user switches the right sidebar tab away and back).
const projectSaveChainByProject = new Map<string, Promise<unknown>>();
const getEffectiveItemHeight = (padding: number) => {
const scale = Math.sqrt(padding / 100);
const paddingPx = 12 * scale;
@@ -170,6 +177,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const [contextReloadTick, setContextReloadTick] = React.useState(0);
const notesHydratedRef = React.useRef(false);
const lastSavedNotesRef = React.useRef('');
const notesDebounceTimerRef = React.useRef<number | null>(null);
const todoPanelHeight = useUIStore((state) => state.todoPanelHeight);
const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight);
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
@@ -195,14 +203,28 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
if (!projectRef) {
return false;
}
const saved = await saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
});
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
const key = projectRef.id;
// Serialize concurrent saves per project: a fast toggle/strike while the
// debounce-driven notes save is in flight no longer races the network.
const previous = projectSaveChainByProject.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(() =>
saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
})
);
projectSaveChainByProject.set(key, next);
try {
const saved = await next;
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
return saved;
} finally {
if (projectSaveChainByProject.get(key) === next) {
projectSaveChainByProject.delete(key);
}
}
return saved;
},
[projectRef, t]
);
@@ -284,7 +306,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
}
const targetHeight = getPanelHeightForItems(todos.length, padding);
const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS;
if (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) {
if (
todoPanelHeight !== targetHeight
&& (todoPanelHeight < minHeight || todoPanelHeight > targetHeight)
) {
setTodoPanelHeight(targetHeight);
}
}, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]);
@@ -325,10 +350,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
event.preventDefault();
}, [todoPanelHeight]);
const cancelNotesDebounce = React.useCallback(() => {
if (notesDebounceTimerRef.current !== null) {
window.clearTimeout(notesDebounceTimerRef.current);
notesDebounceTimerRef.current = null;
}
}, []);
const handleNotesBlur = React.useCallback(() => {
cancelNotesDebounce();
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, [notes, persistProjectData, todos]);
}, [cancelNotesDebounce, notes, persistProjectData, todos]);
React.useEffect(() => {
if (!projectRef || !notesHydratedRef.current) {
@@ -339,15 +372,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
return;
}
const timer = window.setTimeout(() => {
notesDebounceTimerRef.current = window.setTimeout(() => {
notesDebounceTimerRef.current = null;
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, 400);
return () => {
window.clearTimeout(timer);
cancelNotesDebounce();
};
}, [notes, persistProjectData, projectRef, todos]);
}, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]);
React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]);
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();