From b92adda64f53ccf5dcb893064acc9d3f480e975e Mon Sep 17 00:00:00 2001 From: deep1401 Date: Mon, 4 May 2026 14:27:54 -0600 Subject: [PATCH 1/8] init revamp of experiment ui --- ...10f132_add_user_experiment_access_table.py | 52 +++ api/test/test_experiment_access_service.py | 45 +++ .../routers/experiment/experiment.py | 159 +++++++-- .../services/experiment_access_service.py | 48 +++ .../services/experiment_service.py | 19 +- api/transformerlab/shared/models/models.py | 19 + .../Experiment/ExperimentsManagerModal.tsx | 328 ++++++++++++++++++ .../Experiment/RenameExperimentModal.tsx | 96 +++++ .../Experiment/SelectExperimentMenu.tsx | 53 ++- .../Experiment/ShareExperimentModal.tsx | 229 ++++++++++++ .../Experiment/Tasks/JobsChartModal.tsx | 3 +- src/renderer/lib/api-client/endpoints.ts | 3 + 12 files changed, 1018 insertions(+), 36 deletions(-) create mode 100644 api/alembic/versions/46378c10f132_add_user_experiment_access_table.py create mode 100644 api/test/test_experiment_access_service.py create mode 100644 api/transformerlab/services/experiment_access_service.py create mode 100644 src/renderer/components/Experiment/ExperimentsManagerModal.tsx create mode 100644 src/renderer/components/Experiment/RenameExperimentModal.tsx create mode 100644 src/renderer/components/Experiment/ShareExperimentModal.tsx diff --git a/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py new file mode 100644 index 0000000000..1422f06ce8 --- /dev/null +++ b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py @@ -0,0 +1,52 @@ +"""add_user_experiment_access_table + +Revision ID: 46378c10f132 +Revises: 6ccd4a4d9ca1 +Create Date: 2026-05-04 13:23:27.122716 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from transformerlab.db.migration_utils import table_exists + + +# revision identifiers, used by Alembic. +revision: str = '46378c10f132' +down_revision: Union[str, Sequence[str], None] = '6ccd4a4d9ca1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + connection = op.get_bind() + if not table_exists(connection, "user_experiment_access"): + op.create_table( + "user_experiment_access", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("team_id", sa.String(), nullable=False), + sa.Column("experiment_id", sa.String(), nullable=False), + sa.Column( + "last_opened_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", "team_id", "experiment_id", + name="uq_user_experiment_access", + ), + ) + op.create_index( + "idx_user_experiment_access_user_team", + "user_experiment_access", + ["user_id", "team_id"], + ) + + +def downgrade() -> None: + op.drop_index("idx_user_experiment_access_user_team", table_name="user_experiment_access", if_exists=True) + op.drop_table("user_experiment_access", if_exists=True) diff --git a/api/test/test_experiment_access_service.py b/api/test/test_experiment_access_service.py new file mode 100644 index 0000000000..bddcfa0902 --- /dev/null +++ b/api/test/test_experiment_access_service.py @@ -0,0 +1,45 @@ +from unittest.mock import AsyncMock, MagicMock +from datetime import datetime, timezone + +import transformerlab.services.experiment_access_service as svc + + +async def test_touch_experiment_upserts_record(): + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = None + mock_session.execute.return_value = mock_result + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + +async def test_touch_experiment_updates_existing_record(): + mock_session = AsyncMock() + existing = MagicMock() + existing.last_opened_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = existing + mock_session.execute.return_value = mock_result + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + assert existing.last_opened_at > datetime(2024, 1, 1, tzinfo=timezone.utc) + mock_session.commit.assert_called_once() + + +async def test_get_recent_experiment_ids_returns_ordered_list(): + mock_session = AsyncMock() + record1 = MagicMock() + record1.experiment_id = "exp_b" + record2 = MagicMock() + record2.experiment_id = "exp_a" + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [record1, record2] + mock_session.execute.return_value = mock_result + + result = await svc.get_recent_experiment_ids(mock_session, "user1", "team1", limit=3) + + assert result == ["exp_b", "exp_a"] diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index 11a1fca998..dc48a642b2 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -2,10 +2,11 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession import transformerlab.services.experiment_service as experiment_service +import transformerlab.services.experiment_access_service as access_service from lab import Experiment, storage from transformerlab.shared import shared from transformerlab.routers.experiment import ( @@ -16,7 +17,8 @@ ) from transformerlab.routers.auth import get_user_and_team from transformerlab.services.permission_service import check_permission, get_user_team, require_permission -from transformerlab.shared.models.models import TeamRole +from sqlalchemy import select +from transformerlab.shared.models.models import TeamRole, UserExperimentAccess from transformerlab.shared.models.user_model import get_async_session from werkzeug.utils import secure_filename @@ -54,7 +56,7 @@ async def experiments_get_all( session: AsyncSession = Depends(get_async_session), user_and_team: dict = Depends(get_user_and_team), ): - """Get a list of all experiments""" + """Get a list of all experiments, filtered by role, with per-user last_opened_at.""" experiments = await experiment_service.experiment_get_all() user = user_and_team["user"] team_id = user_and_team["team_id"] @@ -63,34 +65,130 @@ async def experiments_get_all( user_team = await get_user_team(session, user_id, team_id) if user_team is None: return [] + + # Role-based filtering (existing logic) if user_team.role == TeamRole.OWNER.value: - return experiments - - filtered_experiments = [] - for experiment in experiments: - experiment_id = str(experiment.get("id")) - if not experiment_id: - continue - allowed = await check_permission( - session=session, - user_id=user_id, - team_id=team_id, - resource_type="experiment", - resource_id=experiment_id, - action="read", - user_team=user_team, + filtered = experiments + else: + filtered = [] + for experiment in experiments: + experiment_id = str(experiment.get("id")) + if not experiment_id: + continue + allowed = await check_permission( + session=session, + user_id=user_id, + team_id=team_id, + resource_type="experiment", + resource_id=experiment_id, + action="read", + user_team=user_team, + ) + if allowed: + filtered.append(experiment) + + # Attach per-user last_opened_at + access_records = await session.execute( + select(UserExperimentAccess).where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, ) - if allowed: - filtered_experiments.append(experiment) - return filtered_experiments + ) + access_map = {row.experiment_id: row.last_opened_at.isoformat() for row in access_records.scalars().all()} + + for exp in filtered: + exp_id = str(exp.get("id", "")) + exp["last_opened_at"] = access_map.get(exp_id) + + return filtered + + +@router.post("/{id}/touch", summary="Record experiment opened", tags=["experiment"]) +async def experiment_touch( + id: str, + session: AsyncSession = Depends(get_async_session), + user_and_team: dict = Depends(get_user_and_team), + _: None = Depends(require_permission("experiment", "read")), +): + user_id = str(user_and_team["user"].id) + team_id = str(user_and_team["team_id"]) + await access_service.touch_experiment(session, user_id, team_id, id) + return {"status": "ok"} + + +@router.get("/recent", summary="Get recently opened experiments", tags=["experiment"]) +async def experiments_get_recent( + session: AsyncSession = Depends(get_async_session), + user_and_team: dict = Depends(get_user_and_team), +): + """Return last 3 experiments opened by the current user that the user still has access to. + Falls back to 3 permitted experiments if no access records exist.""" + user = user_and_team["user"] + team_id = str(user_and_team["team_id"]) + user_id = str(user.id) + + user_team = await get_user_team(session, user_id, team_id) + if user_team is None: + return [] + + recent_ids = await access_service.get_recent_experiment_ids(session, user_id, team_id, limit=3) + + # Fetch and permission-filter recent experiments + results = [] + for exp_id in recent_ids: + if user_team.role != TeamRole.OWNER.value: + allowed = await check_permission( + session=session, + user_id=user_id, + team_id=team_id, + resource_type="experiment", + resource_id=exp_id, + action="read", + user_team=user_team, + ) + if not allowed: + continue + data = await experiment_service.experiment_get(exp_id) + if data: + results.append(data) + + # Fallback: if no recent records, return first 3 permitted experiments + if not results: + all_experiments = await experiment_service.experiment_get_all() + for exp in all_experiments: + exp_id = str(exp.get("id", "")) + if not exp_id: + continue + if user_team.role == TeamRole.OWNER.value: + results.append(exp) + else: + allowed = await check_permission( + session=session, + user_id=user_id, + team_id=team_id, + resource_type="experiment", + resource_id=exp_id, + action="read", + user_team=user_team, + ) + if allowed: + results.append(exp) + if len(results) >= 3: + break + + return results @router.get("/create", summary="Create Experiment", tags=["experiment"]) -async def experiments_create(name: str): +async def experiments_create( + name: str, + user_and_team: dict = Depends(get_user_and_team), +): # Apply secure filename validation to the experiment name secure_name = secure_filename(name) + user_id = str(user_and_team["user"].id) - newid = await experiment_service.experiment_create(secure_name, {}) + newid = await experiment_service.experiment_create(secure_name, {}, created_by=user_id) return newid @@ -117,6 +215,21 @@ async def experiments_delete( return {"message": f"Experiment {id} deleted"} +@router.patch("/{id}/rename", summary="Rename an experiment", tags=["experiment"]) +async def experiment_rename( + id: str, + body: Annotated[dict, Body()], + _: None = Depends(require_permission("experiment", "write")), +): + new_name = body.get("name", "").strip() + if not new_name: + raise HTTPException(status_code=422, detail="name is required") + result = await experiment_service.experiment_rename(id, new_name) + if result is None: + raise HTTPException(status_code=404, detail=f"Experiment {id} not found") + return result + + @router.get("/{id}/update", tags=["experiment"]) async def experiments_update( id: str, diff --git a/api/transformerlab/services/experiment_access_service.py b/api/transformerlab/services/experiment_access_service.py new file mode 100644 index 0000000000..f0bf67b04e --- /dev/null +++ b/api/transformerlab/services/experiment_access_service.py @@ -0,0 +1,48 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from transformerlab.shared.models.models import UserExperimentAccess + +logger = logging.getLogger(__name__) + + +async def touch_experiment(session: AsyncSession, user_id: str, team_id: str, experiment_id: str) -> None: + """Upsert last_opened_at for a user-experiment pair.""" + result = await session.execute( + select(UserExperimentAccess).where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, + UserExperimentAccess.experiment_id == experiment_id, + ) + ) + record = result.scalars().first() + + if record is None: + record = UserExperimentAccess( + user_id=user_id, + team_id=team_id, + experiment_id=experiment_id, + last_opened_at=datetime.now(timezone.utc), + ) + session.add(record) + else: + record.last_opened_at = datetime.now(timezone.utc) + + await session.commit() + + +async def get_recent_experiment_ids(session: AsyncSession, user_id: str, team_id: str, limit: int = 3) -> list[str]: + """Return experiment IDs ordered by last_opened_at DESC for a user.""" + result = await session.execute( + select(UserExperimentAccess) + .where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, + ) + .order_by(UserExperimentAccess.last_opened_at.desc()) + .limit(limit) + ) + return [row.experiment_id for row in result.scalars().all()] diff --git a/api/transformerlab/services/experiment_service.py b/api/transformerlab/services/experiment_service.py index 4740ac68b6..251e3ffa20 100644 --- a/api/transformerlab/services/experiment_service.py +++ b/api/transformerlab/services/experiment_service.py @@ -3,6 +3,7 @@ import json import os +from werkzeug.utils import secure_filename from lab import Experiment from lab import dirs as lab_dirs from lab import storage @@ -100,7 +101,9 @@ async def _read_with_limit(exp_path: str) -> dict | None: return experiments -async def experiment_create(name: str, config: dict) -> str: +async def experiment_create(name: str, config: dict, created_by: str | None = None) -> str: + if created_by: + config = {**config, "created_by": created_by} await Experiment.create_with_config(name, config) # Ensure the experiment dropdown refreshes immediately after creation. await cache.invalidate("experiments") @@ -138,6 +141,20 @@ async def experiment_delete(id): print(f"Error deleting experiment {id}: {e}") +async def experiment_rename(id: str, new_name: str) -> dict | None: + """Update the display name of an experiment in index.json.""" + secure_name = secure_filename(new_name) + if not secure_name: + raise ValueError("Invalid experiment name") + try: + exp = await Experiment.get(id) + await exp.update_config({"name": secure_name}) + await cache.invalidate("experiments") + return await experiment_get(id) + except FileNotFoundError: + return None + + async def experiment_update(id, config): try: exp = await Experiment.get(id) diff --git a/api/transformerlab/shared/models/models.py b/api/transformerlab/shared/models/models.py index 5a2d3deed1..41bf88d2dd 100644 --- a/api/transformerlab/shared/models/models.py +++ b/api/transformerlab/shared/models/models.py @@ -345,3 +345,22 @@ class JobQueue(Base): Index("idx_job_queue_status_type", "status", "queue_type"), Index("idx_job_queue_job_id", "job_id"), ) + + +class UserExperimentAccess(Base): + """Tracks when each user last opened each experiment.""" + + __tablename__ = "user_experiment_access" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(String, nullable=False) + team_id: Mapped[str] = mapped_column(String, nullable=False) + experiment_id: Mapped[str] = mapped_column(String, nullable=False) + last_opened_at: Mapped[DateTime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint("user_id", "team_id", "experiment_id", name="uq_user_experiment_access"), + Index("idx_user_experiment_access_user_team", "user_id", "team_id"), + ) diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx new file mode 100644 index 0000000000..74edeaa439 --- /dev/null +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -0,0 +1,328 @@ +import { + Box, + Button, + Chip, + CircularProgress, + Divider, + IconButton, + Input, + Modal, + ModalClose, + ModalDialog, + Table, + Tooltip, + Typography, +} from '@mui/joy'; +import { PencilIcon, Share2Icon, Trash2Icon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + useSWRWithAuth as useSWR, + useAuth, + useAPI, +} from 'renderer/lib/authContext'; +import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; +import { fetcher } from 'renderer/lib/transformerlab-api-sdk'; +import RenameExperimentModal from './RenameExperimentModal'; +import ShareExperimentModal from './ShareExperimentModal'; + +interface Experiment { + id: string; + name: string; + last_opened_at: string | null; + config?: { created_by?: string }; +} + +interface TeamMember { + user_id: string; + email?: string; + role: string; +} + +interface ExperimentsManagerModalProps { + open: boolean; + onClose: () => void; + onExperimentSelect: (experimentId: string) => void; + onNewExperiment: () => void; + mutateRecent: () => void; +} + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return 'Never'; + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + // Server clock slightly ahead of client yields negative diffDays ("-1 days ago"). + if (diffDays <= 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString(); +} + +export default function ExperimentsManagerModal({ + open, + onClose, + onExperimentSelect, + onNewExperiment, + mutateRecent, +}: ExperimentsManagerModalProps) { + const { user, team } = useAuth(); + const [search, setSearch] = useState(''); + const [renameTarget, setRenameTarget] = useState(null); + const [shareTarget, setShareTarget] = useState(null); + + const { + data: experiments, + isLoading, + mutate, + } = useSWR( + open && chatAPI.API_URL() !== null + ? chatAPI.Endpoints.Experiment.GetAll() + : null, + fetcher, + ); + + // Fetch team members for ShareExperimentModal (matches Team.tsx pattern) + const { data: membersData } = useAPI('teams', ['getMembers'], { + teamId: team?.id ?? null, + }); + const members: TeamMember[] = Array.isArray(membersData?.members) + ? membersData.members + : []; + + // Determine if current user is team owner (admin) + const isAdmin = useMemo( + () => members.some((m) => m.user_id === user?.id && m.role === 'owner'), + [members, user?.id], + ); + + const currentUserId = user?.id as string | undefined; + + const filtered = useMemo(() => { + if (!Array.isArray(experiments)) return []; + const q = search.toLowerCase(); + return experiments.filter((e: Experiment) => + e.name?.toLowerCase().includes(q), + ); + }, [experiments, search]); + + const canManage = (exp: Experiment) => + isAdmin || exp.config?.created_by === currentUserId; + + const handleDelete = async (exp: Experiment) => { + if ( + !confirm( + `Are you sure you want to delete "${exp.name}"? This cannot be undone.`, + ) + ) + return; + await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Delete(exp.id), + {}, + ); + mutate(); + mutateRecent(); + }; + + const handleOpen = (exp: Experiment) => { + onExperimentSelect(exp.id); + onClose(); + }; + + return ( + <> + + + + + Experiments + + + setSearch(e.target.value)} + sx={{ mb: 2 }} + /> + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + {search + ? 'No experiments match your search.' + : 'No experiments yet.'} + + ) : ( + + + + + + + + + + + + + {filtered.map((exp: Experiment) => { + const ownerLabel = + exp.config?.created_by === currentUserId + ? 'you' + : exp.config?.created_by + ? exp.config.created_by.slice(0, 8) + '…' + : 'unknown'; + + return ( + + + + + + + + ); + })} + +
NameOwnerLast OpenedSharingActions
+ handleOpen(exp)} + > + {exp.name} + + + + {ownerLabel} + + + + {formatRelativeTime(exp.last_opened_at)} + + + {isAdmin ? ( + + {canManage(exp) ? 'owned' : 'team'} + + ) : exp.config?.created_by === currentUserId ? ( + + yours + + ) : ( + + shared + + )} + + + + {canManage(exp) && ( + <> + + setShareTarget(exp)} + > + + + + + setRenameTarget(exp)} + > + + + + + handleDelete(exp)} + > + + + + + )} + +
+
+ )} +
+
+ + {renameTarget && ( + setRenameTarget(null)} + onRenamed={() => { + mutate(); + mutateRecent(); + setRenameTarget(null); + }} + /> + )} + + {shareTarget && ( + setShareTarget(null)} + /> + )} + + ); +} diff --git a/src/renderer/components/Experiment/RenameExperimentModal.tsx b/src/renderer/components/Experiment/RenameExperimentModal.tsx new file mode 100644 index 0000000000..ebfd8ee590 --- /dev/null +++ b/src/renderer/components/Experiment/RenameExperimentModal.tsx @@ -0,0 +1,96 @@ +import { + Button, + Divider, + Input, + Modal, + ModalClose, + ModalDialog, + Sheet, + Typography, +} from '@mui/joy'; +import { useState } from 'react'; +import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; + +interface RenameExperimentModalProps { + open: boolean; + experimentId: string; + currentName: string; + onClose: () => void; + onRenamed: (newName: string) => void; +} + +export default function RenameExperimentModal({ + open, + experimentId, + currentName, + onClose, + onRenamed, +}: RenameExperimentModalProps) { + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const newName = (formData.get('name') as string)?.trim(); + if (!newName || newName === currentName) { + onClose(); + return; + } + setSaving(true); + setError(null); + try { + const res = await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Rename(experimentId), + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Rename failed'); + } + const updated = await res.json(); + onRenamed(updated.name ?? newName); + onClose(); + } catch (err: any) { + setError(err?.message || 'Failed to rename experiment'); + } finally { + setSaving(false); + } + }; + + return ( + + + + Rename Experiment + + {error && ( + + {error} + + )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/renderer/components/Experiment/SelectExperimentMenu.tsx b/src/renderer/components/Experiment/SelectExperimentMenu.tsx index 2dcfd44571..37c3581a8f 100644 --- a/src/renderer/components/Experiment/SelectExperimentMenu.tsx +++ b/src/renderer/components/Experiment/SelectExperimentMenu.tsx @@ -4,6 +4,7 @@ import MenuItem from '@mui/joy/MenuItem'; import { CheckIcon, ChevronDownIcon, + LayoutGridIcon, PlusCircleIcon, SettingsIcon, StopCircleIcon, @@ -35,6 +36,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; import { getAPIFullPath, fetcher } from 'renderer/lib/transformerlab-api-sdk'; import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext'; +import ExperimentsManagerModal from './ExperimentsManagerModal'; function ExperimentSettingsMenu({ experimentInfo, @@ -98,6 +100,7 @@ function ExperimentSettingsMenu({ export default function SelectExperimentMenu({ models }) { const [modalOpen, setModalOpen] = useState(false); + const [isManagerOpen, setIsManagerOpen] = useState(false); const navigate = useNavigate(); const location = useLocation(); const { experimentInfo, setExperimentId } = useExperimentInfo(); @@ -105,7 +108,7 @@ export default function SelectExperimentMenu({ models }) { // This gets all the available experiments const { data, isLoading, mutate } = useSWR( - chatAPI.API_URL() === null ? null : chatAPI.Endpoints.Experiment.GetAll(), + chatAPI.API_URL() === null ? null : chatAPI.Endpoints.Experiment.Recent(), fetcher, ); @@ -127,8 +130,17 @@ export default function SelectExperimentMenu({ models }) { mutate(); }, [experimentInfo]); - const createHandleClose = (experimentId: string | number) => () => { + const createHandleClose = (experimentId: string | number) => async () => { setExperimentId(String(experimentId)); + try { + await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Touch(String(experimentId)), + { method: 'POST' }, + ); + mutate(); + } catch { + // non-critical, don't block the switch + } // If currently on an experiment page, update the URL to reflect the new experiment const match = location.pathname.match(/^\/experiment\/[^/]+\/(.+)$/); if (match) { @@ -175,21 +187,21 @@ export default function SelectExperimentMenu({ models }) { newId = responseJson?.data?.experiment_id; } - // After creation, refresh the list and ensure the new experiment is in it before updating state - await mutate(); - const updatedData = await mutate(); // Wait for mutate to complete and get fresh data - const newExperimentExists = updatedData?.some( - (exp: any) => exp.id === newId, + // Recent dropdown only shows a few experiments; membership there is not a valid + // "exists" check. Confirm the experiment is readable, then refresh the menu. + const existsRes = await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Get(String(newId)), ); - if (!newExperimentExists) { + if (!existsRes.ok) { alert( - 'Experiment created, but failed to load in the list. Please refresh and try again.', + 'Experiment created, but it could not be loaded. Please refresh and try again.', ); return; } + await mutate(); setExperimentId(String(newId)); - createHandleClose(newId)(); + void createHandleClose(String(newId))(); // Navigate to Notes page if experiment was created from a recipe AND recipe is not blank if (fromRecipeId !== null && fromRecipeId !== -1) { @@ -369,7 +381,7 @@ export default function SelectExperimentMenu({ models }) { ? 'soft' : undefined } - onClick={createHandleClose(experiment.name)} + onClick={createHandleClose(experiment.id)} key={experiment.id} sx={{ display: 'flex', @@ -398,6 +410,12 @@ export default function SelectExperimentMenu({ models }) { ); })} + setIsManagerOpen(true)}> + + + + See all experiments + setModalOpen(true)} disabled={isLoading}> @@ -455,6 +473,19 @@ export default function SelectExperimentMenu({ models }) { + setIsManagerOpen(false)} + onExperimentSelect={(experimentId: string) => { + createHandleClose(experimentId)(); + setIsManagerOpen(false); + }} + onNewExperiment={() => { + setIsManagerOpen(false); + setModalOpen(true); + }} + mutateRecent={mutate} + /> ); } diff --git a/src/renderer/components/Experiment/ShareExperimentModal.tsx b/src/renderer/components/Experiment/ShareExperimentModal.tsx new file mode 100644 index 0000000000..015f6fc371 --- /dev/null +++ b/src/renderer/components/Experiment/ShareExperimentModal.tsx @@ -0,0 +1,229 @@ +import { + Button, + CircularProgress, + Divider, + IconButton, + Modal, + ModalClose, + ModalDialog, + Option, + Select, + Stack, + Table, + Typography, +} from '@mui/joy'; +import { Trash2Icon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useAuth } from 'renderer/lib/authContext'; + +type PermissionLevel = 'read' | 'read_write' | 'admin'; + +const LEVEL_ACTIONS: Record = { + read: ['read'], + read_write: ['read', 'write', 'execute'], + admin: ['read', 'write', 'execute', 'delete', 'admin'], +}; + +const LEVEL_LABELS: Record = { + read: 'Read', + read_write: 'Read + Write', + admin: 'Admin', +}; + +interface ShareRule { + id: string; + user_id: string; + resource_type: string; + resource_id: string; + actions: string[]; +} + +interface TeamMember { + user_id: string; + email?: string; + role: string; +} + +interface ShareExperimentModalProps { + open: boolean; + experimentId: string; + experimentName: string; + members: TeamMember[]; + onClose: () => void; +} + +export default function ShareExperimentModal({ + open, + experimentId, + experimentName, + members, + onClose, +}: ShareExperimentModalProps) { + const { team, fetchWithAuth } = useAuth(); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(''); + const [selectedLevel, setSelectedLevel] = useState('read'); + + const nonOwnerMembers = members.filter((m) => m.role !== 'owner'); + + useEffect(() => { + if (!open || !team?.id) return; + setLoading(true); + // GET /teams/{teamId}/permissions returns all team rules — filter by this experiment + fetchWithAuth(`teams/${team.id}/permissions`) + .then((res) => res.json()) + .then((data: { permissions?: ShareRule[] } | ShareRule[]) => { + const raw = Array.isArray(data) ? data : data?.permissions; + const list = Array.isArray(raw) ? raw : []; + const forThisExp = list.filter( + (r) => + r.resource_type === 'experiment' && + String(r.resource_id) === String(experimentId), + ); + setRules(forThisExp); + }) + .catch(() => setRules([])) + .finally(() => setLoading(false)); + }, [open, team?.id, experimentId, fetchWithAuth]); + + const onAdd = async () => { + if (!selectedUserId || !team?.id) return; + setSaving(true); + setError(null); + try { + const res = await fetchWithAuth(`teams/${team.id}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: selectedUserId, + resource_type: 'experiment', + resource_id: experimentId, + actions: LEVEL_ACTIONS[selectedLevel], + }), + }); + if (!res.ok) throw new Error(await res.text()); + const saved = await res.json(); + setRules((prev) => { + const without = prev.filter((r) => r.user_id !== saved.user_id); + return [...without, saved]; + }); + setSelectedUserId(''); + } catch (e: any) { + setError(e?.message || 'Failed to add share'); + } finally { + setSaving(false); + } + }; + + const onRemove = async (ruleId: string) => { + if (!team?.id) return; + setSaving(true); + try { + await fetchWithAuth(`teams/${team.id}/permissions/${ruleId}`, { + method: 'DELETE', + }); + setRules((prev) => prev.filter((r) => r.id !== ruleId)); + } catch { + // ignore + } finally { + setSaving(false); + } + }; + + const getMemberEmail = (userId: string) => + members.find((m) => m.user_id === userId)?.email ?? userId; + + return ( + + + + + Share "{experimentName}" + + + + {error && ( + + {error} + + )} + + + + + + + + {loading ? ( + + ) : rules.length === 0 ? ( + + Not shared with anyone yet. + + ) : ( + + + + + + + + + {rules.map((rule) => ( + + + + + + ))} + +
UserAccess +
{getMemberEmail(rule.user_id)} + {rule.actions.includes('admin') + ? 'Admin' + : rule.actions.includes('write') + ? 'Read + Write' + : 'Read'} + + onRemove(rule.id)} + > + + +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/Experiment/Tasks/JobsChartModal.tsx b/src/renderer/components/Experiment/Tasks/JobsChartModal.tsx index b40bf1f4e5..c100ad724f 100644 --- a/src/renderer/components/Experiment/Tasks/JobsChartModal.tsx +++ b/src/renderer/components/Experiment/Tasks/JobsChartModal.tsx @@ -576,7 +576,8 @@ export default function JobsChartModal({ }} > - No jobs with a date + score to plot. Create jobs and record scores for them to appear here. + No jobs with a date + score to plot. Create jobs and record + scores for them to appear here. )} diff --git a/src/renderer/lib/api-client/endpoints.ts b/src/renderer/lib/api-client/endpoints.ts index 526ac35e5d..b604f410f1 100644 --- a/src/renderer/lib/api-client/endpoints.ts +++ b/src/renderer/lib/api-client/endpoints.ts @@ -247,6 +247,9 @@ Endpoints.Experiment = { Create: (name: string) => `${API_URL()}experiment/create?name=${name}`, Get: (id: string) => `${API_URL()}experiment/${id}`, Delete: (id: string) => `${API_URL()}experiment/${id}/delete`, + Touch: (id: string) => `${API_URL()}experiment/${id}/touch`, + Recent: () => `${API_URL()}experiment/recent`, + Rename: (id: string) => `${API_URL()}experiment/${id}/rename`, SavePrompt: (id: string) => `${API_URL()}experiment/${id}/prompt`, GetFile: (id: string, filename: string) => `${API_URL()}experiment/${id}/file_contents?filename=${filename}`, From 73974f239678bb820e6cbf1b06252a19c2260def Mon Sep 17 00:00:00 2001 From: deep1401 Date: Mon, 4 May 2026 14:31:34 -0600 Subject: [PATCH 2/8] stack --- ...10f132_add_user_experiment_access_table.py | 9 ++-- .../Experiment/ExperimentsManagerModal.tsx | 48 +++++++++++-------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py index 1422f06ce8..c3ab46b974 100644 --- a/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py +++ b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py @@ -5,6 +5,7 @@ Create Date: 2026-05-04 13:23:27.122716 """ + from typing import Sequence, Union from alembic import op @@ -13,8 +14,8 @@ # revision identifiers, used by Alembic. -revision: str = '46378c10f132' -down_revision: Union[str, Sequence[str], None] = '6ccd4a4d9ca1' +revision: str = "46378c10f132" +down_revision: Union[str, Sequence[str], None] = "6ccd4a4d9ca1" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -36,7 +37,9 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( - "user_id", "team_id", "experiment_id", + "user_id", + "team_id", + "experiment_id", name="uq_user_experiment_access", ), ) diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx index 74edeaa439..0f7cacc182 100644 --- a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -9,6 +9,7 @@ import { Modal, ModalClose, ModalDialog, + Stack, Table, Tooltip, Typography, @@ -138,32 +139,39 @@ export default function ExperimentsManagerModal({ maxHeight: '90vh', display: 'flex', flexDirection: 'column', - // Reserve space for ModalClose (absolute top-right) so header actions do not overlap. + // Room for ModalClose (absolute top-right); extra top padding for header block. + pt: 2.5, pr: 5.5, - pt: 0.5, + pb: 2, + px: 2.5, }} > - - Experiments - - + + Experiments + + + + + + Date: Tue, 5 May 2026 14:40:27 -0600 Subject: [PATCH 3/8] fix changes --- api/test/test_experiment_access_service.py | 28 ++++-- .../routers/experiment/experiment.py | 57 +++--------- .../services/experiment_access_service.py | 36 ++++---- .../services/experiment_service.py | 6 ++ .../Experiment/ExperimentsManagerModal.tsx | 13 ++- .../Experiment/RenameExperimentModal.tsx | 11 ++- .../Experiment/SelectExperimentMenu.tsx | 90 ++++++++++--------- .../Experiment/ShareExperimentModal.tsx | 23 +++-- 8 files changed, 143 insertions(+), 121 deletions(-) diff --git a/api/test/test_experiment_access_service.py b/api/test/test_experiment_access_service.py index bddcfa0902..173ee8ffb9 100644 --- a/api/test/test_experiment_access_service.py +++ b/api/test/test_experiment_access_service.py @@ -1,13 +1,15 @@ from unittest.mock import AsyncMock, MagicMock from datetime import datetime, timezone +from sqlalchemy.exc import IntegrityError + import transformerlab.services.experiment_access_service as svc async def test_touch_experiment_upserts_record(): mock_session = AsyncMock() - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = None + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=0) mock_session.execute.return_value = mock_result await svc.touch_experiment(mock_session, "user1", "team1", "exp1") @@ -18,18 +20,30 @@ async def test_touch_experiment_upserts_record(): async def test_touch_experiment_updates_existing_record(): mock_session = AsyncMock() - existing = MagicMock() - existing.last_opened_at = datetime(2024, 1, 1, tzinfo=timezone.utc) - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = existing + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=1) mock_session.execute.return_value = mock_result await svc.touch_experiment(mock_session, "user1", "team1", "exp1") - assert existing.last_opened_at > datetime(2024, 1, 1, tzinfo=timezone.utc) + mock_session.add.assert_not_called() mock_session.commit.assert_called_once() +async def test_touch_experiment_handles_insert_race_integrity_error(): + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=0) + mock_session.execute.return_value = mock_result + mock_session.commit.side_effect = [ + IntegrityError("stmt", "params", Exception("duplicate key")), + ] + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + mock_session.rollback.assert_called_once() + + async def test_get_recent_experiment_ids_returns_ordered_list(): mock_session = AsyncMock() record1 = MagicMock() diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index dc48a642b2..2170ef6d4c 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -132,51 +132,15 @@ async def experiments_get_recent( return [] recent_ids = await access_service.get_recent_experiment_ids(session, user_id, team_id, limit=3) + permitted_experiments = await experiments_get_all(session=session, user_and_team=user_and_team) + if not recent_ids: + return permitted_experiments[:3] - # Fetch and permission-filter recent experiments - results = [] - for exp_id in recent_ids: - if user_team.role != TeamRole.OWNER.value: - allowed = await check_permission( - session=session, - user_id=user_id, - team_id=team_id, - resource_type="experiment", - resource_id=exp_id, - action="read", - user_team=user_team, - ) - if not allowed: - continue - data = await experiment_service.experiment_get(exp_id) - if data: - results.append(data) - - # Fallback: if no recent records, return first 3 permitted experiments - if not results: - all_experiments = await experiment_service.experiment_get_all() - for exp in all_experiments: - exp_id = str(exp.get("id", "")) - if not exp_id: - continue - if user_team.role == TeamRole.OWNER.value: - results.append(exp) - else: - allowed = await check_permission( - session=session, - user_id=user_id, - team_id=team_id, - resource_type="experiment", - resource_id=exp_id, - action="read", - user_team=user_team, - ) - if allowed: - results.append(exp) - if len(results) >= 3: - break - - return results + permitted_by_id = { + str(exp.get("id")): exp for exp in permitted_experiments if isinstance(exp, dict) and exp.get("id") + } + ordered_recent = [permitted_by_id[exp_id] for exp_id in recent_ids if exp_id in permitted_by_id] + return ordered_recent[:3] @router.get("/create", summary="Create Experiment", tags=["experiment"]) @@ -224,7 +188,10 @@ async def experiment_rename( new_name = body.get("name", "").strip() if not new_name: raise HTTPException(status_code=422, detail="name is required") - result = await experiment_service.experiment_rename(id, new_name) + try: + result = await experiment_service.experiment_rename(id, new_name) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from e if result is None: raise HTTPException(status_code=404, detail=f"Experiment {id} not found") return result diff --git a/api/transformerlab/services/experiment_access_service.py b/api/transformerlab/services/experiment_access_service.py index f0bf67b04e..2ad371739e 100644 --- a/api/transformerlab/services/experiment_access_service.py +++ b/api/transformerlab/services/experiment_access_service.py @@ -1,7 +1,8 @@ import logging from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from transformerlab.shared.models.models import UserExperimentAccess @@ -11,27 +12,32 @@ async def touch_experiment(session: AsyncSession, user_id: str, team_id: str, experiment_id: str) -> None: """Upsert last_opened_at for a user-experiment pair.""" + now = datetime.now(timezone.utc) result = await session.execute( - select(UserExperimentAccess).where( + update(UserExperimentAccess) + .where( UserExperimentAccess.user_id == user_id, UserExperimentAccess.team_id == team_id, UserExperimentAccess.experiment_id == experiment_id, ) + .values(last_opened_at=now) ) - record = result.scalars().first() - - if record is None: - record = UserExperimentAccess( - user_id=user_id, - team_id=team_id, - experiment_id=experiment_id, - last_opened_at=datetime.now(timezone.utc), - ) - session.add(record) + if result.rowcount == 0: + try: + session.add( + UserExperimentAccess( + user_id=user_id, + team_id=team_id, + experiment_id=experiment_id, + last_opened_at=now, + ) + ) + await session.commit() + except IntegrityError: + # Another concurrent request inserted first; treat as success. + await session.rollback() else: - record.last_opened_at = datetime.now(timezone.utc) - - await session.commit() + await session.commit() async def get_recent_experiment_ids(session: AsyncSession, user_id: str, team_id: str, limit: int = 3) -> list[str]: diff --git a/api/transformerlab/services/experiment_service.py b/api/transformerlab/services/experiment_service.py index 251e3ffa20..9c1262d141 100644 --- a/api/transformerlab/services/experiment_service.py +++ b/api/transformerlab/services/experiment_service.py @@ -3,12 +3,15 @@ import json import os +from sqlalchemy import delete from werkzeug.utils import secure_filename from lab import Experiment from lab import dirs as lab_dirs from lab import storage +from transformerlab.db.session import async_session from transformerlab.services.cache_service import cache, cached +from transformerlab.shared.models.models import UserExperimentAccess logger = logging.getLogger(__name__) EXPERIMENT_LIST_CONCURRENCY = max(1, int(os.getenv("TLAB_EXPERIMENT_LIST_CONCURRENCY", "24"))) @@ -134,6 +137,9 @@ async def experiment_delete(id): try: exp = await Experiment.get(id) await exp.delete() + async with async_session() as session: + await session.execute(delete(UserExperimentAccess).where(UserExperimentAccess.experiment_id == str(id))) + await session.commit() await cache.invalidate("experiments") except FileNotFoundError: print(f"Experiment with id '{id}' not found") diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx index 0f7cacc182..643eaf82eb 100644 --- a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -44,7 +44,7 @@ interface ExperimentsManagerModalProps { onClose: () => void; onExperimentSelect: (experimentId: string) => void; onNewExperiment: () => void; - mutateRecent: () => void; + mutateRecent: () => void | Promise; } function formatRelativeTime(isoString: string | null): string { @@ -126,6 +126,7 @@ export default function ExperimentsManagerModal({ const handleOpen = (exp: Experiment) => { onExperimentSelect(exp.id); + mutate(); onClose(); }; @@ -211,9 +212,9 @@ export default function ExperimentsManagerModal({ const ownerLabel = exp.config?.created_by === currentUserId ? 'you' - : exp.config?.created_by - ? exp.config.created_by.slice(0, 8) + '…' - : 'unknown'; + : (members.find( + (m) => m.user_id === exp.config?.created_by, + )?.email ?? 'unknown'); return ( @@ -328,6 +329,10 @@ export default function ExperimentsManagerModal({ experimentId={shareTarget.id} experimentName={shareTarget.name} members={members} + onShared={() => { + mutate(); + mutateRecent(); + }} onClose={() => setShareTarget(null)} /> )} diff --git a/src/renderer/components/Experiment/RenameExperimentModal.tsx b/src/renderer/components/Experiment/RenameExperimentModal.tsx index ebfd8ee590..f38109afe8 100644 --- a/src/renderer/components/Experiment/RenameExperimentModal.tsx +++ b/src/renderer/components/Experiment/RenameExperimentModal.tsx @@ -19,6 +19,13 @@ interface RenameExperimentModalProps { onRenamed: (newName: string) => void; } +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return 'Failed to rename experiment'; +} + export default function RenameExperimentModal({ open, experimentId, @@ -55,8 +62,8 @@ export default function RenameExperimentModal({ const updated = await res.json(); onRenamed(updated.name ?? newName); onClose(); - } catch (err: any) { - setError(err?.message || 'Failed to rename experiment'); + } catch (err: unknown) { + setError(getErrorMessage(err)); } finally { setSaving(false); } diff --git a/src/renderer/components/Experiment/SelectExperimentMenu.tsx b/src/renderer/components/Experiment/SelectExperimentMenu.tsx index 37c3581a8f..42f91e3d26 100644 --- a/src/renderer/components/Experiment/SelectExperimentMenu.tsx +++ b/src/renderer/components/Experiment/SelectExperimentMenu.tsx @@ -38,6 +38,11 @@ import { getAPIFullPath, fetcher } from 'renderer/lib/transformerlab-api-sdk'; import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext'; import ExperimentsManagerModal from './ExperimentsManagerModal'; +interface ExperimentMenuItem { + id: string; + name: string; +} + function ExperimentSettingsMenu({ experimentInfo, setExperimentId, @@ -125,6 +130,14 @@ export default function SelectExperimentMenu({ models }) { const hasProviders = providers.length > 0; const DEV_MODE = experimentInfo?.name === 'dev'; + const experimentItems: ExperimentMenuItem[] = Array.isArray(data) + ? data.filter( + ( + experiment, + ): experiment is ExperimentMenuItem => + typeof experiment?.id === 'string' && typeof experiment?.name === 'string', + ) + : []; useEffect(() => { mutate(); @@ -367,48 +380,39 @@ export default function SelectExperimentMenu({ models }) { }} > {isLoading && Loading...} - {data && - data - .filter( - (experiment: any) => experiment?.id && experiment?.name, - ) // skip bad rows - .map((experiment: any) => { - return ( - - - {experiment.name} - - {experimentInfo?.id === experiment.id && ( - - )} - - ); - })} + {experimentItems.map((experiment) => { + return ( + + + {experiment.name} + + {experimentInfo?.id === experiment.id && ( + + )} + + ); + })} setIsManagerOpen(true)}> @@ -454,7 +458,7 @@ export default function SelectExperimentMenu({ models }) { return; } // Check if experiment name already exists (fallback, as API also checks) - if (data?.some((exp: any) => exp.name === name)) { + if (experimentItems.some((exp) => exp.name === name)) { alert('Experiment name already exists.'); return; } diff --git a/src/renderer/components/Experiment/ShareExperimentModal.tsx b/src/renderer/components/Experiment/ShareExperimentModal.tsx index 015f6fc371..725ebe8070 100644 --- a/src/renderer/components/Experiment/ShareExperimentModal.tsx +++ b/src/renderer/components/Experiment/ShareExperimentModal.tsx @@ -50,6 +50,14 @@ interface ShareExperimentModalProps { experimentName: string; members: TeamMember[]; onClose: () => void; + onShared?: () => void; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return 'Request failed'; } export default function ShareExperimentModal({ @@ -58,6 +66,7 @@ export default function ShareExperimentModal({ experimentName, members, onClose, + onShared, }: ShareExperimentModalProps) { const { team, fetchWithAuth } = useAuth(); const [rules, setRules] = useState([]); @@ -111,8 +120,9 @@ export default function ShareExperimentModal({ return [...without, saved]; }); setSelectedUserId(''); - } catch (e: any) { - setError(e?.message || 'Failed to add share'); + onShared?.(); + } catch (e: unknown) { + setError(getErrorMessage(e) || 'Failed to add share'); } finally { setSaving(false); } @@ -121,13 +131,16 @@ export default function ShareExperimentModal({ const onRemove = async (ruleId: string) => { if (!team?.id) return; setSaving(true); + setError(null); try { - await fetchWithAuth(`teams/${team.id}/permissions/${ruleId}`, { + const res = await fetchWithAuth(`teams/${team.id}/permissions/${ruleId}`, { method: 'DELETE', }); + if (!res.ok) throw new Error(await res.text()); setRules((prev) => prev.filter((r) => r.id !== ruleId)); - } catch { - // ignore + onShared?.(); + } catch (e: unknown) { + setError(getErrorMessage(e) || 'Failed to remove share'); } finally { setSaving(false); } From 4f8631141e9291d3a785b921d98fad551fcb720b Mon Sep 17 00:00:00 2001 From: deep1401 Date: Tue, 5 May 2026 14:54:50 -0600 Subject: [PATCH 4/8] fix --- api/test/test_experiment_access_service.py | 1 - .../components/Experiment/SelectExperimentMenu.tsx | 11 ++++++----- .../components/Experiment/ShareExperimentModal.tsx | 9 ++++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/test/test_experiment_access_service.py b/api/test/test_experiment_access_service.py index 173ee8ffb9..da23fecfaa 100644 --- a/api/test/test_experiment_access_service.py +++ b/api/test/test_experiment_access_service.py @@ -1,5 +1,4 @@ from unittest.mock import AsyncMock, MagicMock -from datetime import datetime, timezone from sqlalchemy.exc import IntegrityError diff --git a/src/renderer/components/Experiment/SelectExperimentMenu.tsx b/src/renderer/components/Experiment/SelectExperimentMenu.tsx index 42f91e3d26..7228bcd0dc 100644 --- a/src/renderer/components/Experiment/SelectExperimentMenu.tsx +++ b/src/renderer/components/Experiment/SelectExperimentMenu.tsx @@ -132,10 +132,9 @@ export default function SelectExperimentMenu({ models }) { const DEV_MODE = experimentInfo?.name === 'dev'; const experimentItems: ExperimentMenuItem[] = Array.isArray(data) ? data.filter( - ( - experiment, - ): experiment is ExperimentMenuItem => - typeof experiment?.id === 'string' && typeof experiment?.name === 'string', + (experiment): experiment is ExperimentMenuItem => + typeof experiment?.id === 'string' && + typeof experiment?.name === 'string', ) : []; @@ -384,7 +383,9 @@ export default function SelectExperimentMenu({ models }) { return ( prev.filter((r) => r.id !== ruleId)); onShared?.(); From 91b9a53ee54ba60a86679d0abcce0a0084f0b5fb Mon Sep 17 00:00:00 2001 From: deep1401 Date: Tue, 5 May 2026 15:04:37 -0600 Subject: [PATCH 5/8] fix experimentinfomutate on rename --- src/renderer/components/Experiment/ExperimentsManagerModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx index 643eaf82eb..484f3f550e 100644 --- a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -23,6 +23,7 @@ import { } from 'renderer/lib/authContext'; import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; import { fetcher } from 'renderer/lib/transformerlab-api-sdk'; +import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext'; import RenameExperimentModal from './RenameExperimentModal'; import ShareExperimentModal from './ShareExperimentModal'; @@ -67,6 +68,7 @@ export default function ExperimentsManagerModal({ mutateRecent, }: ExperimentsManagerModalProps) { const { user, team } = useAuth(); + const { experimentInfoMutate } = useExperimentInfo(); const [search, setSearch] = useState(''); const [renameTarget, setRenameTarget] = useState(null); const [shareTarget, setShareTarget] = useState(null); @@ -318,6 +320,7 @@ export default function ExperimentsManagerModal({ onRenamed={() => { mutate(); mutateRecent(); + experimentInfoMutate(); setRenameTarget(null); }} /> From 097cdd25a88154d094ba99dff79140696dacb531 Mon Sep 17 00:00:00 2001 From: deep1401 Date: Tue, 5 May 2026 15:30:17 -0600 Subject: [PATCH 6/8] Remove rename experiments --- .../routers/experiment/experiment.py | 18 --- .../services/experiment_service.py | 15 --- .../Experiment/ExperimentsManagerModal.tsx | 30 +---- .../Experiment/RenameExperimentModal.tsx | 103 ------------------ src/renderer/lib/api-client/endpoints.ts | 1 - 5 files changed, 1 insertion(+), 166 deletions(-) delete mode 100644 src/renderer/components/Experiment/RenameExperimentModal.tsx diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index 2170ef6d4c..af882927c4 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -179,24 +179,6 @@ async def experiments_delete( return {"message": f"Experiment {id} deleted"} -@router.patch("/{id}/rename", summary="Rename an experiment", tags=["experiment"]) -async def experiment_rename( - id: str, - body: Annotated[dict, Body()], - _: None = Depends(require_permission("experiment", "write")), -): - new_name = body.get("name", "").strip() - if not new_name: - raise HTTPException(status_code=422, detail="name is required") - try: - result = await experiment_service.experiment_rename(id, new_name) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - if result is None: - raise HTTPException(status_code=404, detail=f"Experiment {id} not found") - return result - - @router.get("/{id}/update", tags=["experiment"]) async def experiments_update( id: str, diff --git a/api/transformerlab/services/experiment_service.py b/api/transformerlab/services/experiment_service.py index 9c1262d141..6b6803febe 100644 --- a/api/transformerlab/services/experiment_service.py +++ b/api/transformerlab/services/experiment_service.py @@ -4,7 +4,6 @@ import os from sqlalchemy import delete -from werkzeug.utils import secure_filename from lab import Experiment from lab import dirs as lab_dirs from lab import storage @@ -147,20 +146,6 @@ async def experiment_delete(id): print(f"Error deleting experiment {id}: {e}") -async def experiment_rename(id: str, new_name: str) -> dict | None: - """Update the display name of an experiment in index.json.""" - secure_name = secure_filename(new_name) - if not secure_name: - raise ValueError("Invalid experiment name") - try: - exp = await Experiment.get(id) - await exp.update_config({"name": secure_name}) - await cache.invalidate("experiments") - return await experiment_get(id) - except FileNotFoundError: - return None - - async def experiment_update(id, config): try: exp = await Experiment.get(id) diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx index 484f3f550e..10c33670d9 100644 --- a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -14,7 +14,7 @@ import { Tooltip, Typography, } from '@mui/joy'; -import { PencilIcon, Share2Icon, Trash2Icon } from 'lucide-react'; +import { Share2Icon, Trash2Icon } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useSWRWithAuth as useSWR, @@ -23,8 +23,6 @@ import { } from 'renderer/lib/authContext'; import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; import { fetcher } from 'renderer/lib/transformerlab-api-sdk'; -import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext'; -import RenameExperimentModal from './RenameExperimentModal'; import ShareExperimentModal from './ShareExperimentModal'; interface Experiment { @@ -68,9 +66,7 @@ export default function ExperimentsManagerModal({ mutateRecent, }: ExperimentsManagerModalProps) { const { user, team } = useAuth(); - const { experimentInfoMutate } = useExperimentInfo(); const [search, setSearch] = useState(''); - const [renameTarget, setRenameTarget] = useState(null); const [shareTarget, setShareTarget] = useState(null); const { @@ -278,15 +274,6 @@ export default function ExperimentsManagerModal({ - - setRenameTarget(exp)} - > - - - - {renameTarget && ( - setRenameTarget(null)} - onRenamed={() => { - mutate(); - mutateRecent(); - experimentInfoMutate(); - setRenameTarget(null); - }} - /> - )} - {shareTarget && ( void; - onRenamed: (newName: string) => void; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message; - } - return 'Failed to rename experiment'; -} - -export default function RenameExperimentModal({ - open, - experimentId, - currentName, - onClose, - onRenamed, -}: RenameExperimentModalProps) { - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const newName = (formData.get('name') as string)?.trim(); - if (!newName || newName === currentName) { - onClose(); - return; - } - setSaving(true); - setError(null); - try { - const res = await chatAPI.authenticatedFetch( - chatAPI.Endpoints.Experiment.Rename(experimentId), - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName }), - }, - ); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || 'Rename failed'); - } - const updated = await res.json(); - onRenamed(updated.name ?? newName); - onClose(); - } catch (err: unknown) { - setError(getErrorMessage(err)); - } finally { - setSaving(false); - } - }; - - return ( - - - - Rename Experiment - - {error && ( - - {error} - - )} - -
- - -
-
-
-
- ); -} diff --git a/src/renderer/lib/api-client/endpoints.ts b/src/renderer/lib/api-client/endpoints.ts index b604f410f1..257a6509e4 100644 --- a/src/renderer/lib/api-client/endpoints.ts +++ b/src/renderer/lib/api-client/endpoints.ts @@ -249,7 +249,6 @@ Endpoints.Experiment = { Delete: (id: string) => `${API_URL()}experiment/${id}/delete`, Touch: (id: string) => `${API_URL()}experiment/${id}/touch`, Recent: () => `${API_URL()}experiment/recent`, - Rename: (id: string) => `${API_URL()}experiment/${id}/rename`, SavePrompt: (id: string) => `${API_URL()}experiment/${id}/prompt`, GetFile: (id: string, filename: string) => `${API_URL()}experiment/${id}/file_contents?filename=${filename}`, From 65203e4a87ba761604ad242362b4fb2feef087e3 Mon Sep 17 00:00:00 2001 From: deep1401 Date: Tue, 5 May 2026 15:31:44 -0600 Subject: [PATCH 7/8] ruff --- api/transformerlab/routers/experiment/experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index af882927c4..54aafac892 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends from sqlalchemy.ext.asyncio import AsyncSession import transformerlab.services.experiment_service as experiment_service From b74810b30139a0ec81224a922bc5048e7c07599d Mon Sep 17 00:00:00 2001 From: deep1401 Date: Tue, 5 May 2026 15:35:46 -0600 Subject: [PATCH 8/8] exp already exists error --- api/test/api/test_experiment_service.py | 10 ++++++++++ .../routers/experiment/experiment.py | 9 +++++++-- .../Experiment/SelectExperimentMenu.tsx | 18 +++++++++++++----- src/renderer/components/Welcome/Welcome.tsx | 5 +++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/api/test/api/test_experiment_service.py b/api/test/api/test_experiment_service.py index 734e768f0d..e9d346573b 100644 --- a/api/test/api/test_experiment_service.py +++ b/api/test/api/test_experiment_service.py @@ -46,6 +46,16 @@ async def test_missing_experiment_returns_none(tmp_experiments_dir): assert await experiment_service.experiment_get("no_such_experiment") is None +@pytest.mark.asyncio +async def test_duplicate_experiment_create_raises_file_exists(tmp_experiments_dir): + _ = tmp_experiments_dir + name = f"duplicate_exp_{uuid.uuid4().hex[:8]}" + await experiment_service.experiment_create(name, {"a": 1}) + + with pytest.raises(FileExistsError): + await experiment_service.experiment_create(name, {"a": 2}) + + # Added test to hit the new FileNotFoundError except-clauses in experiment_service @pytest.mark.asyncio async def test_missing_experiment_operations_handle_FileNotFound(tmp_experiments_dir): diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index 54aafac892..3d17c31938 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession import transformerlab.services.experiment_service as experiment_service @@ -150,9 +150,14 @@ async def experiments_create( ): # Apply secure filename validation to the experiment name secure_name = secure_filename(name) + if not secure_name: + raise HTTPException(status_code=422, detail="Invalid experiment name") user_id = str(user_and_team["user"].id) - newid = await experiment_service.experiment_create(secure_name, {}, created_by=user_id) + try: + newid = await experiment_service.experiment_create(secure_name, {}, created_by=user_id) + except FileExistsError as e: + raise HTTPException(status_code=409, detail=f"Experiment '{secure_name}' already exists") from e return newid diff --git a/src/renderer/components/Experiment/SelectExperimentMenu.tsx b/src/renderer/components/Experiment/SelectExperimentMenu.tsx index 7228bcd0dc..a02674f761 100644 --- a/src/renderer/components/Experiment/SelectExperimentMenu.tsx +++ b/src/renderer/components/Experiment/SelectExperimentMenu.tsx @@ -168,7 +168,7 @@ export default function SelectExperimentMenu({ models }) { // Allow creation if data is an empty array (no experiments yet) if (isLoading || data === null || data === undefined) { alert('Please wait for experiments to load before creating a new one.'); - return; + return false; } let newId = 0; @@ -177,6 +177,11 @@ export default function SelectExperimentMenu({ models }) { const response = await chatAPI.authenticatedFetch( chatAPI.Endpoints.Experiment.Create(name), ); + if (!response.ok) { + const errorText = await response.text(); + alert(errorText || 'Failed to create experiment.'); + return false; + } newId = await response.json(); } else { const response = await chatAPI.authenticatedFetch( @@ -194,7 +199,7 @@ export default function SelectExperimentMenu({ models }) { alert( `Error creating experiment from recipe: ${responseJson?.message || 'Unknown error'}`, ); - return; + return false; } newId = responseJson?.data?.experiment_id; } @@ -208,7 +213,7 @@ export default function SelectExperimentMenu({ models }) { alert( 'Experiment created, but it could not be loaded. Please refresh and try again.', ); - return; + return false; } await mutate(); @@ -219,6 +224,7 @@ export default function SelectExperimentMenu({ models }) { if (fromRecipeId !== null && fromRecipeId !== -1) { navigate(`/experiment/${encodeURIComponent(name)}/notes`); } + return true; }, [setExperimentId, mutate, navigate, isLoading, data], ); @@ -463,8 +469,10 @@ export default function SelectExperimentMenu({ models }) { alert('Experiment name already exists.'); return; } - await createNewExperiment(name); - setModalOpen(false); + const created = await createNewExperiment(name); + if (created) { + setModalOpen(false); + } }} >