Skip to content

feat: folders for voices and clips, list view, and profile duplication - #1005

Open
Lvigentini wants to merge 4 commits into
jamiepine:mainfrom
Lvigentini:feat/ui-organisation
Open

feat: folders for voices and clips, list view, and profile duplication#1005
Lvigentini wants to merge 4 commits into
jamiepine:mainfrom
Lvigentini:feat/ui-organisation

Conversation

@Lvigentini

@Lvigentini Lvigentini commented Aug 8, 2026

Copy link
Copy Markdown

Implements the folder organisation asked for in #821.

What it adds

  • Folders for voices and clips, with a folder tree in the History/clips view and folder sections in the voice profile list
  • List view in the Generate tab alongside the existing card view
  • Profile duplication

Backend: new folders table and migration, backend/routes/folders.py, plus folder fields on the profile and history routes. Frontend: ClipFolderTree, FolderSection, ProfileRow, a useFolders hook, and uiStore state for the view mode.

Stacking

This branch carries one of #1004's three commitsd5eea00, the backend half of VOICEBOX_DATA_DIR. The two Tauri commits that make the variable reach the packaged app are not here, so on this branch the override still only works for a bare uvicorn, which is the half that doesn't fix #981. That's deliberate: they're Rust changes with nothing to do with folders. #1004 remains the PR to merge for the actual fix.

Merge in either order; they don't conflict.

Notes for review

Closes #821

Screenshots

Voices tab — folders and list view
Screenshot 2026-08-07 102959

Folder sections with member counts (PODCAST VOICES 6, AUDITIONS 4), an UNCATEGORISED bucket for unfiled voices, and the New folder action. This is the list view added in this PR — the toggle at top right switches back to the existing card view.

Row actions — duplicate and file into a folder

Screenshot 2026-08-07 103022

The per-row action menu, showing Duplicate profile (copies samples, personality and effects) and Move to folder alongside the existing actions.

Clips tab — folder tree

The same folder model applied to generated clips, with per-folder counts, a selected folder highlighted, and All clips / Uncategorised pseudo-folders. Clips can also be dragged onto a folder header to file them.

🤖 Generated with Claude Code

Summary by CodeRabbit

New Features

  • Added voice and generation folders with creation, renaming, nesting, moving, collapsing, and deletion.
  • Added folder-based and uncategorised filtering for generation history.
  • Added list and card views for voice profiles.
  • Added profile duplication with copied settings, samples, and media.
  • Added profile and generation folder assignment controls.

Bug Fixes

  • Improved folder deletion, child-folder handling, pagination, and active-filter resets.

Accessibility

  • Added translated labels, dialogs, menus, and accessibility text for new actions.

Lvigentini and others added 3 commits August 6, 2026 19:31
backend/README.md documents VOICEBOX_DATA_DIR alongside --data-dir as a way to
override the data directory, but config.py never read it -- only
VOICEBOX_MODELS_DIR was wired up, so the default stayed hardcoded to ./data.

This matters in development: `just dev` starts the backend with a bare
`uvicorn backend.main:app`, which never reaches main.py's argparse, so
--data-dir is unavailable there and the dev server always writes to ./data
inside the repo. Pointing a dev server at the packaged app's data directory
meant editing the justfile.

Read the variable when computing the default. --data-dir still takes
precedence, since it calls set_data_dir() after import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two related additions, sharing changes to the profile models and service.

Folders
-------
A `folders` table plus a folder_id column on profiles and generations. One
table serves both, discriminated by kind:

  - voice      — flat.  Voices are a small stable set that reads better as
                 one level, so the routes reject a parent_id.
  - generation — nests to arbitrary depth, since clips accumulate per
                 project and need real hierarchy.

Nesting is enforced in the routes rather than the schema so the constraint
can relax later without a migration. Reparenting is guarded against cycles:
moving a folder inside its own descendant would detach the subtree from the
root and strand it in the tree UI.

Deleting a folder never deletes its contents. Members become uncategorised
and child folders rise to the deleted folder's parent, so a folder is only
ever a view over items, not an owner of them.

The folders table itself is created by Base.metadata.create_all(), which
runs right after migrations; only the columns on pre-existing tables need
the hand-rolled migration. Those are declared without a REFERENCES clause,
matching story_items.version_id, because SQLite cannot add a column with a
foreign key to a table that does not exist yet.

Duplication
-----------
POST /profiles/{id}/duplicate, deliberately not implemented as an
export-then-import round-trip. The transfer format carries only
name/description/language, so a round-trip silently drops personality,
effects_chain, default_engine and every preset/designed field -- and export
rejects profiles with no samples, which is every preset voice. Copying the
row directly keeps all of it. Sample audio is copied byte-for-byte rather
than re-encoded through add_profile_sample(), so a duplicate sounds like its
original instead of being resampled.

_get_unique_profile_name moves from export_import to services.profiles so
duplication can reuse it without importing the export/import layer; the old
name stays as an alias.

Also threads a folder filter through GET /history. Its limit/offset bounds
are now declared as Query constraints: building HistoryQuery from raw ints
inside the handler surfaced its ValidationError as an opaque 500 instead of
a 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voices as a list
----------------
Two-line rows: name, language and trait badges on the first line, the
description on the second — enough to tell apart clones that differ only by
a suffix (v2_slower_deeper vs v3_podcast). Cards remain available behind a
toggle, remembered across restarts.

Row actions sit behind a menu rather than always-visible icon buttons: at
list density a row is ~48px, too tight for four buttons beside the text.
The selectable area is a real <button> rather than a div with role=button,
because the actions menu is itself a button and nesting interactive
elements is invalid.

Folders
-------
Voices group into flat folders; clips get a nested tree. Both use one
collapsible-section pattern, with an Uncategorised bucket for anything
unfiled. Collapse state persists per kind.

The clip folder tree renders outside the history empty-state branch — if it
did not, filtering to an empty folder would remove the only control able to
clear that filter. Changing the filter drops the accumulated infinite-scroll
pages, which would otherwise leave clips from the previous filter on screen
beneath the new results. Deleting the folder currently being filtered resets
the filter to All, rather than leaving it pointed at something gone.

Both tree builders tolerate a dangling parent/folder id — another window can
delete a folder between renders, and the item should surface at the root
rather than vanish.

Duplicate
---------
A duplicate action on both the row menu and the card, wired to the new
endpoint. Copies samples, avatar, personality, effects and preset fields —
the export/import round-trip users previously had to do by hand keeps only
name, description and language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds voice and generation folders with nesting, CRUD operations, membership assignment, history filtering, persisted UI state, voice list/card views, and voice-profile duplication with copied metadata and samples.

Changes

Folders and profile duplication

Layer / File(s) Summary
Folder data contracts and persistence
backend/config.py, backend/database/..., backend/models.py, app/src/lib/api/types.ts, app/src/stores/uiStore.ts
Adds folder models, nullable profile and generation associations, request/response types, migrations, persisted folder state, and data-directory configuration tests.
Folder APIs and history filtering
backend/routes/folders.py, backend/routes/history.py, backend/services/..., backend/tests/test_folders.py
Adds folder CRUD, hierarchy validation, membership assignment, descendant traversal, and folder-based history filters.
Profile duplication service and endpoint
backend/services/profiles.py, backend/routes/profiles.py, backend/services/export_import.py, backend/tests/test_profile_duplicate.py
Duplicates profile metadata, samples, avatars, and folder assignment with unique names and independent identifiers.
Frontend folder APIs and mutations
app/src/lib/api/client.ts, app/src/lib/hooks/useFolders.ts, app/src/lib/hooks/useProfiles.ts
Adds folder and duplication requests, React Query mutations, cache invalidation, and history filter parameters.
Voice folder and profile views
app/src/components/VoiceProfiles/..., app/src/i18n/locales/en/translation.json
Adds folder sections, card/list rendering, profile movement, duplication, deletion, and localized controls.
Generation folder filtering and actions
app/src/components/History/...
Adds nested generation-folder filtering, pagination reset on selection changes, and generation move actions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProfileList
  participant useFolders
  participant folders_api
  participant Database
  ProfileList->>useFolders: create or update voice folder
  useFolders->>folders_api: send folder mutation
  folders_api->>Database: persist folder change
  Database-->>folders_api: return folder response
  folders_api-->>useFolders: invalidate folder query
  useFolders-->>ProfileList: refresh grouped profiles
Loading

Possibly related PRs

Suggested reviewers: jamiepine

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds clip folders, list view, profile duplication, and VOICEBOX_DATA_DIR changes, which are not required by #821. Split unrelated features into separate PRs or link them to issues that define their requirements.
Docstring Coverage ⚠️ Warning Docstring coverage is 47.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements voice folder and subfolder organization with backend support, UI sections, assignment actions, persistence, and tests [#821].
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: folders for voices and clips, list view support, and profile duplication.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
backend/routes/folders.py (1)

23-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Export a public serializer instead of importing _profile_to_response.

_profile_to_response is named as module-internal to backend/services/profiles.py. Importing it from a route module couples the route to a private symbol, so a rename inside the service breaks this file with no signal at the boundary.

Add a public alias or a thin public function in backend/services/profiles.py, then import that name here.

🤖 Prompt for 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.

In `@backend/routes/folders.py` at line 23, Expose a public profile serializer in
the profiles service by adding a public alias or thin wrapper around
_profile_to_response, then update the folders route import to use that public
symbol instead of the private name. Preserve the serializer’s existing behavior
and response shape.
backend/database/models.py (1)

115-116: 🚀 Performance & Scalability | 🔵 Trivial

Consider indexing generations.folder_id.

list_generations filters on folder_id with equality, IS NULL, and IN (...). The generations table grows with every clip, so this filter runs against the largest table in the schema. An index on folder_id keeps folder filtering and the grouped _member_counts query cheap as history grows.

If you add the index, also add it in _migrate_folders for existing databases, since create_all() only builds indexes for new tables.

⚙️ Proposed index
-    folder_id = Column(String, ForeignKey("folders.id"), nullable=True)
+    folder_id = Column(String, ForeignKey("folders.id"), nullable=True, index=True)
🤖 Prompt for 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.

In `@backend/database/models.py` around lines 115 - 116, Add an index for
generations.folder_id at the column definition, and update _migrate_folders to
create the same index for existing databases. Ensure the index supports
list_generations filtering and the grouped _member_counts query without changing
their behavior.
backend/tests/test_folders.py (1)

265-271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the include_subfolders branch.

These assertions only check that the endpoint returns 200. The descendant branch in backend/services/history.py lines 202-204 is the one folder filter with real logic: it calls folder_and_descendants and matches folder_id IN (...). Nothing here proves a parent folder returns a child folder's clips, and nothing proves include_subfolders=false excludes them.

That branch can break silently — a wrong traversal still returns 200 with the wrong rows. Add a test that creates a parent folder and a child folder, assigns a generation to the child through PUT /history/{generation_id}/folder, then asserts the clip appears when querying the parent with the default and disappears with include_subfolders=false.

🤖 Prompt for 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.

In `@backend/tests/test_folders.py` around lines 265 - 271, Expand
test_history_accepts_folder_filters to create parent and child folders, assign a
generation to the child via PUT /history/{generation_id}/folder, and verify the
generation appears when querying the parent with default include_subfolders
behavior. Add a matching assertion that querying the parent with
include_subfolders=false excludes the child generation, covering the
folder_and_descendants branch rather than only checking status codes.
🤖 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 `@app/src/components/History/HistoryTable.tsx`:
- Around line 716-740: Update the setGenerationFolder mutation calls in the
folder-selection handlers to reset page to zero and clear allHistory in the
mutation success callback after a successful move. Apply this to both the
uncategorised and mapped-folder actions so the active filter reloads from page 0
without retaining the moved clip.

In `@app/src/lib/hooks/useFolders.ts`:
- Around line 44-52: Update the onSuccess handler in useDetachFolder to
invalidate the ['history'] query whenever kind === 'generation', while
preserving the existing ['folders', kind] invalidation for all detach
operations.

In `@backend/models.py`:
- Around line 21-22: Update the name fields in FolderCreate and FolderUpdate to
strip surrounding whitespace before validation, then enforce min_length=1 and
max_length=100 on the stripped value so whitespace-only names are rejected and
stored names remain normalized.

In `@backend/services/profiles.py`:
- Line 755: Update ProfileDuplicateRequest to normalize the provided name before
validation and reject values that become blank after stripping whitespace. Keep
the service call’s get_unique_profile_name behavior unchanged, ensuring
whitespace-only names fail request validation instead of reaching the duplicate
route and producing a 404.
- Line 205: Update create_profile to validate data.folder_id through the shared
folder-validation logic before inserting the profile, requiring the folder to
exist and have kind == "voice". Return the established client error for invalid
or non-voice folders, and only persist folder_id after validation succeeds.

In `@backend/tests/test_data_dir_env.py`:
- Around line 23-25: Update test_data_dir_env.py to import and reload the config
module through the backend package, matching the suite’s established
backend.config module identity; remove the sys.path insertion and top-level
config import, and ensure fixture mutations and reloads target backend.config
consistently.

---

Nitpick comments:
In `@backend/database/models.py`:
- Around line 115-116: Add an index for generations.folder_id at the column
definition, and update _migrate_folders to create the same index for existing
databases. Ensure the index supports list_generations filtering and the grouped
_member_counts query without changing their behavior.

In `@backend/routes/folders.py`:
- Line 23: Expose a public profile serializer in the profiles service by adding
a public alias or thin wrapper around _profile_to_response, then update the
folders route import to use that public symbol instead of the private name.
Preserve the serializer’s existing behavior and response shape.

In `@backend/tests/test_folders.py`:
- Around line 265-271: Expand test_history_accepts_folder_filters to create
parent and child folders, assign a generation to the child via PUT
/history/{generation_id}/folder, and verify the generation appears when querying
the parent with default include_subfolders behavior. Add a matching assertion
that querying the parent with include_subfolders=false excludes the child
generation, covering the folder_and_descendants branch rather than only checking
status codes.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d3a01aa5-a5bb-4d46-90f4-7ea3553057c5

📥 Commits

Reviewing files that changed from the base of the PR and between 51f49de and a70626e.

📒 Files selected for processing (27)
  • app/src/components/History/ClipFolderTree.tsx
  • app/src/components/History/HistoryTable.tsx
  • app/src/components/VoiceProfiles/FolderSection.tsx
  • app/src/components/VoiceProfiles/ProfileCard.tsx
  • app/src/components/VoiceProfiles/ProfileList.tsx
  • app/src/components/VoiceProfiles/ProfileRow.tsx
  • app/src/i18n/locales/en/translation.json
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • app/src/lib/hooks/useFolders.ts
  • app/src/lib/hooks/useProfiles.ts
  • app/src/stores/uiStore.ts
  • backend/config.py
  • backend/database/migrations.py
  • backend/database/models.py
  • backend/models.py
  • backend/routes/__init__.py
  • backend/routes/folders.py
  • backend/routes/history.py
  • backend/routes/profiles.py
  • backend/services/export_import.py
  • backend/services/folders.py
  • backend/services/history.py
  • backend/services/profiles.py
  • backend/tests/test_data_dir_env.py
  • backend/tests/test_folders.py
  • backend/tests/test_profile_duplicate.py

Comment on lines +716 to +740
<DropdownMenuItem
disabled={!gen.folder_id}
onClick={() =>
setGenerationFolder.mutate({
generationId: gen.id,
folderId: null,
})
}
>
{t('folders.uncategorised')}
</DropdownMenuItem>
{(clipFolders ?? []).map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === gen.folder_id}
onClick={() =>
setGenerationFolder.mutate({
generationId: gen.id,
folderId: folder.id,
})
}
>
{folder.name}
</DropdownMenuItem>
))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset accumulated history after a successful folder move.

When page is greater than zero, this mutation invalidates the current history query but does not remove moved clips from allHistory. The history effect appends the refreshed page and preserves a clip that no longer belongs to the selected folder or uncategorised filter.

Reset page and allHistory in the mutation success callback. This forces a fresh page-0 result for the active filter.

Proposed fix
+  const handleMoveToFolder = (generationId: string, folderId: string | null) => {
+    setGenerationFolder.mutate(
+      { generationId, folderId },
+      {
+        onSuccess: () => {
+          setPage(0);
+          setAllHistory([]);
+        },
+      },
+    );
+  };
+
- onClick={() => setGenerationFolder.mutate({ generationId: gen.id, folderId: null })}
+ onClick={() => handleMoveToFolder(gen.id, null)}

- onClick={() => setGenerationFolder.mutate({ generationId: gen.id, folderId: folder.id })}
+ onClick={() => handleMoveToFolder(gen.id, folder.id)}
📝 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
<DropdownMenuItem
disabled={!gen.folder_id}
onClick={() =>
setGenerationFolder.mutate({
generationId: gen.id,
folderId: null,
})
}
>
{t('folders.uncategorised')}
</DropdownMenuItem>
{(clipFolders ?? []).map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === gen.folder_id}
onClick={() =>
setGenerationFolder.mutate({
generationId: gen.id,
folderId: folder.id,
})
}
>
{folder.name}
</DropdownMenuItem>
))}
const handleMoveToFolder = (generationId: string, folderId: string | null) => {
setGenerationFolder.mutate(
{ generationId, folderId },
{
onSuccess: () => {
setPage(0);
setAllHistory([]);
},
},
);
};
<DropdownMenuItem
disabled={!gen.folder_id}
onClick={() => handleMoveToFolder(gen.id, null)}
>
{t('folders.uncategorised')}
</DropdownMenuItem>
{(clipFolders ?? []).map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === gen.folder_id}
onClick={() => handleMoveToFolder(gen.id, folder.id)}
>
{folder.name}
</DropdownMenuItem>
))}
🤖 Prompt for 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.

In `@app/src/components/History/HistoryTable.tsx` around lines 716 - 740, Update
the setGenerationFolder mutation calls in the folder-selection handlers to reset
page to zero and clear allHistory in the mutation success callback after a
successful move. Apply this to both the uncategorised and mapped-folder actions
so the active filter reloads from page 0 without retaining the moved clip.

Comment thread app/src/lib/hooks/useFolders.ts
Comment thread backend/models.py Outdated
Comment thread backend/services/profiles.py
raise ValueError(f"Profile {profile_id} not found")

new_id = str(uuid.uuid4())
new_name = get_unique_profile_name(name.strip() if name else f"{source.name} (copy)", db)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject whitespace-only duplicate names.

ProfileDuplicateRequest accepts " " because its raw length is nonzero. Line 755 then converts it to "", and the duplicate can persist with an empty name.

Normalize and reject blank names in ProfileDuplicateRequest before this service call. This also avoids converting a validation error into the route's current 404 response. Based on the provided model context, ProfileDuplicateRequest validates raw length only.

🤖 Prompt for 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.

In `@backend/services/profiles.py` at line 755, Update ProfileDuplicateRequest to
normalize the provided name before validation and reject values that become
blank after stripping whitespace. Keep the service call’s
get_unique_profile_name behavior unchanged, ensuring whitespace-only names fail
request validation instead of reaching the duplicate route and producing a 404.

Comment on lines +23 to +25
sys.path.insert(0, str(Path(__file__).parent.parent))

import config # noqa: E402

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare sys.path bootstrapping and config import forms across backend tests.
set -euo pipefail

fd -e py . backend/tests --exec rg -n 'sys\.path\.insert|^\s*(import|from)\s+(backend\.)?config' {} \; -x true 2>/dev/null || true

echo '--- direct config imports ---'
rg -n --type=py -C2 '^\s*(import config|from config import|from backend import config|from \.\.? import config|import backend\.config)' backend

Repository: jamiepine/voicebox

Length of output: 9308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- backend/tests/test_data_dir_env.py ---'
cat -n backend/tests/test_data_dir_env.py

echo '--- config source outline/imports ---'
fd -e py '^(config|__init__|conftest|server\.py)$' backend tests -x sh -c 'echo "### $1"; wc -l "$1"; ast-grep outline "$1" 2>/dev/null | head -80 || true' sh {}

echo '--- reload references ---'
rg -n --type=py 'importlib\.reload|reload\(' backend tests

Repository: jamiepine/voicebox

Length of output: 4369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- backend tests list ---'
git ls-files backend/tests | sort

echo '--- config.py ---'
cat -n backend/config.py

echo '--- all config import lines across repository ---'
rg -n --type=py '(^|[^A-Za-z0-9_.])(import (config|backend)|from (config|backend) import|from \.\.?backend import|from \. import config|import backend\.config|from backend import config)' || true

echo '--- pytest configuration ---'
fd -e ini -e toml -e cfg -e json -e yaml -e yml . backend --exec sh -c 'echo "### $1"; sed -n "1,160p" "$1"' sh {}

Repository: jamiepine/voicebox

Length of output: 7825


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
"""
Read-only semantic probe of the module identity produced by the test's bootstrapping.
The probe only prints names and attributes from the bootstrap environment; it does
not import backend/config or run repository code.
"""
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
backend = ROOT / "backend"
assert backend.exists(), "sandbox path not present"

sys.path.insert(0, str(backend))
top_level_import = any(
    part == "backend" and next_part == "config"
    for part, next_part in zip(("backend", "config"), ("backend", "config"))
)

print("backend_dir_exists:", backend.exists())
print("backend_on_path:", (sys.path[0] == str(backend)))
print("config_file:", (backend / "config.py").exists())
print("current_module_name:", __name__)
print("module_attribute_name:", "module" in globals())

PY

echo '--- pytest discovery settings ---'
rg -n --type=ini -C3 'pythonpath|testpaths|addopts|configfile|asyncio' . || true

Repository: jamiepine/voicebox

Length of output: 346


Import and reload backend.config consistently.

test_data_dir_env.py adds backend/ to sys.path and reloads the top-level config module, while the rest of the code/test suite imports config through the backend package (from backend import config, from .. import config, or relative ..imports). In a single pytest process, use the same module identity for all reloads to avoid keeping one backend.config object stale while this fixture mutates another config object.

🤖 Prompt for 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.

In `@backend/tests/test_data_dir_env.py` around lines 23 - 25, Update
test_data_dir_env.py to import and reload the config module through the backend
package, matching the suite’s established backend.config module identity; remove
the sys.path insertion and top-level config import, and ensure fixture mutations
and reloads target backend.config consistently.

Five findings from the review of jamiepine#1005.

Folder assignment on profile creation
-------------------------------------
`create_profile` persisted `folder_id` without checking it. The
folder-assignment endpoint enforces that a voice only lands in a voice
folder, but creation bypassed that entirely — a client could file a new
profile straight into a clip folder, or reference a folder that does not
exist. Same two checks, raised as ValueError so the route returns 400 like
its neighbours.

Whitespace-only names
---------------------
`"   "` passed `min_length=1` because the raw string is three characters
long, and the route then stored `.strip()` — an empty label. Same for the
duplicate-profile override, which could produce a copy with no name at all.
Both now use a `TrimmedName` type that strips first and length-checks the
result. Left `AudioChannelUpdate` and `EffectPresetUpdate` alone; they have
the same gap but are not this PR's concern.

Moved clips lingering in the list
---------------------------------
Moving a clip to another folder invalidated the history query but left
`allHistory` intact. Past page 0 the refreshed first page is appended rather
than replacing the list, so the clip stayed visible in a folder it no longer
belonged to. Folder moves now go through one handler that drops back to page
0, which is what makes the next response replace the list.

Detaching a folder
------------------
`useDetachFolder` invalidated only the folder tree. Member lists include
subfolders, so anyone filtered by the former parent kept seeing the detached
child's items until an unrelated refetch.

The sixth finding — the flat `config` import in test_data_dir_env — is fixed
on the jamiepine#1004 branch and resolves here on merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lvigentini pushed a commit to Lvigentini/voicebox that referenced this pull request Aug 9, 2026
Five findings from the review of jamiepine#1005.

Folder assignment on profile creation
-------------------------------------
`create_profile` persisted `folder_id` without checking it. The
folder-assignment endpoint enforces that a voice only lands in a voice
folder, but creation bypassed that entirely — a client could file a new
profile straight into a clip folder, or reference a folder that does not
exist. Same two checks, raised as ValueError so the route returns 400 like
its neighbours.

Whitespace-only names
---------------------
`"   "` passed `min_length=1` because the raw string is three characters
long, and the route then stored `.strip()` — an empty label. Same for the
duplicate-profile override, which could produce a copy with no name at all.
Both now use a `TrimmedName` type that strips first and length-checks the
result. Left `AudioChannelUpdate` and `EffectPresetUpdate` alone; they have
the same gap but are not this PR's concern.

Moved clips lingering in the list
---------------------------------
Moving a clip to another folder invalidated the history query but left
`allHistory` intact. Past page 0 the refreshed first page is appended rather
than replacing the list, so the clip stayed visible in a folder it no longer
belonged to. Folder moves now go through one handler that drops back to page
0, which is what makes the next response replace the list.

Detaching a folder
------------------
`useDetachFolder` invalidated only the folder tree. Member lists include
subfolders, so anyone filtered by the former parent kept seeing the detached
child's items until an unrelated refetch.

The sixth finding — the flat `config` import in test_data_dir_env — is fixed
on the jamiepine#1004 branch and resolves here on merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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)
backend/services/profiles.py (1)

140-157: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Handle duplicate-name conflicts during profile duplication.

DBVoiceProfile.name has unique=True, but get_unique_profile_name() can pass the same candidate name to multiple concurrent duplicate_profile() calls. Those calls can add rows at the same name and then raise IntegrityError at db.commit(); the route currently catches only ValueError, so duplicate-profile requests become 500. Catch and retry the conflict with a fresh allocation in duplicate_profile(), or serialize the candidate check insert into one transaction.

🤖 Prompt for 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.

In `@backend/services/profiles.py` around lines 140 - 157, Update
duplicate_profile to handle concurrent DBVoiceProfile.name uniqueness conflicts:
catch IntegrityError from the commit, roll back the session, allocate a fresh
name via get_unique_profile_name, and retry the duplication without converting
the request to a 500. Preserve the existing ValueError handling and ensure
retries do not leave the failed transaction state active.
🧹 Nitpick comments (1)
backend/models.py (1)

5-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the standard-library Annotated import.

The Python target is now >=3.12, so import Annotated from typing.

Diff
-from typing_extensions import Annotated
+from typing import Annotated
🤖 Prompt for 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.

In `@backend/models.py` around lines 5 - 6, Update the Annotated import used by
the model definitions to come from the standard-library typing module, while
retaining the existing Pydantic imports and usage.

Source: Linters/SAST tools

🤖 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.

Outside diff comments:
In `@backend/services/profiles.py`:
- Around line 140-157: Update duplicate_profile to handle concurrent
DBVoiceProfile.name uniqueness conflicts: catch IntegrityError from the commit,
roll back the session, allocate a fresh name via get_unique_profile_name, and
retry the duplication without converting the request to a 500. Preserve the
existing ValueError handling and ensure retries do not leave the failed
transaction state active.

---

Nitpick comments:
In `@backend/models.py`:
- Around line 5-6: Update the Annotated import used by the model definitions to
come from the standard-library typing module, while retaining the existing
Pydantic imports and usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 13f7d311-fafb-4a42-8693-b6d906299c2e

📥 Commits

Reviewing files that changed from the base of the PR and between a70626e and cbfb69d.

📒 Files selected for processing (4)
  • app/src/components/History/HistoryTable.tsx
  • app/src/lib/hooks/useFolders.ts
  • backend/models.py
  • backend/services/profiles.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/src/lib/hooks/useFolders.ts
  • app/src/components/History/HistoryTable.tsx

@Lvigentini
Lvigentini force-pushed the feat/ui-organisation branch from cbfb69d to b74eb6a Compare August 9, 2026 13:46
Lvigentini added a commit to Lvigentini/voicebox that referenced this pull request Aug 9, 2026
Five findings from the review of jamiepine#1005.

Folder assignment on profile creation
-------------------------------------
`create_profile` persisted `folder_id` without checking it. The
folder-assignment endpoint enforces that a voice only lands in a voice
folder, but creation bypassed that entirely — a client could file a new
profile straight into a clip folder, or reference a folder that does not
exist. Same two checks, raised as ValueError so the route returns 400 like
its neighbours.

Whitespace-only names
---------------------
`"   "` passed `min_length=1` because the raw string is three characters
long, and the route then stored `.strip()` — an empty label. Same for the
duplicate-profile override, which could produce a copy with no name at all.
Both now use a `TrimmedName` type that strips first and length-checks the
result. Left `AudioChannelUpdate` and `EffectPresetUpdate` alone; they have
the same gap but are not this PR's concern.

Moved clips lingering in the list
---------------------------------
Moving a clip to another folder invalidated the history query but left
`allHistory` intact. Past page 0 the refreshed first page is appended rather
than replacing the list, so the clip stayed visible in a folder it no longer
belonged to. Folder moves now go through one handler that drops back to page
0, which is what makes the next response replace the list.

Detaching a folder
------------------
`useDetachFolder` invalidated only the folder tree. Member lists include
subfolders, so anyone filtered by the former parent kept seeing the detached
child's items until an unrelated refetch.

The sixth finding — the flat `config` import in test_data_dir_env — is fixed
on the jamiepine#1004 branch and resolves here on merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

VOICEBOX_DATA_DIR is ignored by the desktop app (docs say it overrides the data directory) Feature request: Folders/Subfolders for Voices

1 participant