From 1dc38fb146705b1d29195c3897e268749dc827ed Mon Sep 17 00:00:00 2001 From: David Meijer Date: Tue, 28 Jul 2026 11:04:50 -0400 Subject: [PATCH] UPD: allow users to edit parsed out primary sequences in Upload tab --- .../components/workspace/DialogViewItem.tsx | 138 +++------ .../workspace/PrimarySequenceEditor.tsx | 275 ++++++++++++++++++ .../components/workspace/SequenceEditor.tsx | 97 +++++- .../workspace/WorkspaceDiscovery.tsx | 52 ++-- .../workspace/WorkspaceItemCard.tsx | 115 +++++++- .../components/workspace/WorkspaceUpload.tsx | 3 +- .../client/src/features/reconstruction/api.ts | 53 +++- gui/src/client/src/features/session/types.ts | 7 + 8 files changed, 610 insertions(+), 130 deletions(-) create mode 100644 gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index 2ddc441..8562d5e 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -4,18 +4,18 @@ import CircularProgress from "@mui/material/CircularProgress"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useQuery } from "@tanstack/react-query"; -import { SessionItem } from "../../features/session/types"; -import type { PrimarySequenceItem } from "../../features/reconstruction/types"; +import { Session, SessionItem } from "../../features/session/types"; import { reconstructCompound } from "../../features/reconstruction/api"; import { DialogWindow } from "../DialogWindow"; import { ErrorBoundary } from "../ErrorBoundary"; -import { MotifName } from "../MotifName"; import SmilesDrawerContainer from "../SmilesDrawerContainer.js"; +import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor"; type HighlightAtom = [number, string]; type DialogViewItemProps = { - sessionId: string; + session: Session; + setSession: React.Dispatch>; item: SessionItem; open: boolean; onClose: () => void; @@ -73,66 +73,14 @@ function DescriptionBox({ title, description }: { title: string; description: st ); }; -function PrimarySequence({ - sequence, - selectedTags, - onToggleMotif, -}: { - sequence: PrimarySequenceItem[]; - selectedTags: number[]; - onToggleMotif: (tags: number[]) => void; -}) { - return ( - - {sequence.map(([name, tags], idx) => { - const isSelected = - tags.length > 0 && tags.every((tag) => selectedTags.includes(tag)); - - return ( - onToggleMotif(tags)} - sx={{ - px: 1.25, - py: 0.75, - borderRadius: 1, - border: "1px solid", - borderColor: isSelected ? "primary.main" : "divider", - bgcolor: isSelected ? "primary.main" : "background.paper", - color: isSelected ? "primary.contrastText" : "text.primary", - fontSize: "0.875rem", - fontWeight: 500, - cursor: "pointer", - userSelect: "none", - whiteSpace: "nowrap", - "&:hover": { - borderColor: "primary.main", - bgcolor: isSelected ? "primary.dark" : "action.hover", - }, - }} - > - - - ) - })} - - ) -} - export const DialogViewItem: React.FC = ({ - sessionId, + session, + setSession, item, open, onClose, }) => { + const sessionId = session.sessionId; const isCompound = item.kind === "compound"; // there are only two types: "compound" and "cluster" const [selectedTags, setSelectedTags] = React.useState([]); @@ -174,14 +122,46 @@ export const DialogViewItem: React.FC = ({ ? (reconstructionQuery.error as Error).message || "Unknown error" : null; + // resetSignal is `open` -- re-seeding on every open (even for the same item) + // matches the dialog's existing "fresh state each time" behavior for selectedTags. + const editor = usePrimarySequenceEditor(session, setSession, item, data, open); + const { editing, setEditing, anyDirty, saving, handleCancelEdit, handleSaveAll } = editor; + return ( : undefined, + }, + ] + : []), + ...(isCompound && !editing + ? [ + { + key: "edit", + label: "Edit sequences", + variant: "outlined" as const, + color: "primary" as const, + onClick: () => setEditing(true), + disabled: loading || !data || data.length === 0, + }, + ] + : []), + { key: "close", label: "Close", variant: "text" as const, color: "inherit" as const, onClick: onClose }, ]} maxWidth={"lg"} > @@ -328,42 +308,20 @@ export const DialogViewItem: React.FC = ({ pr: 2, }} > - {(data ?? []).map((reconstruction, idx) => ( - - - {`primary sequence ${idx + 1}`} - - - - - ))} + )} diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx new file mode 100644 index 0000000..0c789bb --- /dev/null +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import { Session, SessionItem } from "../../features/session/types"; +import type { PrimarySequenceItem, Reconstruction } from "../../features/reconstruction/types"; +import { saveEditedPrimarySequences, revertEditedPrimarySequence } from "../../features/reconstruction/api"; +import { useNotifications } from "../NotificationProvider"; +import { MotifName } from "../MotifName"; +import { horizontalScrollSx } from "../../theme/scrollbarSx"; +import { SequenceEditor, type SequenceBlock } from "./SequenceEditor"; + +export function blocksFromSequence(sequence: PrimarySequenceItem[]): SequenceBlock[] { + return sequence.map(([name, tags]) => ({ id: crypto.randomUUID(), name, tags })); +} + +export function sequenceFromBlocks(blocks: SequenceBlock[]): PrimarySequenceItem[] { + return blocks.map((b) => [b.name, b.tags ?? []]); +} + +// A missing override means "use whatever the algorithm just parsed" -- see the +// comment on CompoundItemSchema.editedPrimarySequences for why we never store a +// copy of the original. +export function savedSequenceFor(item: SessionItem, idx: number, original: PrimarySequenceItem[]): PrimarySequenceItem[] { + if (item.kind !== "compound") return original; + return item.editedPrimarySequences?.[String(idx)] ?? original; +} + +// Shared editing state machine for a compound's primary sequence(s) -- used by both +// the full "View item" dialog and the inline expandable row in the Upload list, so +// edit/save/revert behave identically (and stay in sync) no matter where they're +// triggered from. +// +// `resetSignal` lets a caller force a fresh draft (e.g. the dialog re-seeds on every +// open, even for the same item); pass a stable value (or omit it) to only reset when +// `data`/`item.id` actually change, which is what lets in-progress edits survive +// unrelated re-renders (like another item's status ticking over). +export function usePrimarySequenceEditor( + session: Session, + setSession: React.Dispatch>, + item: SessionItem, + data: Reconstruction[] | null | undefined, + resetSignal?: unknown, +) { + const { pushNotification } = useNotifications(); + const [editing, setEditing] = React.useState(false); + const [drafts, setDrafts] = React.useState>({}); + const [saving, setSaving] = React.useState(false); + const [revertingIdx, setRevertingIdx] = React.useState(null); + + React.useEffect(() => { + if (!data) return; + const initial: Record = {}; + data.forEach((reconstruction, idx) => { + initial[idx] = blocksFromSequence(savedSequenceFor(item, idx, reconstruction.primary_sequence)); + }); + setDrafts(initial); + setEditing(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, item.id, resetSignal]); + + const isRowDirty = (idx: number, original: PrimarySequenceItem[]): boolean => { + const draft = drafts[idx]; + if (!draft) return false; + return JSON.stringify(sequenceFromBlocks(draft)) !== JSON.stringify(savedSequenceFor(item, idx, original)); + }; + + const anyDirty = (data ?? []).some((reconstruction, idx) => isRowDirty(idx, reconstruction.primary_sequence)); + + const handleCancelEdit = () => { + if (data) { + const initial: Record = {}; + data.forEach((reconstruction, idx) => { + initial[idx] = blocksFromSequence(savedSequenceFor(item, idx, reconstruction.primary_sequence)); + }); + setDrafts(initial); + } + setEditing(false); + }; + + const handleSaveAll = async () => { + if (item.kind !== "compound" || !data) return; + + const overrides: Record = {}; + data.forEach((reconstruction, idx) => { + if (isRowDirty(idx, reconstruction.primary_sequence)) { + overrides[idx] = sequenceFromBlocks(drafts[idx] ?? []); + } + }); + if (Object.keys(overrides).length === 0) return; + + setSaving(true); + try { + const nextSession = await saveEditedPrimarySequences(session, item.id, overrides); + setSession(() => nextSession); + pushNotification("Saved edited primary sequence(s).", "success"); + setEditing(false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to save edited sequence(s): ${msg}`, "error"); + } finally { + setSaving(false); + } + }; + + const handleRevertRow = async (idx: number, original: PrimarySequenceItem[]) => { + setDrafts((prev) => ({ ...prev, [idx]: blocksFromSequence(original) })); + + if (item.kind !== "compound" || !item.editedPrimarySequences?.[String(idx)]) return; + + setRevertingIdx(idx); + try { + const nextSession = await revertEditedPrimarySequence(session, item.id, idx); + setSession(() => nextSession); + pushNotification("Reverted to the algorithm-parsed sequence.", "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to revert sequence: ${msg}`, "error"); + } finally { + setRevertingIdx(null); + } + }; + + return { + editing, + setEditing, + drafts, + setDrafts, + saving, + revertingIdx, + anyDirty, + isRowDirty, + handleCancelEdit, + handleSaveAll, + handleRevertRow, + }; +} + +export type PrimarySequenceEditorState = ReturnType; + +function PrimarySequenceChips({ + sequence, + selectedTags, + onToggleMotif, +}: { + sequence: PrimarySequenceItem[]; + selectedTags: number[]; + onToggleMotif: (tags: number[]) => void; +}) { + return ( + + {sequence.map(([name, tags], idx) => { + const isSelected = tags.length > 0 && tags.every((tag) => selectedTags.includes(tag)); + + return ( + onToggleMotif(tags)} + sx={{ + px: 1.25, + py: 0.75, + borderRadius: 1, + border: "1px solid", + borderColor: isSelected ? "primary.main" : "divider", + bgcolor: isSelected ? "primary.main" : "background.paper", + color: isSelected ? "primary.contrastText" : "text.primary", + fontSize: "0.875rem", + fontWeight: 500, + cursor: "pointer", + userSelect: "none", + whiteSpace: "nowrap", + flexShrink: 0, + "&:hover": { + borderColor: "primary.main", + bgcolor: isSelected ? "primary.dark" : "action.hover", + }, + }} + > + + + ); + })} + + ); +} + +// Renders one row per reconstruction: a label (+ "Edited" chip), and either the +// read-only chip row or the live SequenceEditor + per-row revert control. +export function PrimarySequenceRows({ + item, + data, + state, + selectedTags, + onToggleMotif, + labelWidth = 130, +}: { + item: SessionItem; + data: Reconstruction[]; + state: PrimarySequenceEditorState; + selectedTags: number[]; + onToggleMotif: (tags: number[]) => void; + labelWidth?: number; +}) { + const { editing, drafts, setDrafts, revertingIdx, isRowDirty, handleRevertRow } = state; + + return ( + <> + {data.map((reconstruction, idx) => { + const override = item.kind === "compound" ? item.editedPrimarySequences?.[String(idx)] : undefined; + const draftBlocks = drafts[idx] ?? blocksFromSequence(override ?? reconstruction.primary_sequence); + const dirty = isRowDirty(idx, reconstruction.primary_sequence); + + return ( + + + + {`primary sequence ${idx + 1}`} + + {override && !editing && ( + + )} + + + {editing ? ( + <> + + setDrafts((prev) => ({ ...prev, [idx]: blocks }))} + showProvenance + selectedTags={selectedTags} + onBlockClick={onToggleMotif} + /> + + + + handleRevertRow(idx, reconstruction.primary_sequence)} + > + {revertingIdx === idx ? : } + + + + + ) : ( + + + + )} + + ); + })} + + ); +} diff --git a/gui/src/client/src/components/workspace/SequenceEditor.tsx b/gui/src/client/src/components/workspace/SequenceEditor.tsx index 58236e9..4af2935 100644 --- a/gui/src/client/src/components/workspace/SequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/SequenceEditor.tsx @@ -2,6 +2,7 @@ import React from "react"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; import Autocomplete from "@mui/material/Autocomplete"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import CloseIcon from "@mui/icons-material/Close"; @@ -29,31 +30,51 @@ import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { searchMonomerNames } from "../../features/discovery/api"; import type { MonomerNameOption } from "../../features/discovery/types"; -export type SequenceBlock = { id: string; name: string }; +// tags carries the source atom indices this block was mined from (if any). A +// block with no tags (freshly added, or otherwise hand-edited) has no atoms to +// highlight -- see `showProvenance` below. +export type SequenceBlock = { id: string; name: string; tags?: number[] }; type SequenceEditorProps = { blocks: SequenceBlock[]; onChange: (blocks: SequenceBlock[]) => void; disabled?: boolean; + // When true, visually distinguishes blocks that are still linked to real + // parsed atoms (solid border, clickable) from ones that aren't (dashed + // border) -- e.g. when this editor sits next to the structure it was mined + // from and edits shouldn't be mistaken for algorithm output. + showProvenance?: boolean; + selectedTags?: number[]; + onBlockClick?: (tags: number[]) => void; }; function SortableBlock({ block, disabled, + showProvenance, + selected, onDelete, + onClick, }: { block: SequenceBlock; disabled?: boolean; + showProvenance?: boolean; + selected?: boolean; onDelete: (id: string) => void; + onClick?: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id, disabled, }); - return ( + const linked = (block.tags?.length ?? 0) > 0; + const clickable = showProvenance && linked && !!onClick; + + const content = ( @@ -90,12 +115,30 @@ function SortableBlock({ {!disabled && ( - onDelete(block.id)} sx={{ p: 0.25 }}> + { + e.stopPropagation(); + onDelete(block.id); + }} + sx={{ p: 0.25, color: selected ? "inherit" : undefined }} + > )} ); + + if (!showProvenance) return content; + + return ( + + {content} + + ); } function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name: string) => void }) { @@ -148,9 +191,15 @@ function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name getOptionLabel={(option) => option.name} isOptionEqualToValue={(option, value) => option.name === value.name} inputValue={inputValue} - onInputChange={(_, value) => { + onInputChange={(_, value, reason) => { setInputValue(value); - setSelected(null); + // MUI fires this right after onChange too (reason "reset", syncing the + // input text to the newly-picked option's label) -- clearing `selected` + // unconditionally here would immediately undo that selection. Only clear + // it when the user is actually typing. + if (reason === "input") { + setSelected(null); + } }} value={selected} onChange={(_, value) => setSelected(value)} @@ -168,7 +217,14 @@ function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name ); } -export const SequenceEditor: React.FC = ({ blocks, onChange, disabled = false }) => { +export const SequenceEditor: React.FC = ({ + blocks, + onChange, + disabled = false, + showProvenance = false, + selectedTags = [], + onBlockClick, +}) => { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -189,18 +245,31 @@ export const SequenceEditor: React.FC = ({ blocks, onChange onChange(blocks.filter((b) => b.id !== id)); }; - // Requirement: adding always appends to the end. + // Requirement: adding always appends to the end. Manually added blocks carry + // no tags -- there's no parsed atom range for them to link to. const handleAdd = (name: string) => { - onChange([...blocks, { id: crypto.randomUUID(), name }]); + onChange([...blocks, { id: crypto.randomUUID(), name, tags: [] }]); }; return ( b.id)} strategy={horizontalListSortingStrategy}> - {blocks.map((block) => ( - - ))} + {blocks.map((block) => { + const linked = (block.tags?.length ?? 0) > 0; + const selected = linked && block.tags!.every((tag) => selectedTags.includes(tag)); + return ( + onBlockClick?.(block.tags!) : undefined} + /> + ); + })} {!disabled && } diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index 8930a15..a18e8e7 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -325,6 +325,7 @@ export const WorkspaceDiscovery: React.FC = ({ session const clusterCount = session.items.length - compoundItems.length; const [selectedItemId, setSelectedItemId] = React.useState(""); + const selectedItem = compoundItems.find((item) => item.id === selectedItemId); const [blocks, setBlocks] = React.useState([]); const [entryType, setEntryType] = React.useState("compound"); @@ -475,26 +476,37 @@ export const WorkspaceDiscovery: React.FC = ({ session )} - {(reconstructionQuery.data ?? []).map((reconstruction, idx) => ( - - - - - ))} + {(reconstructionQuery.data ?? []).map((reconstruction, idx) => { + // Prefer whatever was saved for this reconstruction in the Upload + // tab's viewer over the raw algorithm output, so a correction made + // there is what actually gets queried here. + const override = selectedItem?.editedPrimarySequences?.[String(idx)]; + const effectiveSequence = override ?? reconstruction.primary_sequence; + + return ( + + + {override && ( + + )} + + + ); + })} )} diff --git a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index 2addec4..fd8057f 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -1,5 +1,9 @@ import React from "react"; +import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import Divider from "@mui/material/Divider"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Checkbox from "@mui/material/Checkbox"; @@ -8,12 +12,17 @@ import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import DeleteIcon from "@mui/icons-material/Delete"; import ViewIcon from "@mui/icons-material/Visibility"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import CircularProgress from "@mui/material/CircularProgress"; +import { useQuery } from "@tanstack/react-query"; import { Gauge } from "@mui/x-charts/Gauge"; -import { SessionItem } from "../../features/session/types"; +import { Session, SessionItem } from "../../features/session/types"; import { alpha } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import { DialogViewItem } from "./DialogViewItem"; +import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor"; +import { reconstructCompound } from "../../features/reconstruction/api"; import { useTick } from "../../hooks/useTick"; function getScoreColor(theme: Theme, value: number): string { @@ -24,7 +33,8 @@ function getScoreColor(theme: Theme, value: number): string { }; type WorkspaceItemCardProps = { - sessionId: string; + session: Session; + setSession: React.Dispatch>; item: SessionItem; selected: boolean; disabled?: boolean; @@ -54,7 +64,8 @@ function formatUpdatedAgo(updatedAt?: number): string { }; export const WorkspaceItemCard: React.FC = ({ - sessionId, + session, + setSession, item, selected, disabled = false, @@ -65,6 +76,8 @@ export const WorkspaceItemCard: React.FC = ({ const itemScore = typeof item.score === "number" ? item.score : 0.0; const [openViewItem, setOpenViewItem] = React.useState(false); + const [expanded, setExpanded] = React.useState(false); + const [selectedTags, setSelectedTags] = React.useState([]); // Re-render every 5s so "X ago" updates, via one shared timer for all cards useTick(5000); @@ -85,6 +98,24 @@ export const WorkspaceItemCard: React.FC = ({ setOpenViewItem(true); }; + const handleToggleMotif = (tags: number[]) => { + setSelectedTags((prev) => { + const allSelected = tags.every((tag) => prev.includes(tag)); + if (allSelected) return prev.filter((tag) => !tags.includes(tag)); + return Array.from(new Set([...prev, ...tags])); + }); + }; + + // Shares its query cache (and edit state machine) with DialogViewItem -- expanding + // here and opening "View item" for the same compound don't refetch or diverge. + const reconstructionQuery = useQuery({ + queryKey: ["reconstructCompound", session.sessionId, item.id], + queryFn: ({ signal }) => reconstructCompound(session.sessionId, item.id, signal), + enabled: expanded && isCompound, + }); + const reconstructions = reconstructionQuery.data ?? null; + const editor = usePrimarySequenceEditor(session, setSession, item, reconstructions); + return ( <> = ({ > + + + { + e.stopPropagation(); + if (disabled) return; + setExpanded((prev) => !prev); + }} + > + {expanded ? : } + + + + + {isCompound && ( + + e.stopPropagation()} sx={{ pt: 0.5 }}> + + + {reconstructionQuery.isLoading && } + + {reconstructionQuery.error && ( + + {(reconstructionQuery.error as Error).message || "Failed to load reconstruction."} + + )} + + {reconstructions && reconstructions.length === 0 && ( + + No reconstructed primary sequences found for this compound. + + )} + + {reconstructions && reconstructions.length > 0 && ( + + + + + + + {editor.editing ? ( + <> + + + + ) : ( + + )} + + + )} + + + )} setOpenViewItem(false)} diff --git a/gui/src/client/src/components/workspace/WorkspaceUpload.tsx b/gui/src/client/src/components/workspace/WorkspaceUpload.tsx index 7cb54c6..521831c 100644 --- a/gui/src/client/src/components/workspace/WorkspaceUpload.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceUpload.tsx @@ -392,7 +392,8 @@ export const WorkspaceUpload: React.FC = ({ session, setSe {session.items.map((item) => ( ) => Record, +): Session { + return { + ...session, + items: session.items.map((it) => { + if (it.id !== itemId || it.kind !== "compound") return it; + return { ...it, editedPrimarySequences: mutate(it.editedPrimarySequences ?? {}) }; + }), + }; +} + +// Persists user-edited primary sequence(s) for one compound item, keyed by +// reconstruction index. Only the given indices are touched -- any other saved +// overrides on this item (or edits on other items) are carried over as-is. +export async function saveEditedPrimarySequences( + session: Session, + itemId: string, + overrides: Record, +): Promise { + const nextSession = withEditedPrimarySequences(session, itemId, (current) => { + const next = { ...current }; + for (const [idx, sequence] of Object.entries(overrides)) { + next[idx] = sequence; + } + return next; + }); + await saveSession(nextSession); + return nextSession; +} + +// Clears a saved edit for one reconstruction index. We never persist a copy of +// the algorithm's own output, so "reverting" is just deleting the override -- +// the next render falls back to the (always freshly-fetched) parsed sequence. +export async function revertEditedPrimarySequence( + session: Session, + itemId: string, + reconstructionIndex: number, +): Promise { + const nextSession = withEditedPrimarySequences(session, itemId, (current) => { + const { [String(reconstructionIndex)]: _removed, ...rest } = current; + return rest; + }); + await saveSession(nextSession); + return nextSession; +} diff --git a/gui/src/client/src/features/session/types.ts b/gui/src/client/src/features/session/types.ts index 041c847..ac68757 100644 --- a/gui/src/client/src/features/session/types.ts +++ b/gui/src/client/src/features/session/types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { PrimarySequenceItemSchema } from "../reconstruction/types"; export const BaseItemSchema = z.object({ id: z.string(), @@ -13,6 +14,12 @@ export const CompoundItemSchema = BaseItemSchema.extend({ kind: z.literal("compound"), smiles: z.string(), matchStereochemistry: z.boolean(), + // User overrides for the primary sequence(s) RetroMol parsed out of this + // compound, keyed by reconstruction index (stringified, for JSON-safe object + // keys). A missing key means "use the algorithm's own parse" -- reverting an + // edit just deletes that key rather than storing a copy of the original, so + // the original can never go stale. + editedPrimarySequences: z.record(z.string(), z.array(PrimarySequenceItemSchema)).nullable().optional(), }); export const ClusterItemSchema = BaseItemSchema.extend({