diff --git a/client/src/components/entities/PicksPanel.tsx b/client/src/components/entities/PicksPanel.tsx
new file mode 100644
index 0000000..8598916
--- /dev/null
+++ b/client/src/components/entities/PicksPanel.tsx
@@ -0,0 +1,296 @@
+/**
+ * Enhanced picks panel with editing capabilities.
+ * Extends PicksTable with create, edit, and delete functionality.
+ */
+
+import { useState } from "react";
+import {
+ Box,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ IconButton,
+ Typography,
+ CircularProgress,
+ Button,
+ Tooltip,
+} from "@mui/material";
+import {
+ Visibility,
+ VisibilityOff,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+ Edit as EditIcon,
+ Lock as LockIcon,
+} from "@mui/icons-material";
+import { usePicks, usePickPoints, useCreatePicks, useDeletePicks } from "@/api/hooks";
+import { useCopick } from "@/contexts/CopickContext";
+import { usePicking, type PickingPoint } from "@/contexts/PickingContext";
+import { rgbaToHex } from "@/utils/colorUtils";
+import { NewPickDialog } from "@/components/picking/NewPickDialog";
+import type { PicksSummaryResponse } from "@/api/types";
+
+interface PicksPanelProps {
+ runName: string;
+}
+
+export function PicksPanel({ runName }: PicksPanelProps) {
+ const { data: picks, isLoading, error } = usePicks(runName);
+ const { state, togglePickVisibility, addPick } = useCopick();
+ const { state: pickingState, isEditing } = usePicking();
+ const createPicks = useCreatePicks();
+ const deletePicks = useDeletePicks();
+
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Failed to load picks
+
+ );
+ }
+
+ const handleCreatePicks = async (objectName: string, userId: string, sessionId: string) => {
+ await createPicks.mutateAsync({
+ runName,
+ data: { object_name: objectName, user_id: userId, session_id: sessionId },
+ });
+ };
+
+ const handleDeletePicks = async (pick: PicksSummaryResponse) => {
+ if (window.confirm(`Delete picks for ${pick.object_name} by ${pick.user_id}?`)) {
+ await deletePicks.mutateAsync({
+ runName,
+ objectName: pick.object_name,
+ userId: pick.user_id,
+ sessionId: pick.session_id,
+ });
+ }
+ };
+
+ const handleToggle = (pick: PicksSummaryResponse) => {
+ const existing = state.selectedPicks.find(
+ (p) => p.objectName === pick.object_name && p.userId === pick.user_id && p.sessionId === pick.session_id
+ );
+
+ if (existing) {
+ togglePickVisibility(pick.object_name, pick.user_id, pick.session_id);
+ } else {
+ addPick({
+ objectName: pick.object_name,
+ userId: pick.user_id,
+ sessionId: pick.session_id,
+ });
+ }
+ };
+
+ const isVisible = (pick: PicksSummaryResponse) => {
+ const existing = state.selectedPicks.find(
+ (p) => p.objectName === pick.object_name && p.userId === pick.user_id && p.sessionId === pick.session_id
+ );
+ return existing?.visible ?? false;
+ };
+
+ const isToolPick = (pick: PicksSummaryResponse) => pick.session_id === "0";
+
+ const isCurrentlyEditing = (pick: PicksSummaryResponse) =>
+ pickingState.editingPicks?.objectName === pick.object_name &&
+ pickingState.editingPicks?.userId === pick.user_id &&
+ pickingState.editingPicks?.sessionId === pick.session_id;
+
+ return (
+
+ {/* Toolbar */}
+
+ } size="small" onClick={() => setDialogOpen(true)} disabled={isEditing}>
+ New
+
+
+
+ {/* Table */}
+ {!picks || picks.length === 0 ? (
+
+ No picks found
+
+ ) : (
+
+
+
+
+
+ Object
+ User
+ Session
+
+ Count
+
+
+ Actions
+
+
+
+
+ {picks.map((pick) => (
+ handleToggle(pick)}
+ onDelete={() => handleDeletePicks(pick)}
+ />
+ ))}
+
+
+
+ )}
+
+ {/* New Pick Dialog */}
+ setDialogOpen(false)} onSubmit={handleCreatePicks} runName={runName} />
+
+ );
+}
+
+// Separate row component for editing functionality
+interface PickRowProps {
+ pick: PicksSummaryResponse;
+ runName: string;
+ isVisible: boolean;
+ isToolPick: boolean;
+ isCurrentlyEditing: boolean;
+ isAnyEditing: boolean;
+ onToggle: () => void;
+ onDelete: () => void;
+}
+
+function PickRow({
+ pick,
+ runName,
+ isVisible,
+ isToolPick,
+ isCurrentlyEditing,
+ isAnyEditing,
+ onToggle,
+ onDelete,
+}: PickRowProps) {
+ const { startEditing } = usePicking();
+ const { data: pickDetail } = usePickPoints(runName, pick.object_name, pick.user_id, pick.session_id);
+
+ const handleEdit = () => {
+ if (!pickDetail) return;
+
+ // Convert points to PickingPoint format
+ const points: PickingPoint[] = pickDetail.points.map((p, index) => ({
+ id: `${p.x.toFixed(2)}-${p.y.toFixed(2)}-${p.z.toFixed(2)}-${index}`,
+ x: p.x,
+ y: p.y,
+ z: p.z,
+ instance_id: p.instance_id,
+ score: p.score,
+ }));
+
+ startEditing(
+ {
+ runName,
+ objectName: pick.object_name,
+ userId: pick.user_id,
+ sessionId: pick.session_id,
+ color: pick.color,
+ },
+ points
+ );
+ };
+
+ return (
+
+
+
+ {isVisible ? : }
+
+
+
+
+
+
+ {pick.object_name}
+
+
+
+
+
+ {pick.user_id}
+
+
+
+
+
+ {pick.session_id}
+
+ {isToolPick && (
+
+
+
+ )}
+
+
+
+ {pick.point_count}
+
+
+
+ {!isToolPick && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/overlays/InteractivePicksOverlay.tsx b/client/src/components/overlays/InteractivePicksOverlay.tsx
new file mode 100644
index 0000000..2914a4f
--- /dev/null
+++ b/client/src/components/overlays/InteractivePicksOverlay.tsx
@@ -0,0 +1,222 @@
+/**
+ * Interactive picks overlay that supports both viewing and editing modes.
+ * Renders both read-only picks and editable picks being modified.
+ *
+ * When editing, the picks being edited are rendered from local state
+ * instead of fetched data, allowing real-time visual feedback.
+ */
+
+import { useEffect, useRef } from "react";
+import { useIdetik } from "@idetik/react";
+import { useCopick } from "@/contexts/CopickContext";
+import { usePicking, type PickingPoint } from "@/contexts/PickingContext";
+import { usePickPoints } from "@/api/hooks";
+import { PicksLayer } from "./layers/PicksLayer";
+
+const DEFAULT_POINT_SIZE_PIXELS = 30;
+const DEFAULT_Z_FADE_FACTOR = 64.0;
+const SELECTED_POINT_SIZE_PIXELS = 40; // Larger for selected points
+
+interface InteractivePicksOverlayProps {
+ currentZIndex: number;
+ voxelSpacing: number;
+}
+
+export function InteractivePicksOverlay({ currentZIndex, voxelSpacing }: InteractivePicksOverlayProps) {
+ const { state: copickState } = useCopick();
+ const { state: pickingState, isEditing } = usePicking();
+
+ // Get all visible picks
+ const visiblePicks = copickState.selectedPicks.filter((p) => p.visible);
+
+ return (
+ <>
+ {/* Read-only picks (excluding the one being edited) */}
+ {visiblePicks
+ .filter(
+ (pick) =>
+ !isEditing ||
+ pick.objectName !== pickingState.editingPicks?.objectName ||
+ pick.userId !== pickingState.editingPicks?.userId ||
+ pick.sessionId !== pickingState.editingPicks?.sessionId
+ )
+ .map((pick) => (
+
+ ))}
+
+ {/* Editable picks layer */}
+ {isEditing && pickingState.editingPicks && (
+
+ )}
+ >
+ );
+}
+
+// Read-only layer (same as existing PickPointsLayer)
+interface ReadOnlyPickPointsLayerProps {
+ objectName: string;
+ userId: string;
+ sessionId: string;
+ currentZIndex: number;
+ voxelSpacing: number;
+}
+
+function ReadOnlyPickPointsLayer({
+ objectName,
+ userId,
+ sessionId,
+ currentZIndex,
+ voxelSpacing,
+}: ReadOnlyPickPointsLayerProps) {
+ const { state } = useCopick();
+ const { runtime } = useIdetik();
+ const layerRef = useRef(null);
+
+ const { data: picks } = usePickPoints(state.selectedRunName, objectName, userId, sessionId);
+
+ useEffect(() => {
+ if (!runtime || !picks || picks.points.length === 0) {
+ return;
+ }
+
+ const layerManager = runtime.viewports[0]?.layerManager;
+ if (!layerManager) return;
+
+ if (layerRef.current) {
+ layerManager.remove(layerRef.current);
+ layerRef.current = null;
+ }
+
+ const layer = new PicksLayer({
+ points: picks.points.map((pt) => ({ x: pt.x, y: pt.y, z: pt.z })),
+ color: picks.color,
+ pointSizePixels: DEFAULT_POINT_SIZE_PIXELS,
+ zFadeRadius: DEFAULT_Z_FADE_FACTOR,
+ });
+
+ const currentZInAngstroms = currentZIndex * voxelSpacing;
+ layer.setCurrentZ(currentZInAngstroms);
+
+ layerManager.add(layer);
+ layerRef.current = layer;
+
+ return () => {
+ if (layerRef.current && layerManager.layers.includes(layerRef.current)) {
+ layerManager.remove(layerRef.current);
+ layerRef.current = null;
+ }
+ };
+ }, [runtime, picks]);
+
+ useEffect(() => {
+ if (layerRef.current) {
+ const currentZInAngstroms = currentZIndex * voxelSpacing;
+ layerRef.current.setCurrentZ(currentZInAngstroms);
+ }
+ }, [currentZIndex, voxelSpacing]);
+
+ return null;
+}
+
+// Editable layer with selection highlighting
+interface EditablePicksLayerProps {
+ points: PickingPoint[];
+ selectedIds: Set;
+ color: [number, number, number, number];
+ currentZIndex: number;
+ voxelSpacing: number;
+}
+
+function EditablePicksLayer({ points, selectedIds, color, currentZIndex, voxelSpacing }: EditablePicksLayerProps) {
+ const { runtime } = useIdetik();
+ const normalLayerRef = useRef(null);
+ const selectedLayerRef = useRef(null);
+
+ useEffect(() => {
+ if (!runtime) return;
+
+ const layerManager = runtime.viewports[0]?.layerManager;
+ if (!layerManager) return;
+
+ // Clean up old layers
+ if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) {
+ layerManager.remove(normalLayerRef.current);
+ }
+ if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) {
+ layerManager.remove(selectedLayerRef.current);
+ }
+ normalLayerRef.current = null;
+ selectedLayerRef.current = null;
+
+ const normalPoints = points.filter((p) => !selectedIds.has(p.id));
+ const selectedPoints = points.filter((p) => selectedIds.has(p.id));
+ const currentZInAngstroms = currentZIndex * voxelSpacing;
+
+ // Normal points layer
+ if (normalPoints.length > 0) {
+ const normalLayer = new PicksLayer({
+ points: normalPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
+ color,
+ pointSizePixels: DEFAULT_POINT_SIZE_PIXELS,
+ zFadeRadius: DEFAULT_Z_FADE_FACTOR,
+ });
+ normalLayer.setCurrentZ(currentZInAngstroms);
+ layerManager.add(normalLayer);
+ normalLayerRef.current = normalLayer;
+ }
+
+ // Selected points layer (brighter, larger)
+ if (selectedPoints.length > 0) {
+ // Use a brighter version of the color for selection
+ const highlightColor: [number, number, number, number] = [
+ Math.min(255, color[0] + 80),
+ Math.min(255, color[1] + 80),
+ Math.min(255, color[2] + 80),
+ 255,
+ ];
+
+ const selectedLayer = new PicksLayer({
+ points: selectedPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
+ color: highlightColor,
+ pointSizePixels: SELECTED_POINT_SIZE_PIXELS,
+ zFadeRadius: DEFAULT_Z_FADE_FACTOR,
+ });
+ selectedLayer.setCurrentZ(currentZInAngstroms);
+ layerManager.add(selectedLayer);
+ selectedLayerRef.current = selectedLayer;
+ }
+
+ return () => {
+ if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) {
+ layerManager.remove(normalLayerRef.current);
+ }
+ if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) {
+ layerManager.remove(selectedLayerRef.current);
+ }
+ normalLayerRef.current = null;
+ selectedLayerRef.current = null;
+ };
+ }, [runtime, points, selectedIds, color, currentZIndex, voxelSpacing]);
+
+ // Update z-position when slice changes (without recreating layers)
+ useEffect(() => {
+ const currentZInAngstroms = currentZIndex * voxelSpacing;
+ normalLayerRef.current?.setCurrentZ(currentZInAngstroms);
+ selectedLayerRef.current?.setCurrentZ(currentZInAngstroms);
+ }, [currentZIndex, voxelSpacing]);
+
+ return null;
+}
diff --git a/client/src/components/picking/NewPickDialog.tsx b/client/src/components/picking/NewPickDialog.tsx
new file mode 100644
index 0000000..28b6c0a
--- /dev/null
+++ b/client/src/components/picking/NewPickDialog.tsx
@@ -0,0 +1,148 @@
+/**
+ * Dialog for creating a new picks collection.
+ * Inspired by chimerax-copick's NewPickDialog.py
+ */
+
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ TextField,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Box,
+ Typography,
+} from "@mui/material";
+import { useConfig, useObjects, usePicks } from "@/api/hooks";
+import { validateCopickName, generateSessionId } from "@/utils/validation";
+import { rgbaToHex } from "@/utils/colorUtils";
+
+interface NewPickDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onSubmit: (objectName: string, userId: string, sessionId: string) => void;
+ runName: string;
+}
+
+export function NewPickDialog({ open, onClose, onSubmit, runName }: NewPickDialogProps) {
+ const { data: config } = useConfig();
+ const { data: objects } = useObjects();
+ const { data: existingPicks } = usePicks(runName);
+
+ // Form state
+ const [objectName, setObjectName] = useState("");
+ const [userId, setUserId] = useState("");
+ const [sessionId, setSessionId] = useState("");
+
+ // Initialize defaults when dialog opens
+ useEffect(() => {
+ if (open) {
+ // Default user ID from config
+ setUserId(config?.user_id || "user");
+
+ // Auto-generate session ID
+ const existingSessionIds = existingPicks?.map((p) => p.session_id) || [];
+ setSessionId(generateSessionId(existingSessionIds));
+
+ // Select first object by default
+ if (objects && objects.length > 0) {
+ setObjectName(objects[0].name);
+ }
+ }
+ }, [open, config, existingPicks, objects]);
+
+ // Validation
+ const userValidation = validateCopickName(userId);
+ const sessionValidation = validateCopickName(sessionId);
+
+ const isValid = objectName && userValidation.isValid && sessionValidation.isValid;
+
+ const handleSubmit = () => {
+ if (isValid) {
+ onSubmit(objectName, userId, sessionId);
+ handleClose();
+ }
+ };
+
+ const handleClose = () => {
+ setObjectName("");
+ setUserId("");
+ setSessionId("");
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/client/src/components/picking/PickingEventHandler.tsx b/client/src/components/picking/PickingEventHandler.tsx
new file mode 100644
index 0000000..38a4b4b
--- /dev/null
+++ b/client/src/components/picking/PickingEventHandler.tsx
@@ -0,0 +1,236 @@
+/**
+ * Handles mouse events for picking interactions.
+ * Attaches event listeners to the viewer canvas.
+ */
+
+import { useEffect, useCallback, useRef } from "react";
+import { OrthographicCamera } from "@idetik/core";
+import { useIdetik } from "@idetik/react";
+import { usePicking, type PickingPoint } from "@/contexts/PickingContext";
+
+interface PickingEventHandlerProps {
+ currentZIndex: number;
+ onZIndexChange: (newIndex: number) => void;
+ maxZIndex: number | undefined;
+ voxelSpacing: number;
+}
+
+export function PickingEventHandler({
+ currentZIndex,
+ onZIndexChange,
+ maxZIndex,
+ voxelSpacing,
+}: PickingEventHandlerProps) {
+ const { runtime } = useIdetik();
+ const { state: pickingState, addPoint, deletePoint, selectPoint, clearSelection, isEditing } = usePicking();
+
+ // Store canvas reference
+ const canvasRef = useRef(null);
+
+ // Get canvas when runtime is available
+ useEffect(() => {
+ if (!runtime) return;
+ // Access canvas directly from runtime
+ canvasRef.current = runtime.canvas;
+ }, [runtime]);
+
+ // Convert screen coordinates to world coordinates
+ const screenToWorld = useCallback(
+ (screenX: number, screenY: number): { x: number; y: number } | null => {
+ if (!runtime || !canvasRef.current) return null;
+
+ const viewport = runtime.viewports[0];
+ if (!viewport) return null;
+
+ // Get canvas bounds
+ const canvas = runtime.canvas;
+ const rect = canvas.getBoundingClientRect();
+ const canvasX = screenX - rect.left;
+ const canvasY = screenY - rect.top;
+
+ // Get camera for coordinate conversion
+ const camera = viewport.camera;
+ if (!camera || camera.type !== "OrthographicCamera") return null;
+
+ const orthoCamera = camera as OrthographicCamera;
+
+ // Canvas dimensions
+ const canvasWidth = canvas.clientWidth;
+ const canvasHeight = canvas.clientHeight;
+
+ // Convert from canvas space to normalized device coordinates (-1 to 1)
+ // Note: Don't flip Y - image coordinates have Y increasing downward
+ const ndcX = (canvasX / canvasWidth) * 2 - 1;
+ const ndcY = (canvasY / canvasHeight) * 2 - 1;
+
+ // For idetik's orthographic camera:
+ // cameraWidthWorld = transform.scale[0] * viewportSize[0]
+ const cameraWidthWorld = orthoCamera.transform.scale[0] * orthoCamera.viewportSize[0];
+ const cameraHeightWorld = orthoCamera.transform.scale[1] * orthoCamera.viewportSize[1];
+
+ // Camera center is at transform.translation
+ const centerX = orthoCamera.transform.translation[0];
+ const centerY = orthoCamera.transform.translation[1];
+
+ // Convert NDC to world coordinates
+ const worldX = centerX + ndcX * (cameraWidthWorld / 2);
+ const worldY = centerY + ndcY * (cameraHeightWorld / 2);
+
+ return { x: worldX, y: worldY };
+ },
+ [runtime]
+ );
+
+ // Find nearest point to click location
+ const findNearestPoint = useCallback(
+ (worldX: number, worldY: number, threshold: number = 50): PickingPoint | null => {
+ if (!isEditing) return null;
+
+ const currentZ = currentZIndex * voxelSpacing;
+ let nearest: PickingPoint | null = null;
+ let minDist = threshold;
+
+ for (const point of pickingState.localPoints) {
+ // Only consider points near current z-slice (within 3 slices)
+ const zDist = Math.abs(point.z - currentZ);
+ if (zDist > voxelSpacing * 3) continue;
+
+ const dist = Math.sqrt(Math.pow(point.x - worldX, 2) + Math.pow(point.y - worldY, 2));
+
+ if (dist < minDist) {
+ minDist = dist;
+ nearest = point;
+ }
+ }
+
+ return nearest;
+ },
+ [isEditing, pickingState.localPoints, currentZIndex, voxelSpacing]
+ );
+
+ // Handle mouse click
+ const handleClick = useCallback(
+ (event: MouseEvent) => {
+ if (!isEditing) return;
+
+ // Don't intercept if user is interacting with UI elements
+ if ((event.target as HTMLElement).tagName !== "CANVAS") return;
+
+ const worldPos = screenToWorld(event.clientX, event.clientY);
+ if (!worldPos) return;
+
+ const currentZ = currentZIndex * voxelSpacing;
+
+ switch (pickingState.activeTool) {
+ case "add": {
+ // Add new point at click location on current z-slice
+ const newPoint: PickingPoint = {
+ id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ x: worldPos.x,
+ y: worldPos.y,
+ z: currentZ,
+ score: 1.0,
+ };
+ addPoint(newPoint);
+ break;
+ }
+
+ case "select": {
+ const nearest = findNearestPoint(worldPos.x, worldPos.y);
+ if (nearest) {
+ selectPoint(nearest.id, event.shiftKey);
+ } else if (!event.shiftKey) {
+ clearSelection();
+ }
+ break;
+ }
+
+ case "delete": {
+ const nearest = findNearestPoint(worldPos.x, worldPos.y);
+ if (nearest) {
+ deletePoint(nearest.id);
+ }
+ break;
+ }
+ }
+ },
+ [
+ isEditing,
+ pickingState.activeTool,
+ screenToWorld,
+ findNearestPoint,
+ addPoint,
+ selectPoint,
+ deletePoint,
+ clearSelection,
+ currentZIndex,
+ voxelSpacing,
+ ]
+ );
+
+ // Handle wheel for z-navigation (Shift + scroll)
+ const handleWheel = useCallback(
+ (event: WheelEvent) => {
+ if (!event.shiftKey || maxZIndex === undefined) return;
+
+ // Only intercept on canvas
+ if ((event.target as HTMLElement).tagName !== "CANVAS") return;
+
+ event.preventDefault();
+
+ const delta = event.deltaY > 0 ? 1 : -1;
+ const newIndex = Math.max(0, Math.min(maxZIndex, currentZIndex + delta));
+ if (newIndex !== currentZIndex) {
+ onZIndexChange(newIndex);
+ }
+ },
+ [currentZIndex, maxZIndex, onZIndexChange]
+ );
+
+ // Attach event listeners
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ canvas.addEventListener("click", handleClick);
+ canvas.addEventListener("wheel", handleWheel, { passive: false });
+
+ return () => {
+ canvas.removeEventListener("click", handleClick);
+ canvas.removeEventListener("wheel", handleWheel);
+ };
+ }, [handleClick, handleWheel]);
+
+ // Update cursor based on tool
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ if (!isEditing) {
+ canvas.style.cursor = "";
+ return;
+ }
+
+ switch (pickingState.activeTool) {
+ case "add":
+ canvas.style.cursor = "crosshair";
+ break;
+ case "select":
+ canvas.style.cursor = "pointer";
+ break;
+ case "delete":
+ canvas.style.cursor = "not-allowed";
+ break;
+ default:
+ canvas.style.cursor = "";
+ }
+
+ return () => {
+ if (canvas) {
+ canvas.style.cursor = "";
+ }
+ };
+ }, [pickingState.activeTool, isEditing]);
+
+ return null;
+}
diff --git a/client/src/components/picking/PickingToolbar.tsx b/client/src/components/picking/PickingToolbar.tsx
new file mode 100644
index 0000000..d01b047
--- /dev/null
+++ b/client/src/components/picking/PickingToolbar.tsx
@@ -0,0 +1,144 @@
+/**
+ * Toolbar for selecting picking tools and displaying current edit state.
+ */
+
+import { Box, ToggleButtonGroup, ToggleButton, Button, Chip, Tooltip } from "@mui/material";
+import PanToolIcon from "@mui/icons-material/PanTool";
+import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
+import HighlightAltIcon from "@mui/icons-material/HighlightAlt";
+import DeleteIcon from "@mui/icons-material/Delete";
+import SaveIcon from "@mui/icons-material/Save";
+import CloseIcon from "@mui/icons-material/Close";
+import { usePicking, type PickingTool } from "@/contexts/PickingContext";
+import { useUpdatePicks } from "@/api/hooks";
+import { rgbaToCss } from "@/utils/colorUtils";
+
+export function PickingToolbar() {
+ const { state, setTool, stopEditing, markSaved, deleteSelectedPoints } = usePicking();
+ const updatePicks = useUpdatePicks();
+
+ const handleToolChange = (_: React.MouseEvent, tool: PickingTool | null) => {
+ if (tool) {
+ setTool(tool);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!state.editingPicks) return;
+
+ await updatePicks.mutateAsync({
+ runName: state.editingPicks.runName,
+ objectName: state.editingPicks.objectName,
+ userId: state.editingPicks.userId,
+ sessionId: state.editingPicks.sessionId,
+ points: state.localPoints.map((p) => ({
+ x: p.x,
+ y: p.y,
+ z: p.z,
+ instance_id: p.instance_id,
+ score: p.score,
+ })),
+ });
+
+ markSaved();
+ };
+
+ const handleCancel = () => {
+ stopEditing();
+ };
+
+ const handleDeleteSelected = () => {
+ if (state.selectedPointIds.size > 0) {
+ deleteSelectedPoints();
+ }
+ };
+
+ if (!state.editingPicks) {
+ return null; // Don't show toolbar when not editing
+ }
+
+ return (
+
+ {/* Current editing info */}
+
+
+ {/* Tool selector */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Point count */}
+
+
+ {/* Selection info and delete button */}
+ {state.selectedPointIds.size > 0 && (
+ <>
+ }
+ />
+ >
+ )}
+
+ {/* Spacer */}
+
+
+ {/* Unsaved changes indicator */}
+ {state.hasUnsavedChanges && }
+
+ {/* Save/Cancel buttons */}
+ }
+ onClick={handleSave}
+ variant="contained"
+ size="small"
+ disabled={!state.hasUnsavedChanges || updatePicks.isPending}
+ >
+ {updatePicks.isPending ? "Saving..." : "Save"}
+
+ } onClick={handleCancel} size="small">
+ Cancel
+
+
+ );
+}
diff --git a/client/src/contexts/PickingContext.tsx b/client/src/contexts/PickingContext.tsx
new file mode 100644
index 0000000..b114d7b
--- /dev/null
+++ b/client/src/contexts/PickingContext.tsx
@@ -0,0 +1,176 @@
+/**
+ * Context for managing picking tool state and operations.
+ */
+
+import { createContext, useContext, useReducer, useCallback, type ReactNode } from "react";
+
+export type PickingTool = "view" | "add" | "select" | "delete";
+
+export interface EditingPicks {
+ runName: string;
+ objectName: string;
+ userId: string;
+ sessionId: string;
+ color: [number, number, number, number];
+}
+
+export interface PickingPoint {
+ id: string; // Unique ID for selection
+ x: number;
+ y: number;
+ z: number;
+ instance_id?: number | null;
+ score?: number | null;
+}
+
+interface PickingState {
+ activeTool: PickingTool;
+ editingPicks: EditingPicks | null;
+ localPoints: PickingPoint[]; // Local copy of points being edited
+ selectedPointIds: Set;
+ hasUnsavedChanges: boolean;
+}
+
+type PickingAction =
+ | { type: "SET_TOOL"; tool: PickingTool }
+ | { type: "START_EDITING"; picks: EditingPicks; points: PickingPoint[] }
+ | { type: "STOP_EDITING" }
+ | { type: "ADD_POINT"; point: PickingPoint }
+ | { type: "DELETE_POINT"; pointId: string }
+ | { type: "DELETE_SELECTED_POINTS" }
+ | { type: "SELECT_POINT"; pointId: string; addToSelection: boolean }
+ | { type: "CLEAR_SELECTION" }
+ | { type: "SET_POINTS"; points: PickingPoint[] }
+ | { type: "MARK_SAVED" };
+
+const initialState: PickingState = {
+ activeTool: "view",
+ editingPicks: null,
+ localPoints: [],
+ selectedPointIds: new Set(),
+ hasUnsavedChanges: false,
+};
+
+function pickingReducer(state: PickingState, action: PickingAction): PickingState {
+ switch (action.type) {
+ case "SET_TOOL":
+ return { ...state, activeTool: action.tool };
+
+ case "START_EDITING":
+ return {
+ ...state,
+ editingPicks: action.picks,
+ localPoints: action.points,
+ selectedPointIds: new Set(),
+ hasUnsavedChanges: false,
+ activeTool: "add", // Switch to add mode when starting to edit
+ };
+
+ case "STOP_EDITING":
+ return {
+ ...state,
+ editingPicks: null,
+ localPoints: [],
+ selectedPointIds: new Set(),
+ hasUnsavedChanges: false,
+ activeTool: "view",
+ };
+
+ case "ADD_POINT":
+ return {
+ ...state,
+ localPoints: [...state.localPoints, action.point],
+ hasUnsavedChanges: true,
+ };
+
+ case "DELETE_POINT": {
+ return {
+ ...state,
+ localPoints: state.localPoints.filter((p) => p.id !== action.pointId),
+ selectedPointIds: new Set([...state.selectedPointIds].filter((id) => id !== action.pointId)),
+ hasUnsavedChanges: true,
+ };
+ }
+
+ case "DELETE_SELECTED_POINTS": {
+ const selected = state.selectedPointIds;
+ return {
+ ...state,
+ localPoints: state.localPoints.filter((p) => !selected.has(p.id)),
+ selectedPointIds: new Set(),
+ hasUnsavedChanges: true,
+ };
+ }
+
+ case "SELECT_POINT": {
+ const newSelection = new Set(action.addToSelection ? state.selectedPointIds : []);
+ if (newSelection.has(action.pointId)) {
+ newSelection.delete(action.pointId);
+ } else {
+ newSelection.add(action.pointId);
+ }
+ return { ...state, selectedPointIds: newSelection };
+ }
+
+ case "CLEAR_SELECTION":
+ return { ...state, selectedPointIds: new Set() };
+
+ case "SET_POINTS":
+ return { ...state, localPoints: action.points, hasUnsavedChanges: true };
+
+ case "MARK_SAVED":
+ return { ...state, hasUnsavedChanges: false };
+
+ default:
+ return state;
+ }
+}
+
+interface PickingContextType {
+ state: PickingState;
+ dispatch: React.Dispatch;
+ setTool: (tool: PickingTool) => void;
+ startEditing: (picks: EditingPicks, points: PickingPoint[]) => void;
+ stopEditing: () => void;
+ addPoint: (point: PickingPoint) => void;
+ deletePoint: (pointId: string) => void;
+ deleteSelectedPoints: () => void;
+ selectPoint: (pointId: string, addToSelection?: boolean) => void;
+ clearSelection: () => void;
+ markSaved: () => void;
+ isEditing: boolean;
+}
+
+const PickingContext = createContext(null);
+
+export function PickingProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(pickingReducer, initialState);
+
+ const value: PickingContextType = {
+ state,
+ dispatch,
+ setTool: useCallback((tool) => dispatch({ type: "SET_TOOL", tool }), []),
+ startEditing: useCallback((picks, points) => dispatch({ type: "START_EDITING", picks, points }), []),
+ stopEditing: useCallback(() => dispatch({ type: "STOP_EDITING" }), []),
+ addPoint: useCallback((point) => dispatch({ type: "ADD_POINT", point }), []),
+ deletePoint: useCallback((pointId) => dispatch({ type: "DELETE_POINT", pointId }), []),
+ deleteSelectedPoints: useCallback(() => dispatch({ type: "DELETE_SELECTED_POINTS" }), []),
+ selectPoint: useCallback(
+ (pointId, addToSelection = false) => dispatch({ type: "SELECT_POINT", pointId, addToSelection }),
+ []
+ ),
+ clearSelection: useCallback(() => dispatch({ type: "CLEAR_SELECTION" }), []),
+ markSaved: useCallback(() => dispatch({ type: "MARK_SAVED" }), []),
+ isEditing: state.editingPicks !== null,
+ };
+
+ return {children};
+}
+
+export function usePicking(): PickingContextType {
+ const context = useContext(PickingContext);
+ if (!context) {
+ throw new Error("usePicking must be used within a PickingProvider");
+ }
+ return context;
+}
diff --git a/client/src/utils/validation.ts b/client/src/utils/validation.ts
new file mode 100644
index 0000000..7242c50
--- /dev/null
+++ b/client/src/utils/validation.ts
@@ -0,0 +1,74 @@
+/**
+ * Validation utilities for copick entity names.
+ * Mirrors copick-shared-ui/util/validation.py
+ */
+
+// Invalid characters pattern from copick.util.escape
+// Invalid: <>:"/\|?* (Windows), control chars, spaces, and underscores
+const INVALID_CHARS_PATTERN = /[<>:"/\\|?*\x00-\x1F\x7F\s_]/g;
+
+export interface ValidationResult {
+ isValid: boolean;
+ sanitized: string;
+ errorMessage: string;
+}
+
+/**
+ * Validate a string for use as copick object name, user_id or session_id.
+ *
+ * @param inputStr - The input string to validate
+ * @returns Validation result with isValid flag, sanitized version, and error message
+ */
+export function validateCopickName(inputStr: string): ValidationResult {
+ if (!inputStr) {
+ return { isValid: false, sanitized: "", errorMessage: "Name cannot be empty" };
+ }
+
+ // Check if string contains invalid characters
+ const hasInvalid = INVALID_CHARS_PATTERN.test(inputStr);
+
+ // Reset regex lastIndex (global regex)
+ INVALID_CHARS_PATTERN.lastIndex = 0;
+
+ // Create sanitized version
+ let sanitized = inputStr.replace(INVALID_CHARS_PATTERN, "-");
+ // Reset again after replace
+ INVALID_CHARS_PATTERN.lastIndex = 0;
+ // Trim leading/trailing dashes
+ sanitized = sanitized.replace(/^-+|-+$/g, "");
+
+ if (sanitized === "") {
+ return { isValid: false, sanitized: "", errorMessage: "Name cannot consist only of invalid characters" };
+ }
+
+ if (hasInvalid) {
+ // Find invalid characters for error message
+ INVALID_CHARS_PATTERN.lastIndex = 0;
+ const invalidFound = new Set(inputStr.match(INVALID_CHARS_PATTERN) || []);
+ const invalidList = Array.from(invalidFound)
+ .map((char) => (char === " " ? "'space'" : char === "_" ? "'underscore'" : `'${char}'`))
+ .sort()
+ .join(", ");
+ return { isValid: false, sanitized, errorMessage: `Invalid characters: ${invalidList}` };
+ }
+
+ return { isValid: true, sanitized: inputStr, errorMessage: "" };
+}
+
+/**
+ * Generate an auto-incremented session ID in the format 'manual-X'.
+ *
+ * @param existingSessionIds - List of existing session IDs to check against
+ * @returns New unique session ID
+ */
+export function generateSessionId(existingSessionIds: string[]): string {
+ const existing = new Set(existingSessionIds.map((s) => s.toLowerCase()));
+ let counter = 1;
+ while (true) {
+ const candidate = `manual-${counter}`;
+ if (!existing.has(candidate)) {
+ return candidate;
+ }
+ counter++;
+ }
+}
diff --git a/server/src/copick_web/app/validation.py b/server/src/copick_web/app/validation.py
new file mode 100644
index 0000000..3b2dd55
--- /dev/null
+++ b/server/src/copick_web/app/validation.py
@@ -0,0 +1,46 @@
+"""
+Validation utilities for copick entity names using copick.util.escape rules.
+"""
+
+import re
+from typing import Tuple
+
+
+def validate_copick_name(input_str: str) -> Tuple[bool, str, str]:
+ """
+ Validate a string for use as copick object name, user_id or session_id.
+
+ Args:
+ input_str: The input string to validate
+
+ Returns:
+ Tuple of (is_valid, sanitized_name, error_message)
+ - is_valid: True if the original string is valid
+ - sanitized_name: The sanitized version of the input
+ - error_message: Error message if invalid, empty string if valid
+ """
+ if not input_str:
+ return False, "", "Name cannot be empty"
+
+ # Define invalid characters pattern from copick.util.escape
+ # Invalid: <>:"/\|?* (Windows), control chars, spaces, and underscores
+ invalid_chars = r'[<>:"/\\|?*\x00-\x1F\x7F\s_]'
+
+ # Check if string contains invalid characters
+ has_invalid = bool(re.search(invalid_chars, input_str))
+
+ # Create sanitized version
+ sanitized = re.sub(invalid_chars, "-", input_str)
+ sanitized = sanitized.strip("-")
+
+ if sanitized == "":
+ return False, "", "Name cannot consist only of invalid characters"
+
+ if has_invalid:
+ # Get list of invalid characters found
+ invalid_found = set(re.findall(invalid_chars, input_str))
+ invalid_list = ", ".join(f"'{char}'" if char != " " else "'space'" for char in sorted(invalid_found))
+ error_msg = f"Invalid characters: {invalid_list}"
+ return False, sanitized, error_msg
+
+ return True, input_str, ""