Skip to content

Preserve scroll position when adding actions to Focus#42

Merged
tavva merged 7 commits intomainfrom
fix/preserve-scroll-on-focus-add
Feb 17, 2026
Merged

Preserve scroll position when adding actions to Focus#42
tavva merged 7 commits intomainfrom
fix/preserve-scroll-on-focus-add

Conversation

@tavva
Copy link
Copy Markdown
Owner

@tavva tavva commented Feb 17, 2026

Summary

  • Focus file changes now trigger a lightweight CSS-only update instead of a full view re-render, preserving scroll position in the Sphere view
  • Action items get data attributes (data-focus-file, data-focus-action) so focus highlighting can be toggled on existing DOM elements without rebuilding the view
  • Project file changes still trigger the full refresh as before

Fixes #41

Test plan

  • Open a Sphere view with enough projects/actions to require scrolling
  • Scroll down and click an action to add it to Focus
  • Verify the scroll position stays put and the item gets the "in focus" highlight
  • Remove an item from the Focus view and verify the Sphere view updates the highlight without scrolling

Summary by CodeRabbit

  • New Features

    • Per-item focus metadata and visible "in-focus" state with targeted DOM updates to avoid full refreshes.
    • View now emits action-completed and action-waiting events for item state changes.
    • New persisted focus identifier exported for integration.
  • Bug Fixes

    • Suppresses redundant full refreshes for user-driven focus changes; preserves scroll/position.
    • Cleans up workspace event listeners on view close.
  • Tests

    • Expanded coverage for focus visuals, data attributes, suppression behavior, event handling, DOM cleanup, waiting-state indicator, and priority edits.

When adding a focus item from the SphereView, saving the focus file
triggered a metadata cache change that caused a full view refresh,
resetting scroll to top. Instead, focus file changes now trigger a
lightweight CSS-only update that toggles sphere-action-in-focus classes
on existing DOM elements without re-rendering.

Fixes #41
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

SphereView adds workspace-level event tracking and a suppressFocusRefresh flag, replaces simple focus-file handling with refreshFocusHighlighting, emits/handles flow:action-completed to prune DOM, and annotates action elements with per-item focus data attributes; tests and mocks updated accordingly.

Changes

Cohort / File(s) Summary
Sphere view core
src/sphere-view.ts
Added workspaceEventRefs: EventRef[] and suppressFocusRefresh: boolean; register/cleanup workspace events; replace direct focus-file refresh with refreshFocusHighlighting(); added removeActionFromDom, addToFocus/removeFromFocus suppress logic; render attaches data-focus-file and data-focus-action.
Focus persistence
src/focus-persistence.ts
Exported new FOCUS_FILE_PATH constant used to identify the focus file.
Focus view event emission
src/focus-view.ts
When marking an item complete, emits flow:action-completed on workspace with { file, action } before re-rendering.
Tests — sphere & focus
tests/sphere-view.test.ts, tests/focus-view.test.ts, tests/focus-integration.test.ts
Expanded tests: assert data-focus attributes, focus highlighting behavior and suppression, DOM-only CSS update flows, action-completed handling, workspace event cleanup, and priority-edit interactions; added tests that expect flow:action-completed.
Mocks
tests/__mocks__/obsidian.ts
Workspace mock gains offref and trigger jest mock functions to support registering/offing event refs and emitting workspace events.

Sequence Diagram

sequenceDiagram
    participant MC as MetadataCache
    participant SV as SphereView
    participant FP as FocusPersistence
    participant DOM as ActionItemsDOM
    participant WS as Workspace

    MC->>SV: metadataChanged(filePath)
    activate SV
    SV->>FP: read FOCUS_FILE_PATH / load focus items
    alt filePath == FP::FOCUS_FILE_PATH and not SV.suppressFocusRefresh
        SV->>SV: schedule full refresh
    else if SV.isRelevantFile(filePath)
        SV->>SV: schedule auto-refresh
    else
        SV-->>MC: ignore
    end
    SV->>SV: save container.scrollTop
    SV->>DOM: renderActionItems (attach data-focus-file / data-focus-action)
    SV->>FP: loadFocusItems()
    FP-->>SV: currentFocusItems
    SV->>DOM: apply/remove sphere-action-in-focus per item (refreshFocusHighlighting)
    SV->>SV: restore container.scrollTop
    deactivate SV

    Note over WS,SV: When an item is completed in FocusView
    WS->>SV: flow:action-completed {file, action}
    SV->>SV: removeActionFromDom(file, action)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop and tag with nimble paws,
I hush a double-refresh cause,
I keep your scroll where it should be,
I mark the focus — swift and free,
A tiny hop to tidy laws.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title mentions preserving scroll position when adding actions to Focus, but the actual implementation focuses on preventing full view re-renders using CSS-only updates and workspace events. Update the title to better reflect the core implementation approach, such as 'Use CSS-only updates for focus changes in Sphere view' or 'Prevent full re-render when toggling focus state'.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The code changes fully address issue #41 by implementing CSS-only updates for focus state changes, preventing full view re-renders and preserving scroll position in the Sphere view.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the scroll preservation feature: workspace event tracking, focus highlighting, action removal on completion, and supporting test infrastructure.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/preserve-scroll-on-focus-add

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/sphere-view.ts (1)

602-609: loadFocusItems is called per action item — O(n) file reads.

Each call to renderActionItem individually loads focus items from disk (line 603). With many actions this means redundant I/O. The new refreshFocusHighlighting already demonstrates the better pattern: load once, iterate. Consider passing the pre-loaded focus items into renderActionItem, or skipping the initial focus check entirely and relying on a single refreshFocusHighlighting() call after the view is rendered.

Sketch: load once, pass in

In renderProjectsSection / renderGeneralNextActionsSection, load once before the loop:

+    const focusItems = await loadFocusItems(this.app.vault);
     project.nextActions.forEach((action, index) => {
-      void this.renderActionItem(list, action, project.file, this.sphere, false);
+      void this.renderActionItem(list, action, project.file, this.sphere, false, focusItems);
     });

Then in renderActionItem, accept and use the pre-loaded list instead of calling loadFocusItems again.

Alternatively, remove the in-focus check from renderActionItem entirely and call this.refreshFocusHighlighting() once at the end of renderContent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 602 - 609, The per-item call to
loadFocusItems inside renderActionItem causes O(n) disk reads; instead load
focus items once and pass them into renderActionItem (or remove the check there
and call this.refreshFocusHighlighting() once after renderContent). Concretely:
update renderProjectsSection and renderGeneralNextActionsSection to call const
focusItems = await loadFocusItems(this.app.vault) before looping, change
renderActionItem to accept a focusItems parameter and use that list for the
inFocus check (replace the await loadFocusItems(...) call), or alternatively
delete the in-focus logic from renderActionItem and invoke
this.refreshFocusHighlighting() once after rendering completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/sphere-view.ts`:
- Around line 602-609: The per-item call to loadFocusItems inside
renderActionItem causes O(n) disk reads; instead load focus items once and pass
them into renderActionItem (or remove the check there and call
this.refreshFocusHighlighting() once after renderContent). Concretely: update
renderProjectsSection and renderGeneralNextActionsSection to call const
focusItems = await loadFocusItems(this.app.vault) before looping, change
renderActionItem to accept a focusItems parameter and use that list for the
inFocus check (replace the await loadFocusItems(...) call), or alternatively
delete the in-focus logic from renderActionItem and invoke
this.refreshFocusHighlighting() once after rendering completes.

The CSS-only approach for focus file changes broke sphere view updates
when items were completed from the Focus view, because the focus file
change was the only reliable trigger for a full refresh (Obsidian's
metadata cache doesn't fire for checkbox body changes).

Instead, preserve scroll position by loading sphere data before emptying
the container, so the empty-and-rebuild happens in one synchronous block
with no async gap (no visible flash). Scroll position is saved and
restored around the refresh.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/sphere-view.ts (1)

600-607: 🛠️ Refactor suggestion | 🟠 Major

loadFocusItems is called once per action item — O(N) file reads.

Each call to renderActionItem independently calls loadFocusItems, which reads the focus file from the vault. For a sphere with many actions, this results in redundant I/O. Load the focus items once in the calling context (e.g., renderProjectsSection / renderGeneralNextActionsSection) and pass the array into renderActionItem.

Sketch of the fix
  private async renderActionItem(
    list: HTMLElement,
    action: string,
    file: string,
    sphere: string,
-   isGeneral: boolean
+   isGeneral: boolean,
+   focusItems?: FocusItem[]
  ): Promise<void> {
    // ...
-   const focusItems = await loadFocusItems(this.app.vault);
+   const resolvedFocusItems = focusItems ?? await loadFocusItems(this.app.vault);
    const inFocus = resolvedFocusItems.some(
      (focusItem) => focusItem.file === file && focusItem.text === action
    );

Then in the callers, load once and pass:

+  const focusItems = await loadFocusItems(this.app.vault);
   project.nextActions.forEach((action, index) => {
-    void this.renderActionItem(list, action, project.file, this.sphere, false);
+    void this.renderActionItem(list, action, project.file, this.sphere, false, focusItems);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 600 - 607, renderActionItem currently calls
loadFocusItems for every action, causing O(N) vault reads; instead, change
renderActionItem to accept a preloaded focusItems array parameter (from
loadFocusItems) and use that to determine inFocus, then remove the internal
loadFocusItems call. Update callers such as renderProjectsSection and
renderGeneralNextActionsSection to call loadFocusItems(this.app.vault) once,
store the result, and pass it into each renderActionItem invocation. Ensure
function signatures (renderActionItem) and all call sites are updated
accordingly and types/signatures are adjusted where needed.
🧹 Nitpick comments (3)
src/sphere-view.ts (2)

160-168: Double-refresh after focus toggle: consider guarding.

When a user clicks an action, addToFocus/removeFromFocus updates the CSS class locally, but also writes the focus file. The metadata cache listener on line 165 will then fire scheduleAutoRefresh, causing a redundant full re-render 500ms later. The scroll preservation mitigates the UX impact, but you could avoid the extra work by setting a short "suppress auto-refresh" flag around focus writes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 160 - 168, The metadata change listener
(registered in registerMetadataCacheListener via this.metadataCacheEventRef)
triggers scheduleAutoRefresh even when focus toggles originate locally
(addToFocus/removeFromFocus) causing a redundant full re-render; introduce a
short-lived guard (e.g., this.suppressAutoRefresh boolean) that
addToFocus/removeFromFocus sets before writing the FOCUS_FILE_PATH and clears
after the write completes or after a short timeout, and update the "changed"
handler to return early when this.suppressAutoRefresh is true so
scheduleAutoRefresh is not called for locally-triggered metadata updates.

1-9: Missing required ABOUTME comment lines.

Source files must start with two ABOUTME comment lines describing file purpose. As per coding guidelines: src/**/*.{ts,tsx}: "All source files must start with two ABOUTME comment lines describing file purpose."

Proposed fix
+// ABOUTME: SphereView renders the GTD sphere view showing projects and next actions.
+// ABOUTME: Handles focus toggling, scroll preservation, filtering, and project hierarchy display.
 import {
   ItemView,
   WorkspaceLeaf,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 1 - 9, This file is missing the mandated two
ABOUTME header comment lines; add two top-of-file comment lines starting with
ABOUTME that succinctly describe the file's purpose (e.g., what SphereView
provides/does) and its relation to the Obsidian plugin, placing them before the
import block in src/sphere-view.ts; reference the primary symbol implemented
here (e.g., the SphereView class or any exported functions) in those comments so
the file meets the `src/**/*.{ts,tsx}` guideline.
tests/sphere-view.test.ts (1)

743-744: Reliance on setTimeout(resolve, 10) for async rendering is fragile.

These timeouts may flake in slow CI environments. Consider a polling helper (e.g., wait until container.querySelectorAll("li[data-focus-file]").length === expectedCount) for more deterministic assertions. This is a minor concern — the tests likely pass reliably in practice.

Also applies to: 766-767

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/sphere-view.test.ts` around lines 743 - 744, The tests use fragile
fixed delays (await new Promise(resolve => setTimeout(resolve, 10))) after
calling view.onOpen(); instead replace them with a deterministic polling helper
that waits until the DOM reaches the expected state (for example, a helper like
waitForCondition(() => container.querySelectorAll('li[data-focus-file]').length
=== expectedCount, { timeout: 2000, interval: 50 }) or use your test framework's
waitFor/awaitFor utilities) so assertions only run once the expected elements
exist; update the two occurrences after view.onOpen() to call that helper
(referencing view.onOpen() and container.querySelectorAll('li[data-focus-file]')
to locate the changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/sphere-view.ts`:
- Around line 600-607: renderActionItem currently calls loadFocusItems for every
action, causing O(N) vault reads; instead, change renderActionItem to accept a
preloaded focusItems array parameter (from loadFocusItems) and use that to
determine inFocus, then remove the internal loadFocusItems call. Update callers
such as renderProjectsSection and renderGeneralNextActionsSection to call
loadFocusItems(this.app.vault) once, store the result, and pass it into each
renderActionItem invocation. Ensure function signatures (renderActionItem) and
all call sites are updated accordingly and types/signatures are adjusted where
needed.

---

Nitpick comments:
In `@src/sphere-view.ts`:
- Around line 160-168: The metadata change listener (registered in
registerMetadataCacheListener via this.metadataCacheEventRef) triggers
scheduleAutoRefresh even when focus toggles originate locally
(addToFocus/removeFromFocus) causing a redundant full re-render; introduce a
short-lived guard (e.g., this.suppressAutoRefresh boolean) that
addToFocus/removeFromFocus sets before writing the FOCUS_FILE_PATH and clears
after the write completes or after a short timeout, and update the "changed"
handler to return early when this.suppressAutoRefresh is true so
scheduleAutoRefresh is not called for locally-triggered metadata updates.
- Around line 1-9: This file is missing the mandated two ABOUTME header comment
lines; add two top-of-file comment lines starting with ABOUTME that succinctly
describe the file's purpose (e.g., what SphereView provides/does) and its
relation to the Obsidian plugin, placing them before the import block in
src/sphere-view.ts; reference the primary symbol implemented here (e.g., the
SphereView class or any exported functions) in those comments so the file meets
the `src/**/*.{ts,tsx}` guideline.

In `@tests/sphere-view.test.ts`:
- Around line 743-744: The tests use fragile fixed delays (await new
Promise(resolve => setTimeout(resolve, 10))) after calling view.onOpen();
instead replace them with a deterministic polling helper that waits until the
DOM reaches the expected state (for example, a helper like waitForCondition(()
=> container.querySelectorAll('li[data-focus-file]').length === expectedCount, {
timeout: 2000, interval: 50 }) or use your test framework's waitFor/awaitFor
utilities) so assertions only run once the expected elements exist; update the
two occurrences after view.onOpen() to call that helper (referencing
view.onOpen() and container.querySelectorAll('li[data-focus-file]') to locate
the changes).

The fire-and-forget renderActionItem calls mean the DOM isn't fully
built when scrollTop is set, causing the browser to clamp the value.
Use MutationObserver to wait for 50ms of DOM stability before
restoring the scroll position.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/sphere-view.ts (4)

624-631: loadFocusItems is called once per action item — O(n) file reads per refresh.

Each renderActionItem call independently reads and parses focus.md from disk (line 625). For a sphere with many actions, this results in redundant I/O. Consider loading focus items once per refresh cycle and passing the array into renderActionItem.

Sketch
-  private async renderActionItem(
-    list: HTMLElement,
-    action: string,
-    file: string,
-    sphere: string,
-    isGeneral: boolean
-  ): Promise<void> {
+  private async renderActionItem(
+    list: HTMLElement,
+    action: string,
+    file: string,
+    sphere: string,
+    isGeneral: boolean,
+    focusItems?: FocusItem[]
+  ): Promise<void> {
     ...
-    const focusItems = await loadFocusItems(this.app.vault);
+    const items = focusItems ?? await loadFocusItems(this.app.vault);
-    const inFocus = focusItems.some(
+    const inFocus = items.some(
       (focusItem) => focusItem.file === file && focusItem.text === action
     );

Then in the callers (renderProjectsSection, renderGeneralNextActionsSection), load once and pass through.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 624 - 631, The current renderActionItem
calls loadFocusItems() inside each item, causing O(n) file reads; instead, load
focusItems once per refresh and pass that array into renderActionItem. Change
renderActionItem to accept a focusItems parameter (or optional cache) and remove
the internal await loadFocusItems call, then update callers
renderProjectsSection and renderGeneralNextActionsSection to call
loadFocusItems(this.app.vault) once, store the result in a local variable
(focusItems), and pass it into each renderActionItem invocation so the per-item
file read is eliminated.

1-9: Missing required ABOUTME comment lines at the top of the file.

As per coding guidelines, all source files under src/ must start with two ABOUTME comment lines describing the file's purpose.

Proposed fix
+// ABOUTME: SphereView renders the GTD sphere view showing projects, actions, and focus state.
+// ABOUTME: Handles scroll preservation, metadata-driven refresh, and focus toggling.
 import {
   ItemView,

As per coding guidelines: src/**/*.{ts,tsx}: All source files must start with two ABOUTME comment lines describing file purpose.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 1 - 9, This file is missing the required two
ABOUTME comment lines at the top; add two single-line comments as the first two
lines of src/sphere-view.ts that briefly describe the file's purpose (e.g., what
the ItemView subclass in this file does), so the ABOUTME header precedes any
imports such as ItemView, WorkspaceLeaf, or other symbols; ensure both comment
lines follow the project's ABOUTME format and are placed before the existing
import block.

302-310: Redundant container.empty()renderContent already empties the container.

Line 306 calls container.empty(), then renderContent at line 381 does the same. The explicit empty on line 306 is redundant.

Proposed fix
 private async refresh(): Promise<void> {
   const container = this.contentEl;
   const scrollTop = container.scrollTop;
   const data = await this.loadSphereData();
-  container.empty();
   this.renderContent(container, data);
   await this.waitForRenderSettled(container);
   container.scrollTop = scrollTop;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 302 - 310, Redundant container.empty() in
refresh(): remove the explicit call to container.empty() inside the refresh()
method and rely on renderContent(container, data) to clear and repopulate the
container; keep preserving scrollTop, calling waitForRenderSettled(container),
and restoring container.scrollTop to preserve behavior. Update the refresh()
method (the container.empty() call between loadSphereData() and renderContent())
accordingly so there is no double-emptying.

160-169: Focus file changes now trigger a full refresh — acceptable with scroll preservation, but worth noting the interaction.

When addToFocus/removeFromFocus save focus items, this listener will fire ~500ms later and trigger a full refresh(). The CSS class was already toggled immediately for responsiveness, so the subsequent full refresh is redundant but ensures consistency for external modifications. The scroll preservation in refresh() keeps this from being disruptive.

If performance becomes a concern with many projects, consider short-circuiting the full refresh when the focus file changes (e.g., only updating sphere-action-in-focus classes on existing DOM elements using the data-focus-* attributes).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 160 - 169, Listener
registerMetadataCacheListener currently treats changes to FOCUS_FILE_PATH the
same as other files and calls scheduleAutoRefresh (leading to a full refresh);
instead, short-circuit inside the metadataCache "changed" handler: if file.path
=== FOCUS_FILE_PATH then update the DOM-only state by toggling the
sphere-action-in-focus class on existing elements using the data-focus-*
attributes (find elements by data-focus-id or equivalent) and return early
without calling scheduleAutoRefresh; otherwise fall through to the existing
isRelevantFile check and call scheduleAutoRefresh as before. Ensure you still
update metadataCacheEventRef via app.metadataCache.on and preserve scroll logic
inside refresh unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sphere-view.ts`:
- Around line 312-333: The waitForRenderSettled function can hang because the
50ms debounce timeout is continually reset by mutations; add a hard upper-bound
timeout (e.g., MAX_WAIT_MS = 2000) so the returned Promise always resolves even
if mutations keep occurring. Modify waitForRenderSettled to start both the
existing debounce timeout (variable timeout) and a separate maxTimeout; if
maxTimeout fires, disconnect the MutationObserver, clear the debounce timeout,
and resolve; likewise, when the debounce resolves first, clear the maxTimeout
and disconnect. Reference the function name waitForRenderSettled, the observer
variable, timeout local, and ensure both timers are cleared on settle to avoid
leaks.

---

Nitpick comments:
In `@src/sphere-view.ts`:
- Around line 624-631: The current renderActionItem calls loadFocusItems()
inside each item, causing O(n) file reads; instead, load focusItems once per
refresh and pass that array into renderActionItem. Change renderActionItem to
accept a focusItems parameter (or optional cache) and remove the internal await
loadFocusItems call, then update callers renderProjectsSection and
renderGeneralNextActionsSection to call loadFocusItems(this.app.vault) once,
store the result in a local variable (focusItems), and pass it into each
renderActionItem invocation so the per-item file read is eliminated.
- Around line 1-9: This file is missing the required two ABOUTME comment lines
at the top; add two single-line comments as the first two lines of
src/sphere-view.ts that briefly describe the file's purpose (e.g., what the
ItemView subclass in this file does), so the ABOUTME header precedes any imports
such as ItemView, WorkspaceLeaf, or other symbols; ensure both comment lines
follow the project's ABOUTME format and are placed before the existing import
block.
- Around line 302-310: Redundant container.empty() in refresh(): remove the
explicit call to container.empty() inside the refresh() method and rely on
renderContent(container, data) to clear and repopulate the container; keep
preserving scrollTop, calling waitForRenderSettled(container), and restoring
container.scrollTop to preserve behavior. Update the refresh() method (the
container.empty() call between loadSphereData() and renderContent()) accordingly
so there is no double-emptying.
- Around line 160-169: Listener registerMetadataCacheListener currently treats
changes to FOCUS_FILE_PATH the same as other files and calls scheduleAutoRefresh
(leading to a full refresh); instead, short-circuit inside the metadataCache
"changed" handler: if file.path === FOCUS_FILE_PATH then update the DOM-only
state by toggling the sphere-action-in-focus class on existing elements using
the data-focus-* attributes (find elements by data-focus-id or equivalent) and
return early without calling scheduleAutoRefresh; otherwise fall through to the
existing isRelevantFile check and call scheduleAutoRefresh as before. Ensure you
still update metadataCacheEventRef via app.metadataCache.on and preserve scroll
logic inside refresh unchanged.

Comment thread src/sphere-view.ts Outdated
Comment on lines 312 to 333
private waitForRenderSettled(container: HTMLElement): Promise<void> {
if (typeof MutationObserver === "undefined") {
return Promise.resolve();
}

return new Promise((resolve) => {
const observer = new MutationObserver(() => {
clearTimeout(timeout);
timeout = setTimeout(() => {
observer.disconnect();
resolve();
}, 50);
});

let timeout = setTimeout(() => {
observer.disconnect();
resolve();
}, 50);

observer.observe(container, { childList: true, subtree: true, characterData: true });
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

waitForRenderSettled has no upper-bound timeout — can hang indefinitely.

If DOM mutations keep firing (e.g., animated elements, recursive renders, or third-party plugins modifying the container), the 50ms timer resets on every mutation and the promise never resolves. This silently blocks refresh() and scroll restoration.

Add a maximum timeout as a safety net:

Proposed fix
 private waitForRenderSettled(container: HTMLElement): Promise<void> {
   if (typeof MutationObserver === "undefined") {
     return Promise.resolve();
   }

+  const MAX_WAIT = 2000;
   return new Promise((resolve) => {
+    const done = () => {
+      clearTimeout(timeout);
+      clearTimeout(maxTimeout);
+      observer.disconnect();
+      resolve();
+    };
+
     const observer = new MutationObserver(() => {
       clearTimeout(timeout);
       timeout = setTimeout(() => {
-        observer.disconnect();
-        resolve();
+        done();
       }, 50);
     });

     let timeout = setTimeout(() => {
-      observer.disconnect();
-      resolve();
+      done();
     }, 50);

+    const maxTimeout = setTimeout(() => {
+      done();
+    }, MAX_WAIT);
+
     observer.observe(container, { childList: true, subtree: true, characterData: true });
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 312 - 333, The waitForRenderSettled function
can hang because the 50ms debounce timeout is continually reset by mutations;
add a hard upper-bound timeout (e.g., MAX_WAIT_MS = 2000) so the returned
Promise always resolves even if mutations keep occurring. Modify
waitForRenderSettled to start both the existing debounce timeout (variable
timeout) and a separate maxTimeout; if maxTimeout fires, disconnect the
MutationObserver, clear the debounce timeout, and resolve; likewise, when the
debounce resolves first, clear the maxTimeout and disconnect. Reference the
function name waitForRenderSettled, the observer variable, timeout local, and
ensure both timers are cleared on settle to avoid leaks.

Instead of re-rendering and restoring scroll (which causes a flash),
set a flag before saving focus items so the metadata cache handler
skips the auto-refresh. The CSS class update already happens locally.

External focus changes (e.g. completing items in FocusView) still
trigger a full refresh as before.
Focus file changes now trigger CSS-only updates in the sphere view
(toggling sphere-action-in-focus classes) instead of a full re-render.

When an item is completed in FocusView, a flow:action-completed
workspace event removes the specific action from the sphere view DOM.

This eliminates the jarring flash when interacting with the focus view
while the sphere view is visible alongside it.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/sphere-view.ts (2)

650-663: loadFocusItems called per action item — N reads for N actions.

Each call to renderActionItem independently calls loadFocusItems (line 657), resulting in one vault read per action. For spheres with many actions, this multiplies I/O on every render.

Consider loading the focus items once in the caller (renderProjectsSection / renderGeneralNextActionsSection) and passing the array into renderActionItem.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 650 - 663, renderActionItem currently calls
loadFocusItems for each action causing N vault reads; instead, remove the await
loadFocusItems(...) call from renderActionItem and add a new parameter (e.g.,
focusItems: FocusItem[]) to renderActionItem so it checks inFocus against the
passed array; update the callers renderProjectsSection and
renderGeneralNextActionsSection to call loadFocusItems(this.app.vault) once
before iterating actions and pass that array into each renderActionItem
invocation; keep the same equality check (focusItem.file === file &&
focusItem.text === action) and ensure callers await the single loadFocusItems
promise once.

1-16: Missing ABOUTME comment lines.

As per coding guidelines, all source files in src/ must start with two ABOUTME comment lines describing the file's purpose.

Proposed fix
+// ABOUTME: Sphere view displaying projects and actions grouped by a single sphere.
+// ABOUTME: Supports focus toggling, priority editing, and CSS-only focus highlighting.
+
 import {
   ItemView,
   WorkspaceLeaf,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 1 - 16, This file is missing the required
two ABOUTME comment lines at the top; add two comment lines at the very
beginning of src/sphere-view.ts that start with ABOUTME and briefly describe the
file's purpose (e.g., "ABOUTME: UI view for Sphere projects and summaries" and a
second line with author/intent or short description), placing them before the
import block so tools/readers pick them up; ensure they follow the project's
comment style (//) and include a concise description referencing that this file
defines the Sphere view and coordinates SphereDataLoader, SphereProjectSummary,
and related view components like ItemView/WorkspaceLeaf.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sphere-view.ts`:
- Around line 184-192: registerWorkspaceEvents currently appends a new
completedRef to workspaceEventRefs every time it's called, leaking EventRefs;
before registering the new handler inside registerWorkspaceEvents, unregister
any existing refs by iterating this.workspaceEventRefs and calling
this.app.workspace.off(ref) (or the appropriate unregister method used
elsewhere), then clear this.workspaceEventRefs, then create the new completedRef
for the "flow:action-completed" listener and push it; this mirrors the cleanup
behavior used in registerMetadataCacheListener and prevents accumulating
handlers.

In `@tests/sphere-view.test.ts`:
- Around line 945-963: The test for SphereView's cleanup is weak because it only
asserts app.workspace.offref is defined instead of verifying it was called;
update the test after creating the view and calling view.onOpen() and
view.onClose() to assert that app.workspace.offref (the mock function) was
called (e.g., using toHaveBeenCalled or toHaveBeenCalledTimes) and optionally
verify it was called with the same event refs registered via app.workspace.on —
locate the test referencing SphereView, onOpen, onClose, and
app.workspace.offref and replace the toBeDefined assertion with a call assertion
against the mock.

---

Nitpick comments:
In `@src/sphere-view.ts`:
- Around line 650-663: renderActionItem currently calls loadFocusItems for each
action causing N vault reads; instead, remove the await loadFocusItems(...) call
from renderActionItem and add a new parameter (e.g., focusItems: FocusItem[]) to
renderActionItem so it checks inFocus against the passed array; update the
callers renderProjectsSection and renderGeneralNextActionsSection to call
loadFocusItems(this.app.vault) once before iterating actions and pass that array
into each renderActionItem invocation; keep the same equality check
(focusItem.file === file && focusItem.text === action) and ensure callers await
the single loadFocusItems promise once.
- Around line 1-16: This file is missing the required two ABOUTME comment lines
at the top; add two comment lines at the very beginning of src/sphere-view.ts
that start with ABOUTME and briefly describe the file's purpose (e.g., "ABOUTME:
UI view for Sphere projects and summaries" and a second line with author/intent
or short description), placing them before the import block so tools/readers
pick them up; ensure they follow the project's comment style (//) and include a
concise description referencing that this file defines the Sphere view and
coordinates SphereDataLoader, SphereProjectSummary, and related view components
like ItemView/WorkspaceLeaf.

Comment thread src/sphere-view.ts
Comment on lines +184 to +192
private registerWorkspaceEvents(): void {
const completedRef = (this.app.workspace as any).on(
"flow:action-completed",
(detail: { file: string; action: string }) => {
this.removeActionFromDom(detail.file, detail.action);
}
);
this.workspaceEventRefs.push(completedRef);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Workspace event handlers accumulate on repeated onOpen calls.

registerWorkspaceEvents pushes new refs without clearing previous ones. Since onOpen is re-invoked by setState and setSphere, each call registers an additional flow:action-completed handler. While the duplicate handlers are functionally harmless (the DOM element is already removed by the first), this leaks EventRefs until onClose.

Compare with registerMetadataCacheListener (line 168–170) which correctly unregisters the old ref before re-registering.

Proposed fix
  private registerWorkspaceEvents(): void {
+   for (const ref of this.workspaceEventRefs) {
+     this.app.workspace.offref(ref);
+   }
+   this.workspaceEventRefs = [];
+
    const completedRef = (this.app.workspace as any).on(
      "flow:action-completed",
      (detail: { file: string; action: string }) => {
        this.removeActionFromDom(detail.file, detail.action);
      }
    );
    this.workspaceEventRefs.push(completedRef);
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private registerWorkspaceEvents(): void {
const completedRef = (this.app.workspace as any).on(
"flow:action-completed",
(detail: { file: string; action: string }) => {
this.removeActionFromDom(detail.file, detail.action);
}
);
this.workspaceEventRefs.push(completedRef);
}
private registerWorkspaceEvents(): void {
for (const ref of this.workspaceEventRefs) {
this.app.workspace.offref(ref);
}
this.workspaceEventRefs = [];
const completedRef = (this.app.workspace as any).on(
"flow:action-completed",
(detail: { file: string; action: string }) => {
this.removeActionFromDom(detail.file, detail.action);
}
);
this.workspaceEventRefs.push(completedRef);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/sphere-view.ts` around lines 184 - 192, registerWorkspaceEvents currently
appends a new completedRef to workspaceEventRefs every time it's called, leaking
EventRefs; before registering the new handler inside registerWorkspaceEvents,
unregister any existing refs by iterating this.workspaceEventRefs and calling
this.app.workspace.off(ref) (or the appropriate unregister method used
elsewhere), then clear this.workspaceEventRefs, then create the new completedRef
for the "flow:action-completed" listener and push it; this mirrors the cleanup
behavior used in registerMetadataCacheListener and prevents accumulating
handlers.

Comment thread tests/sphere-view.test.ts
Comment on lines +945 to +963
it("should clean up workspace event listeners on close", async () => {
const view = new SphereView(leaf, "work", settings, mockSaveSettings);
view.app = app;

await view.onOpen();

// Verify workspace events were registered
const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls;
const completedHandler = workspaceOnCalls.find(
(call: any[]) => call[0] === "flow:action-completed"
);
expect(completedHandler).toBeTruthy();

await view.onClose();

// offref should have been called for workspace event refs
expect(app.workspace.offref).toBeDefined();
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Weak assertion — only checks offref is defined, not that it was called.

The test title says "should clean up workspace event listeners on close" but the assertion at line 961 only verifies the method exists (toBeDefined), not that it was actually invoked. This test would pass even if onClose didn't call offref at all.

Proposed fix
      await view.onClose();

-     // offref should have been called for workspace event refs
-     expect(app.workspace.offref).toBeDefined();
+     // offref should have been called for each workspace event ref
+     expect(app.workspace.offref).toHaveBeenCalled();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("should clean up workspace event listeners on close", async () => {
const view = new SphereView(leaf, "work", settings, mockSaveSettings);
view.app = app;
await view.onOpen();
// Verify workspace events were registered
const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls;
const completedHandler = workspaceOnCalls.find(
(call: any[]) => call[0] === "flow:action-completed"
);
expect(completedHandler).toBeTruthy();
await view.onClose();
// offref should have been called for workspace event refs
expect(app.workspace.offref).toBeDefined();
});
});
it("should clean up workspace event listeners on close", async () => {
const view = new SphereView(leaf, "work", settings, mockSaveSettings);
view.app = app;
await view.onOpen();
// Verify workspace events were registered
const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls;
const completedHandler = workspaceOnCalls.find(
(call: any[]) => call[0] === "flow:action-completed"
);
expect(completedHandler).toBeTruthy();
await view.onClose();
// offref should have been called for each workspace event ref
expect(app.workspace.offref).toHaveBeenCalled();
});
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/sphere-view.test.ts` around lines 945 - 963, The test for SphereView's
cleanup is weak because it only asserts app.workspace.offref is defined instead
of verifying it was called; update the test after creating the view and calling
view.onOpen() and view.onClose() to assert that app.workspace.offref (the mock
function) was called (e.g., using toHaveBeenCalled or toHaveBeenCalledTimes) and
optionally verify it was called with the same event refs registered via
app.workspace.on — locate the test referencing SphereView, onOpen, onClose, and
app.workspace.offref and replace the toBeDefined assertion with a call assertion
against the mock.

FocusView now triggers a flow:action-waiting workspace event when
converting an action to waiting-for. SphereView listens and re-renders
the specific action's content with the handshake emoji, avoiding a
full view refresh.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/sphere-view.ts`:
- Around line 184-200: registerWorkspaceEvents is re-registering handlers on
each onOpen (called via setState/setSphere) and simply pushes new EventRef
objects into workspaceEventRefs, leaking refs; before adding the new refs,
unregister any existing ones (like registerMetadataCacheListener does) by
iterating this.workspaceEventRefs and calling the workspace/unregister/off
method with each stored EventRef, then clear the array, and only then attach the
two listeners (the handlers removeActionFromDom and markActionWaitingInDom) and
push their new refs into workspaceEventRefs.

In `@tests/sphere-view.test.ts`:
- Around line 1005-1008: The test asserts that app.workspace.offref exists but
doesn't verify it was invoked; update the assertion to spy on or mock
app.workspace.offref before calling await view.onClose() (e.g.,
jest.spyOn(app.workspace, 'offref') or set app.workspace.offref = jest.fn()) and
then assert it was called (e.g., expect(app.workspace.offref).toHaveBeenCalled()
or toHaveBeenCalledTimes(1) and/or with the expected args) so the test for
view.onClose actually verifies cleanup occurred.

Unregister existing workspace event handlers before registering new
ones in registerWorkspaceEvents, matching the pattern used by
registerMetadataCacheListener. Also strengthen the cleanup test
assertion from toBeDefined to toHaveBeenCalled.
@tavva tavva merged commit e647785 into main Feb 17, 2026
8 checks passed
@tavva tavva deleted the fix/preserve-scroll-on-focus-add branch February 17, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request: Maintain scroll position after adding a task to Focus

1 participant