Skip to content

feat: nested lorebook folders - #2353

Merged
Xelvanis merged 8 commits into
Pasta-Devs:refactorfrom
kolacheee:feat/lorebook-nested-folders
Jun 6, 2026
Merged

feat: nested lorebook folders#2353
Xelvanis merged 8 commits into
Pasta-Devs:refactorfrom
kolacheee:feat/lorebook-nested-folders

Conversation

@kolacheee

@kolacheee kolacheee commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Closes #2127 — this is the nested-folders half. (The intra-lorebook entry-duplication half shipped earlier in #2157.)

Why this change

  • [Feature]: Lorebook subfolders and intra-lorebook entry duplication #2127 asked for lorebook sub-folders. Folders were flat — parentFolderId existed but was reserved/always-null. This ships nesting end-to-end: organize a lorebook's folders into a tree, set parentage by dropdown or drag, and (the load-bearing part) extend the folder-disabled activation gate to disabled ancestors, so nesting can never silently leak gated content into the prompt.

What changed

  • Scanner gate (active-lorebook-scanner.ts) — activation now resolves "effective disabled" by walking each entry's folder parentFolderId chain (with a cycle guard): a folder OR any of its ancestors being disabled gates the entry, regardless of the entry's own flag.
  • Validation + tree model (lib/lorebook-folder-tree.ts)canReparentFolder is the single write-time guard (same lorebook, no self-parent, no cycles, with reasons); buildFolderForest groups folders into { roots, childrenByParent } for rendering. A dangling/missing parent falls back to root, which auto-promotes a deleted folder's children to the top level.
  • Storage (hooks/use-lorebooks.ts)reorderLorebookFolders can now also set parentFolderId, so nest/un-nest + per-parent ordering happen in one pass; reorder/reparent invalidates the activation cache.
  • Editor UI — recursive folder-tree render with depth indent + collapse-hides-subtree (LorebookEntriesTab.tsx); a per-folder parent dropdown filtered to valid parents (LorebookFolderRow.tsx); drag-to-nest via the header (top = reorder before, middle = nest, bottom = reorder after) and via a folder's body including the empty placeholder, with the left-indent margin targeting shallower ancestors; drag to the root area to un-nest (use-lorebook-editor-drag-drop.ts, LorebookEditor.tsx).
  • Folder type/schema comments updated to describe real nesting (no longer "reserved / always null").

Refactor impact

Primary owner: Catalog / Lorebooks editor (frontend), plus the activation scanner (engine generation)

Impact areas reviewed:

  • The lorebook editor (recursive render + folder drag-drop), the new folder-tree lib, the activation scanner's disabled-folder gate, and the folder reorder hook. No engine/shared-API/Rust/remote-runtime changes; no storage-contract or src-tauri/src/lib.rs change.

Boundary notes:

  • Confined to the lorebooks feature UI + its React Query layer + the activation scanner's normalization step. Reuses canReparentFolder as the one write-time guard and the existing generic storage gateway; no new storage collection, command, or cross-boundary path.

Pressure points touched:

  • The activation scanner (generation path) — specifically the disabled-folder gate, now ancestor-aware. No ModeSurface/GameSurface/shared mode UI, no src-tauri/src/lib.rs, no imports, no version-bearing files.

Validation

  • Matching validation command passes locally (for example pnpm typecheck, pnpm build, pnpm check:architecture, pnpm check:docs, or full pnpm check when warranted)
  • Full pnpm check passes before PR push/handoff
  • Human/manual validation completed by contributor or reviewer

Manual verification notes

  • pnpm typecheck plus check:architecture / check:frontend / check:rust / check:discovery / check:docs / check:launcher-safety / check:unused all pass locally. check:line-endings fails only on pre-existing CRLF in files this PR doesn't touch (DESIGN.md, custom_components/*.py); their committed versions are LF, so CI is unaffected.
  • Manually verified in the running Tauri build: nesting via the parent dropdown and via drag (header three-zone, folder body, empty placeholder, left-indent targeting); un-nesting via the root area and the dropdown; cycle / self / cross-lorebook moves are never offered as drop targets; collapsing a parent hides its subtree; deleting a parent promotes its children to root; checked light + dark and at ~400px width.

Feature Discoverability

  • Updated src/features/shell/discovery/ because this PR adds or materially changes a user-discoverable feature, workflow, setting, mode, panel, import path, agent, media capability, or advanced tool.
  • N/A because this PR is only a bugfix, refactor, test, docs, internal wiring, visual polish, copy edit, or compatibility fix and does not add a new thing users need to find.

Reason:

  • N/A — sub-folders are an enhancement within the already-registered Lorebooks surface (the lorebooks discovery entry opens the panel). The registry is surface-level, not per-action, so no new entry is required and check:discovery passes unchanged (same call as the entry-duplication PR feat: duplicate lorebook entries #2157).

Docs and release impact

  • No docs changes needed
  • Updated README.md
  • Updated CONTRIBUTING.md
  • Updated docs/developer/
  • Updated repo skills or AGENTS.md
  • Confirmed this PR does not restore old staging/package-workspace/release claims

Proof

Core claim:

  • Lorebook folders nest into a tree; a disabled folder or any disabled ancestor gates its entries at activation time; re-parenting is validated at write time (no self-parent / cross-lorebook / cycles) and the scanner can't loop or leak on malformed data.

Verification run:

  • The full check suite minus the pre-existing line-endings artifact (see Manual verification notes), plus hands-on testing in the running Tauri build.

Evidence:

  • Manual: a disabled parent/grandparent hides entries that live in an enabled child folder at generation time; the parent dropdown and every drag path only offer valid targets; collapse / delete / un-nest behave as described above, in light + dark and at ~400px.
  • Local unit tests (uncommitted — the repo gitignores *.test.ts, and the template asks not to submit them as proof) cover the scanner's ancestor chain-walk and the canReparentFolder / buildFolderForest edge cases (self, cross-lorebook, direct + deep cycles, dangling-parent→root). Happy to fold them in if the team wants them tracked.

Design notes (recorded for review):

  • Client-side validation + scanner backstop, by design. canReparentFolder runs client-side because the lorebook-folder collection is stored generically with no server-side field validation — matching how every other lorebook edit is validated. The activation scanner independently resolves disabled ancestors and guards against cycles at read time, so a malformed parent introduced by an import or a direct write can never hang generation or leak gated content. If the lorebook layer later moves into Rust, the same three checks (same lorebook, no self-parent, no cycle) port directly.
  • Deleting a parent folder promotes its children to the top level (orphan fallback in buildFolderForest) rather than cascade-deleting — consistent with how an entry whose folder is gone falls back to root.

Manual verification requested:

  • Reviewers can confirm by nesting a few folders (dropdown + drag), disabling a parent and checking that a nested entry does not inject in a chat, and deleting a parent to watch its children rise to the top level.

UI evidence

{FD93A120-C1AF-48A8-BB3C-F10246B0A196} {0B080A73-73DE-45C6-B1EC-BD85E6AF4E53} {A77F01B1-B64E-4715-A0A6-0854A38C40A4} {C7CCFAC4-8BF0-4B8A-942A-6F1039F7DD1E}

Summary by CodeRabbit

  • New Features

    • Added support for nested folders within loreBooks, allowing hierarchical organization of folder structures.
    • Enhanced drag-and-drop functionality with improved drop-zone targeting ("before," "inside," "after" positions).
    • Added parent folder selector dropdown in the folder editor for easy reparenting.
  • Bug Fixes

    • Fixed folder disablement logic to cascade down—entries under disabled folders or their ancestors are now properly skipped during activation.
  • Documentation

    • Updated inline documentation to clarify nested-folder behavior and write-time validation rules.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aae38df4-0762-4365-b486-9cb4c47a9256

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions github-actions Bot added the feature New feature or enhancement label Jun 5, 2026
Ship sub-folders for lorebooks (the nesting half of Pasta-Devs#2127; the entry-duplication
half landed in Pasta-Devs#2157). Folders can now nest into a tree.

- scanner: activation gating resolves "effective disabled" by walking the
  parentFolderId chain (cycle-guarded), so a disabled folder OR any disabled
  ancestor gates its entries -- nesting can't silently leak gated content.
- lib/lorebook-folder-tree: canReparentFolder (same-lorebook, no self-parent,
  no cycles) is the write-time guard; buildFolderForest groups folders into a
  render-ready tree (a dangling/missing parent falls back to root, so deleting a
  parent promotes its children).
- storage: reorderLorebookFolders optionally sets parentFolderId, so nest /
  un-nest + per-parent order happen in one pass; reorder/reparent refreshes
  activation.
- editor UI: recursive folder-tree render (depth indent, collapse hides the
  subtree); a per-folder parent dropdown filtered by canReparentFolder; folder
  drag-to-nest via the header (top = reorder before, middle = nest, bottom =
  reorder after) and via a folder's body (incl. the empty placeholder, the
  left-indent margin targeting shallower ancestors); drag into the root area to
  un-nest.

Validation is client-side because the lorebook-folder collection is stored
generically with no server-side field validation (matching every other lorebook
edit); the scanner independently guards cycles + disabled ancestors at read
time, so malformed import/direct-write data can't hang generation.

Closes Pasta-Devs#2127.
@kolacheee
kolacheee marked this pull request as ready for review June 5, 2026 16:40
@kolacheee
kolacheee force-pushed the feat/lorebook-nested-folders branch from 980f904 to 15687d7 Compare June 5, 2026 16:40
@kolacheee

Copy link
Copy Markdown
Collaborator Author

@cha1latte ready for review btw

@coderabbitai coderabbitai Bot added the client label Jun 6, 2026
@github-actions github-actions Bot removed the client label Jun 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/features/catalog/lorebooks/components/editor/use-lorebook-editor-drag-drop.ts`:
- Around line 243-285: The handlers handleFolderDragOverRow,
handleFolderBodyNestDragOver, and handleRootFolderDragOver currently set
folderRootDropActive and folderDropTarget but never clear stale state when the
cursor moves to a different target or an invalid/self target; update each
handler to proactively clear the other state when the current hover becomes
invalid or is a different target (e.g., call setFolderRootDropActive(false)
and/or setFolderDropTarget(null) as appropriate before returning or when
switching zones), and ensure when an invalid/self target is detected you clear
both folderRootDropActive and folderDropTarget so no previous highlight remains.

In `@src/features/catalog/lorebooks/components/entries/LorebookFolderRow.tsx`:
- Around line 270-280: The parent dropdown is hidden when folders.length > 1 is
false, preventing clearing a stale parentFolderId; update the render guard
around CompactSelect so it also shows when a dangling parent exists (e.g.,
change the condition from folders.length > 1 to (folders.length > 1 ||
localParentId != null && localParentId !== "")), keeping the existing props
(value={localParentId ?? ""}, onChange={handleParentChange},
options={parentOptions}) and the surrounding span/onClick to preserve behavior
so users can select “(no parent)” to clear the stale parent.

In `@src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts`:
- Around line 27-37: Ensure the source folder exists before allowing a
move-to-root: instead of returning { ok: true } immediately when newParentId ===
null, first build byId and lookup folder using folderId (the folder variable),
and return { ok: false, reason: "Folder not found." } if it's missing; only
after confirming folder exists should you allow newParentId === null to return
success. Also preserve the existing checks that create byId from folders and
validate newParent (newParentId, newParent.lorebookId) when applicable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c8e36630-a0c8-423e-94ac-4d803e64efe1

📥 Commits

Reviewing files that changed from the base of the PR and between 3a53bf5 and 15687d7.

📒 Files selected for processing (9)
  • src/engine/contracts/schemas/lorebook.schema.ts
  • src/engine/contracts/types/lorebook.ts
  • src/engine/generation/active-lorebook-scanner.ts
  • src/features/catalog/lorebooks/components/editor/LorebookEditor.tsx
  • src/features/catalog/lorebooks/components/editor/LorebookEntriesTab.tsx
  • src/features/catalog/lorebooks/components/editor/use-lorebook-editor-drag-drop.ts
  • src/features/catalog/lorebooks/components/entries/LorebookFolderRow.tsx
  • src/features/catalog/lorebooks/hooks/use-lorebooks.ts
  • src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts

Comment thread src/features/catalog/lorebooks/components/entries/LorebookFolderRow.tsx Outdated
Comment thread src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts
@Xelvanis

Xelvanis commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Additional review notes from the nested-folder pass:

  • Collapsed ancestor folders still count descendant entries as visible for bulk selection. use-lorebook-entry-selection.ts only excludes entries whose direct folderId is collapsed, but the new renderer hides the whole descendant tree. Select all visible can therefore include entries the user cannot see. Fix by deriving hidden folder ids from collapsed folders plus descendants.

  • Deleting a parent folder does not clear direct child folders' parentFolderId. buildFolderForest renders missing-parent folders at the root, but storage still contains the stale parent id; later reparent/reorder logic reads that stale value and can reject or misplace moves. Prefer clearing child folders to parentFolderId: null in the storage delete path.

  • Child folders do not show inherited disabled state. A child under a disabled ancestor still looks enabled and its tooltip says entries activate normally, even though the scanner correctly gates it. Pass inherited disabled state into the row and adjust the styling/copy so the UI matches activation behavior.

- Select-all-visible now excludes entries under a collapsed ancestor, not
  just directly-collapsed folders (collectHiddenFolderIds walks the subtree),
  so it can't select entries the user can't see.
- Deleting a folder reparents its direct child folders to root (clears the
  stale parentFolderId) alongside the existing entry reparenting, so later
  reorder/reparent logic never reads a dangling parent. Adds a regression test.
- Folder rows under a disabled ancestor now show muted icon/toggle, a "parent
  off" cue, and an accurate tooltip, matching the scanner's activation gating.
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

✅ Bunny Review Completed

Tip

Review posted. The specimen has left the observation table.

  • Commit: 2ea62e5 - Tighten lorebook folder test fixtures

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

🐰 Bunny Review

Bunny Merge Signal: Ready With Notes

Warning

READY WITH NOTES
No actionable defects were isolated, but non-blocking notes remain.

Findings Nitpicks Controls Reviewed Head Mode
0 0 3 warn, 1 pass 2ea62e56 incremental

Note

Mode: incremental
Head: 2ea62e5 - Tighten lorebook folder test fixtures
Base: 9b2153a - Fix lorebook folder source reorder

🧭 Specimen Summary

  • The specimen tries to make lorebook folder ancestry less permissive at the storage membrane while trimming the editor’s drag-drop commentary into smaller labels. The visible Rust path preserves guards for same-lorebook parents, immutable folder ownership, self-parent and descendant-cycle rejection, delete-time child promotion, and adds tidier fixtures around the proof. The React focus files alter explanatory tissue rather than machinery; a modest experiment, but even modest reagents can contaminate a forest if the storage contract blinks.
  • The specimen trims the lorebook folder row down to its working organs, leaving the optimistic enable, rename, parent-select, and delete paths behaviorally intact. The more interesting incision is in buildFolderForest: malformed unreachable parent chains are now promoted to roots and their stale child-list edges are excised. A tidy little correction, amusing precisely because the previous forest could not decide whether a cyclic folder should vanish or appear twice.

🔎 Isolated Defects

Tip

No actionable defects isolated.

✅ Resolved Since Last Review

  • src-tauri/src/commands/storage/commands/entities.rs:1704 - Cross-parent reorder abandons source siblings
  • src-tauri/src/commands/storage/commands/entities.rs:1229 - Root lorebook moves strand descendants across books
  • src-tauri/src/commands/storage/commands/entities.rs:1210 - Lorebook moves evade the parent validator
  • src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts:140 - Cycle promotion manufactures duplicate forest membership
  • src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts:121 - Cyclic folders vanish from the editable forest

🧹 Nitpicks

  • None recorded.

✅ Control Checks

Status Type Check Detail
✅ PASS CI Timing CI Status Expected CI controls completed without a reported failure.
⚠️ WARN Review Limitation Prior contracts The visible ancestry guards and tests satisfy the lorebook ownership and parent-validation contracts in this packet. The full lorebook_folder_reorder_inner implementation was not visible behind truncation, so the atomic compaction mechanism is judged through its exposed tests and call contract rather than direct dissection.
⚠️ WARN Proof Gap Proof The reorder proof covers a root-to-child source/destination move. The earlier contract also named child-to-root and non-root-to-non-root moves; if those proofs live outside the packet, excellent, but they were not visible in this specimen.
⚠️ WARN Proof Gap Proof The focus-file repair appears to satisfy prior cyclic-visibility contracts 4 and 6 by promoting unreachable folders and removing their old parent edge, but the packet includes no focused A↔B, three-node cycle, or dangling-parent fixture. The antidote is visible; its assay is absent.

🧪 Observations

  • Established incremental review against base 9b2153a2ee296952b4dc6aad7c9d576c1ecfb96b and head 2ea62e56559f82927c7df4ee90bf4260c0284f6f, limited to the three requested focus files.
  • Read AGENTS and matched architecture and bugfix guidance; no React leakage into engine code or raw feature-side Tauri bypass appeared in the focused diff.
  • Inspected Rust lorebook folder create/patch validation for immutable ownership, same-book parent checks, self-parent rejection, and ancestor-cycle detection.

🧰 CI Status

  • pass: Frontend, Architecture, and Organization
  • pass: Rust Capability Layer
  • pass: Browser Smoke and Performance
  • note: Bunny gates only required job conclusions from ci-checks.json; advisory reports such as continue-on-error dependency checks are not included in this status.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bunny Review inline findings

Comment thread src/engine/contracts/schemas/lorebook.schema.ts Outdated
Comment thread src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts
Comment thread src/features/catalog/lorebooks/hooks/use-lorebooks.ts Outdated
- Add validate_lorebook_folder_for_create/_patch: reject a parentFolderId in
  another lorebook, a self-parent, a missing parent, or a cycle, at the generic
  create/update path — not just the editor's canReparentFolder. Imports, remote
  callers, and command writes can no longer persist a malformed tree. Adds a
  regression test (cycle + cross-lorebook rejected, root move allowed).
- buildFolderForest now promotes folders unreachable from any root (trapped in a
  malformed parent cycle) up to the roots, so they stay visible and repairable
  instead of vanishing from the editor.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bunny Review inline findings

Comment thread src-tauri/src/commands/storage/commands/entities.rs
Comment thread src/features/catalog/lorebooks/lib/lorebook-folder-tree.ts
- validate_lorebook_folder_for_patch now checks the effective post-patch
  (lorebookId, parentFolderId) pair, so a lorebookId-only patch can't leave a
  parent that has become cross-lorebook. Test extended to cover the evasion.
- buildFolderForest severs the cyclic child edge when promoting an unreachable
  node to the roots, so a rescued cycle member appears once (as a root) instead
  of also under its parent — a clean forest, not a hall of mirrors.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bunny Review inline findings

Comment thread src-tauri/src/commands/storage/commands/entities.rs Outdated
kolacheee and others added 2 commits June 6, 2026 12:43
A folder's lorebook is an ownership key the app never changes (entry transfers
move entries, never folders). Allowing a lorebookId patch stranded a root
folder's children — or a child's parent — across books. Reject any change to a
different lorebook outright instead of validating ever-more-elaborate cross-book
move shapes. Test covers both the child and root cases.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bunny Review inline findings

Comment thread src-tauri/src/commands/storage/commands/entities.rs Outdated
@Xelvanis
Xelvanis merged commit 7f85956 into Pasta-Devs:refactor Jun 6, 2026
8 checks passed
@kolacheee
kolacheee deleted the feat/lorebook-nested-folders branch June 19, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants