From 65ccaafb657eb26f9a52d322e4f788c026f81a37 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 22:27:35 +0200 Subject: [PATCH 01/26] ENH: bump major version --- gui/src/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/client/package.json b/gui/src/client/package.json index b7f4697..ca47a7f 100644 --- a/gui/src/client/package.json +++ b/gui/src/client/package.json @@ -1,6 +1,6 @@ { "name": "retromol-gui", - "version": "0.1.0", + "version": "1.0.0", "private": true, "dependencies": { "@dnd-kit/core": "^6.3.1", From f5c458d73f088c22ddcb405b585aa7261161cf14 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 22:27:52 +0200 Subject: [PATCH 02/26] DEL: remove uninformative stats on dashboard --- .../components/workspace/WorkspaceHome.tsx | 83 +------------------ gui/src/client/src/features/database/types.ts | 4 - src/retromol_database/duckdb.py | 63 +------------- 3 files changed, 3 insertions(+), 147 deletions(-) diff --git a/gui/src/client/src/components/workspace/WorkspaceHome.tsx b/gui/src/client/src/components/workspace/WorkspaceHome.tsx index c96f95e..96e953f 100644 --- a/gui/src/client/src/components/workspace/WorkspaceHome.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceHome.tsx @@ -9,7 +9,6 @@ import Skeleton from "@mui/material/Skeleton"; import { useTheme } from "@mui/material/styles"; import { Link as RouterLink } from "react-router-dom"; import { PieChart } from "@mui/x-charts/PieChart"; -import { BarChart } from "@mui/x-charts/BarChart"; import { getDatabaseStats } from "../../features/database/api"; import { DatabaseStatsResp } from "../../features/database/types"; @@ -92,8 +91,6 @@ export const WorkspaceHome: React.FC = () => { palette.palette.secondary.main, ]; - const resolvedPct = stats && stats.totalEntries > 0 ? (100 * stats.fullyResolvedCount) / stats.totalEntries : 0; - return ( { {!error && loading && ( - {Array.from({ length: 4 }).map((_, i) => ( + {Array.from({ length: 3 }).map((_, i) => ( ))} @@ -178,11 +175,6 @@ export const WorkspaceHome: React.FC = () => { value={stats.sequenceLengthAvg.toFixed(1)} caption={`Range ${stats.sequenceLengthMin}–${stats.sequenceLengthMax} blocks`} /> - @@ -206,80 +198,7 @@ export const WorkspaceHome: React.FC = () => { slotProps={{ legend: { direction: "vertical" } }} /> - - - - - - - b.label), - label: "Blocks in sequence", - }, - ]} - yAxis={[{ label: "Entries" }]} - series={[ - { - data: stats.sequenceLengthBuckets.map((b) => b.count), - label: "Entries", - color: palette.palette.primary.main, - }, - ]} - height={280} - hideLegend - grid={{ horizontal: true }} - /> - - - - b.label), - }, - ]} - xAxis={[{ label: "Occurrences" }]} - series={[ - { - data: [...stats.topBlocks].reverse().map((b) => b.count), - label: "Occurrences", - color: palette.palette.primary.main, - }, - ]} - height={360} - hideLegend - grid={{ vertical: true }} - margin={{ left: 110 }} - /> - )} diff --git a/gui/src/client/src/features/database/types.ts b/gui/src/client/src/features/database/types.ts index 2658c7a..c46d9c2 100644 --- a/gui/src/client/src/features/database/types.ts +++ b/gui/src/client/src/features/database/types.ts @@ -12,11 +12,7 @@ export const DatabaseStatsRespSchema = z.object({ sequenceLengthMin: z.number().int().nonnegative(), sequenceLengthMax: z.number().int().nonnegative(), sequenceLengthAvg: z.number().nonnegative(), - sequenceLengthBuckets: z.array(CountSchema), - topBlocks: z.array(CountSchema), uniqueBlockCount: z.number().int().nonnegative(), - fullyResolvedCount: z.number().int().nonnegative(), - hasUnknownBlockCount: z.number().int().nonnegative(), withSourceUrlCount: z.number().int().nonnegative(), withoutSourceUrlCount: z.number().int().nonnegative(), }); diff --git a/src/retromol_database/duckdb.py b/src/retromol_database/duckdb.py index a0a4a6d..84e4ad9 100644 --- a/src/retromol_database/duckdb.py +++ b/src/retromol_database/duckdb.py @@ -13,11 +13,6 @@ EntryType = Literal["compound", "bgc"] FINGERPRINT_SIZE = 1024 -# Upper bound on primary-sequence length that still gets its own histogram bucket; -# anything longer is folded into a single overflow bucket so a handful of very long -# sequences can't blow up the number of bars in a chart. -STATS_SEQUENCE_LENGTH_BUCKET_MAX = 10 - @dataclass(frozen=True) class Entry: @@ -49,11 +44,7 @@ class DatabaseStats: sequence_length_min: int sequence_length_max: int sequence_length_avg: float - sequence_length_buckets: list[Count] - top_blocks: list[Count] unique_block_count: int - fully_resolved_count: int - has_unknown_block_count: int with_source_url_count: int without_source_url_count: int @@ -230,7 +221,7 @@ def add_entries(self, entries: Iterable[Entry]) -> int: def count(self) -> int: return int(self.con.execute("SELECT count(*) FROM entries").fetchone()[0]) - def stats(self, *, top_blocks_limit: int = 12) -> DatabaseStats: + def stats(self) -> DatabaseStats: """ Compute summary statistics over the whole entries table, for display on a dashboard/overview page. @@ -238,10 +229,8 @@ def stats(self, *, top_blocks_limit: int = 12) -> DatabaseStats: Building blocks are the tokens in each entry's primary_sequence -- e.g. amino acid names, PK reduction-state groups, or tailoring events like "methylation". TOKEN_UNK ("") marks a block RetroMol couldn't identify and is excluded - from top_blocks/unique_block_count since it isn't a real building block, but is - still reflected in fully_resolved_count/has_unknown_block_count. + from unique_block_count since it isn't a real building block. - :param top_blocks_limit: how many of the most frequent building blocks to return :return: a DatabaseStats snapshot """ total_entries = self.count() @@ -264,38 +253,6 @@ def stats(self, *, top_blocks_limit: int = 12) -> DatabaseStats: sequence_length_max = int(length_row[1]) if length_row[1] is not None else 0 sequence_length_avg = float(length_row[2]) if length_row[2] is not None else 0.0 - bucket_rows = self.con.execute( - f""" - SELECT bucket_label, count(*) AS n - FROM ( - SELECT - CASE - WHEN len(primary_sequence) >= {STATS_SEQUENCE_LENGTH_BUCKET_MAX} - THEN '{STATS_SEQUENCE_LENGTH_BUCKET_MAX}+' - ELSE len(primary_sequence)::VARCHAR - END AS bucket_label, - least(len(primary_sequence), {STATS_SEQUENCE_LENGTH_BUCKET_MAX}) AS bucket_order - FROM entries - ) - GROUP BY bucket_label, bucket_order - ORDER BY bucket_order - """ - ).fetchall() - sequence_length_buckets = [Count(label=str(row[0]), count=int(row[1])) for row in bucket_rows] - - top_block_rows = self.con.execute( - """ - SELECT token, count(*) AS n - FROM (SELECT unnest(primary_sequence) AS token FROM entries) - WHERE token != ? - GROUP BY token - ORDER BY n DESC, token - LIMIT ? - """, - [TOKEN_UNK, top_blocks_limit], - ).fetchall() - top_blocks = [Count(label=str(row[0]), count=int(row[1])) for row in top_block_rows] - unique_block_count = int( self.con.execute( """ @@ -307,18 +264,6 @@ def stats(self, *, top_blocks_limit: int = 12) -> DatabaseStats: ).fetchone()[0] ) - resolution_row = self.con.execute( - """ - SELECT - count(*) FILTER (WHERE NOT list_contains(primary_sequence, ?)), - count(*) FILTER (WHERE list_contains(primary_sequence, ?)) - FROM entries - """, - [TOKEN_UNK, TOKEN_UNK], - ).fetchone() - fully_resolved_count = int(resolution_row[0]) - has_unknown_block_count = int(resolution_row[1]) - url_row = self.con.execute( """ SELECT @@ -336,11 +281,7 @@ def stats(self, *, top_blocks_limit: int = 12) -> DatabaseStats: sequence_length_min=sequence_length_min, sequence_length_max=sequence_length_max, sequence_length_avg=sequence_length_avg, - sequence_length_buckets=sequence_length_buckets, - top_blocks=top_blocks, unique_block_count=unique_block_count, - fully_resolved_count=fully_resolved_count, - has_unknown_block_count=has_unknown_block_count, with_source_url_count=with_source_url_count, without_source_url_count=without_source_url_count, ) From e1b7c33808f4f4c1afe426d260c73c32f1e50cd6 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 22:29:52 +0200 Subject: [PATCH 03/26] UPD: new query defaults --- .../client/src/components/workspace/WorkspaceDiscovery.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index 4c72b28..d2132e9 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -181,12 +181,12 @@ export const WorkspaceDiscovery: React.FC = ({ session, const [blocks, setBlocks] = React.useState([]); const [entryType, setEntryType] = React.useState("compound"); - const [scoreMode, setScoreMode] = React.useState("subsequence"); - const [n, setN] = React.useState(100); + const [scoreMode, setScoreMode] = React.useState("longest_sequence"); + const [n, setN] = React.useState(1000); const [topX, setTopX] = React.useState(20); const [includeUserUploads, setIncludeUserUploads] = React.useState(false); const [onlyUserUploads, setOnlyUserUploads] = React.useState(false); - const [computeMsa, setComputeMsa] = React.useState(false); + const [computeMsa, setComputeMsa] = React.useState(true); const [computeCompare, setComputeCompare] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); From 9aeb5aa25a38254f2f4c0746083027b746e88f00 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 22:43:40 +0200 Subject: [PATCH 04/26] ADD: tooltip info to coverage scores --- .../components/workspace/DialogViewItem.tsx | 20 +++++++ .../workspace/WorkspaceItemCard.tsx | 57 +++++++++++-------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index 72a390e..eaa3f4c 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -2,7 +2,10 @@ import React from "react"; import Alert from "@mui/material/Alert"; import CircularProgress from "@mui/material/CircularProgress"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { useQuery } from "@tanstack/react-query"; import { Session, SessionItem } from "../../features/session/types"; import { reconstructCompound } from "../../features/reconstruction/api"; @@ -84,6 +87,7 @@ export const DialogViewItem: React.FC = ({ }) => { const sessionId = session.sessionId; const isCompound = item.kind === "compound"; // there are only two types: "compound" and "cluster" + const itemScore = typeof item.score === "number" ? item.score : 0; const [selectedTags, setSelectedTags] = React.useState([]); @@ -215,6 +219,22 @@ export const DialogViewItem: React.FC = ({ {(isCompound && !loading && !error) && ( + {(itemScore < 0.5 || (data?.length ?? 0) === 0) && ( + + + + + + Low coverage -- parts of the structure below may be sparse or empty + + + + + )} = ({ }} /> - getScoreColor(theme, itemScore), - transition: "stroke-dashoffset 0.3s ease", - }, - }} - text={({ value }) => `${value}%`} - /> + + getScoreColor(theme, itemScore), + transition: "stroke-dashoffset 0.3s ease", + }, + }} + text={({ value }) => `${value}%`} + /> + From c03da7ac0de2db5ed8804a11176f03fb7b6e6654 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 23:30:31 +0200 Subject: [PATCH 05/26] ENH: edit name of workspace item --- .../src/components/MinimalIconButton.tsx | 43 ++++++ .../workspace/WorkspaceItemCard.tsx | 122 +++++++++++++++++- gui/src/client/src/features/session/api.ts | 9 ++ gui/src/server/routes/session_store.py | 1 - 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 gui/src/client/src/components/MinimalIconButton.tsx diff --git a/gui/src/client/src/components/MinimalIconButton.tsx b/gui/src/client/src/components/MinimalIconButton.tsx new file mode 100644 index 0000000..217ff8c --- /dev/null +++ b/gui/src/client/src/components/MinimalIconButton.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import IconButton, { IconButtonProps } from "@mui/material/IconButton"; + +// A borderless, paddingless IconButton for inline "icon next to text" affordances +// (e.g. a rename pencil sitting right next to a label) where a normal IconButton's +// padding/hover halo would look out of place. Forwards its ref so it still works +// as the direct child of a Tooltip. +export const MinimalIconButton = React.forwardRef( + ({ sx, size = "small", ...props }, ref) => ( + + ) +); + +MinimalIconButton.displayName = "MinimalIconButton"; diff --git a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index 77d3792..bacfacd 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -9,15 +9,20 @@ import Typography from "@mui/material/Typography"; import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import DeleteIcon from "@mui/icons-material/Delete"; import ViewIcon from "@mui/icons-material/Visibility"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; 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 { Session, SessionItem } from "../../features/session/types"; +import { renameSessionItem } from "../../features/session/api"; import { alpha } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import { DialogViewItem } from "./DialogViewItem"; @@ -26,6 +31,8 @@ import { ClusterReadoutRows } from "./ClusterReadoutRows"; import { reconstructCompound } from "../../features/reconstruction/api"; import { getClusterReadout } from "../../features/clusters/api"; import { useTick } from "../../hooks/useTick"; +import { useNotifications } from "../NotificationProvider"; +import { MinimalIconButton } from "../MinimalIconButton"; function getScoreColor(theme: Theme, value: number): string { const t = theme.vars || theme; @@ -77,10 +84,22 @@ export const WorkspaceItemCard: React.FC = ({ const isCompound = item.kind === "compound"; // there are only two types: "compound" and "cluster" const itemScore = typeof item.score === "number" ? item.score : 0.0; + const { pushNotification } = useNotifications(); + const [openViewItem, setOpenViewItem] = React.useState(false); const [expanded, setExpanded] = React.useState(false); const [selectedTags, setSelectedTags] = React.useState([]); + const [editingName, setEditingName] = React.useState(false); + const [nameDraft, setNameDraft] = React.useState(item.name); + const [savingName, setSavingName] = React.useState(false); + + // Keep the draft in sync with the persisted name as long as we're not mid-edit + // (e.g. another tab/session update coming through). + React.useEffect(() => { + if (!editingName) setNameDraft(item.name); + }, [item.name, editingName]); + // Re-render every 5s so "X ago" updates, via one shared timer for all cards useTick(5000); @@ -108,6 +127,42 @@ export const WorkspaceItemCard: React.FC = ({ }); }; + const handleStartEditName = (e: React.SyntheticEvent) => { + e.stopPropagation(); + if (disabled) return; + setNameDraft(item.name); + setEditingName(true); + }; + + const handleCancelEditName = () => { + setNameDraft(item.name); + setEditingName(false); + }; + + const handleSaveName = async () => { + const trimmed = nameDraft.trim(); + if (!trimmed) { + pushNotification("Name cannot be empty.", "error"); + return; + } + if (trimmed === item.name) { + setEditingName(false); + return; + } + + setSavingName(true); + try { + const nextSession = await renameSessionItem(session, item.id, trimmed); + setSession(() => nextSession); + setEditingName(false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to rename item: ${msg}`, "error"); + } finally { + setSavingName(false); + } + }; + // 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({ @@ -207,15 +262,66 @@ export const WorkspaceItemCard: React.FC = ({ /> - + - + {editingName ? ( + e.stopPropagation()} + > + setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveName(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelEditName(); + } + }} + size="small" + variant="standard" + autoFocus + disabled={savingName} + sx={{ minWidth: 0, width: "auto" }} + /> + + + + {savingName ? : } + + + + + + + + + + + + ) : ( + = ({ > {item.name} - + + + + + + + + + )} diff --git a/gui/src/client/src/features/session/api.ts b/gui/src/client/src/features/session/api.ts index 474e050..bce248e 100644 --- a/gui/src/client/src/features/session/api.ts +++ b/gui/src/client/src/features/session/api.ts @@ -39,6 +39,15 @@ export async function deleteSessionItem(sessionId: string, itemId: string): Prom await postJson("/api/deleteSessionItem", { sessionId, itemId }, OkRespSchema); }; +export async function renameSessionItem(session: Session, itemId: string, name: string): Promise { + const nextSession: Session = { + ...session, + items: session.items.map((it) => (it.id === itemId ? { ...it, name } : it)), + }; + await saveSession(nextSession); + return nextSession; +}; + // EventSource can only issue a plain GET (no custom headers/body), so the SSE // endpoint can't be authorized with a POSTed sessionId like everything else // here. Instead we mint a short-lived, single-use ticket via a normal POST, diff --git a/gui/src/server/routes/session_store.py b/gui/src/server/routes/session_store.py index 3c58a47..3ebaf23 100644 --- a/gui/src/server/routes/session_store.py +++ b/gui/src/server/routes/session_store.py @@ -40,7 +40,6 @@ # Fields that are owned by the server and should not be overwritten by client data # Item 'name' and 'updatedAt' are client-editable SERVER_OWNED_FIELDS = { - "name", "score", "payload", "status", From c0c6d39f471fd99b668ac8c2ec77c72af04bc6ea Mon Sep 17 00:00:00 2001 From: David Meijer Date: Fri, 7 Aug 2026 23:38:23 +0200 Subject: [PATCH 06/26] UPD: tooltips for workspace items --- .../workspace/WorkspaceItemCard.tsx | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index bacfacd..3a149f5 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -436,28 +436,32 @@ export const WorkspaceItemCard: React.FC = ({ /> )} - { - event.stopPropagation(); - if (disabled) return; - handleOpenViewItem(event); - }} - > - - - { - e.stopPropagation(); - if (disabled) return; - onDelete(item.id); - }} - > - - + + { + event.stopPropagation(); + if (disabled) return; + handleOpenViewItem(event); + }} + > + + + + + { + e.stopPropagation(); + if (disabled) return; + onDelete(item.id); + }} + > + + + Date: Fri, 7 Aug 2026 23:38:43 +0200 Subject: [PATCH 07/26] FIX: add dev suffix to version for now --- gui/src/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/client/package.json b/gui/src/client/package.json index ca47a7f..df2c4dc 100644 --- a/gui/src/client/package.json +++ b/gui/src/client/package.json @@ -1,6 +1,6 @@ { "name": "retromol-gui", - "version": "1.0.0", + "version": "1.0.0-dev", "private": true, "dependencies": { "@dnd-kit/core": "^6.3.1", From e9e7970ef620a492bbe8c7abc4dcabac4a716c83 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sat, 8 Aug 2026 01:40:13 +0200 Subject: [PATCH 08/26] STY: update styling and user experience primary sequence editing --- .../components/workspace/DialogViewItem.tsx | 8 +- .../workspace/PrimarySequenceEditor.tsx | 243 +++++++++++++----- .../components/workspace/SequenceEditor.tsx | 96 +++++-- .../workspace/WorkspaceItemCard.tsx | 15 +- 4 files changed, 251 insertions(+), 111 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index eaa3f4c..d93cd5f 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -332,7 +332,11 @@ export const DialogViewItem: React.FC = ({ = ({ )} diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx index 0c789bb..565ded9 100644 --- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -3,7 +3,6 @@ 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"; @@ -14,6 +13,7 @@ import { useNotifications } from "../NotificationProvider"; import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { SequenceEditor, type SequenceBlock } from "./SequenceEditor"; +import { MinimalIconButton } from "../MinimalIconButton"; export function blocksFromSequence(sequence: PrimarySequenceItem[]): SequenceBlock[] { return sequence.map(([name, tags]) => ({ id: crypto.randomUUID(), name, tags })); @@ -150,41 +150,89 @@ function PrimarySequenceChips({ }: { sequence: PrimarySequenceItem[]; selectedTags: number[]; - onToggleMotif: (tags: number[]) => void; + // Omit this to render a plain, non-interactive chip row -- e.g. the inline + // preview in the Upload list, which has no molecule view alongside it for a + // highlight to make sense against. Only the "View item" dialog wires this up. + onToggleMotif?: (tags: number[]) => void; }) { + const selectable = !!onToggleMotif; + return ( - - {sequence.map(([name, tags], idx) => { - const isSelected = tags.length > 0 && tags.every((tag) => selectedTags.includes(tag)); + + 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", - }, - }} - > - - - ); - })} + // The connecting "string" + "&::before": { + content: '""', + position: "absolute", + left: 0, + right: 0, + top: "50%", + height: "2px", + backgroundColor: "divider", + zIndex: 0, + }, + }} + > + {sequence.map(([name, tags], idx) => { + const linked = tags.length > 0; + const isSelected = selectable && linked && tags.every((tag) => selectedTags.includes(tag)); + + const chip = ( + onToggleMotif!(tags) : undefined} + sx={{ + px: 1.25, + py: 0.75, + borderRadius: 1, + border: "1px solid", + borderStyle: linked ? "solid" : "dashed", + borderColor: isSelected ? "primary.main" : "divider", + bgcolor: isSelected ? "primary.main" : "background.paper", + color: isSelected ? "primary.contrastText" : "text.primary", + fontSize: "0.875rem", + fontWeight: 500, + cursor: selectable ? "pointer" : "default", + userSelect: "none", + whiteSpace: "nowrap", + flexShrink: 0, + position: "relative", + zIndex: 1, + ...(selectable && { + "&:hover": { + borderColor: "primary.main", + }, + }), + }} + > + + + ); + + // Same rule as the drag-and-drop editor's blocks: only claim it's + // clickable when it actually is (see SortableBlock in SequenceEditor). + const title = !linked + ? "Not linked to the original structure (added or edited by hand)" + : selectable + ? "Click to highlight the source atoms" + : ""; + + if (!title) return chip; + + return ( + + {chip} + + ); + })} + ); } @@ -197,76 +245,133 @@ export function PrimarySequenceRows({ state, selectedTags, onToggleMotif, - labelWidth = 130, + labelWidth, }: { item: SessionItem; data: Reconstruction[]; state: PrimarySequenceEditorState; selectedTags: number[]; - onToggleMotif: (tags: number[]) => void; + // Omit to disable click-to-highlight entirely (see PrimarySequenceChips) -- + // used for both the read-only chip row and the drag-and-drop editor's blocks. + onToggleMotif?: (tags: number[]) => void; labelWidth?: number; }) { const { editing, drafts, setDrafts, revertingIdx, isRowDirty, handleRevertRow } = state; + const labelColumn = labelWidth ? `${labelWidth}px` : "max-content"; + 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 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 ( - - + {/* Label column */} + + {`primary sequence ${idx + 1}`} + {override && !editing && ( )} - - - {editing ? ( - <> - - setDrafts((prev) => ({ ...prev, [idx]: blocks }))} - showProvenance - selectedTags={selectedTags} - onBlockClick={onToggleMotif} - /> + + + {/* Sequence column */} + + {editing ? ( + + + + setDrafts((prev) => ({ + ...prev, + [idx]: blocks, + })) + } + showProvenance + selectedTags={selectedTags} + onBlockClick={onToggleMotif} + /> + + + + + + handleRevertRow(idx, reconstruction.primary_sequence) + } + sx={{ transform: "translateY(-6px)"}} + > + {revertingIdx === idx ? ( + + ) : ( + + )} + + + - - - 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 4af2935..580289c 100644 --- a/gui/src/client/src/components/workspace/SequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/SequenceEditor.tsx @@ -29,6 +29,7 @@ import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { searchMonomerNames } from "../../features/discovery/api"; import type { MonomerNameOption } from "../../features/discovery/types"; +import { MinimalIconButton } from "../MinimalIconButton"; // 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 @@ -85,6 +86,8 @@ function SortableBlock({ px: 1, py: 0.75, borderRadius: 1, + position: "relative", + zIndex: 1, border: "1px solid", borderStyle: showProvenance && !linked ? "dashed" : "solid", borderColor: selected ? "primary.main" : "divider", @@ -115,7 +118,7 @@ function SortableBlock({ {!disabled && ( - { e.stopPropagation(); @@ -124,18 +127,26 @@ function SortableBlock({ sx={{ p: 0.25, color: selected ? "inherit" : undefined }} > - + )} ); if (!showProvenance) return content; + // Only claim it's clickable when it actually is -- e.g. the inline Upload-list + // editor has no molecule view alongside it to highlight, so onClick is omitted + // there and linked blocks get no tooltip at all. + const title = !linked + ? "Not linked to the original structure (added or edited by hand)" + : clickable + ? "Click to highlight the source atoms" + : ""; + + if (!title) return content; + return ( - + {content} ); @@ -173,7 +184,15 @@ function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name size="small" disabled={disabled} onClick={() => setOpen(true)} - sx={{ border: "1px dashed", borderColor: "divider", borderRadius: 1, flexShrink: 0 }} + sx={{ + border: "1px dashed", + borderColor: "divider", + borderRadius: 1, + flexShrink: 0, + position: "relative", + zIndex: 1, + bgcolor: "background.paper", + }} > @@ -181,7 +200,7 @@ function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name } return ( - + {/* freeSolo is intentionally off: block names must resolve to a real monomer identity */} size="small" @@ -253,25 +272,48 @@ export const SequenceEditor: React.FC = ({ return ( - - b.id)} strategy={horizontalListSortingStrategy}> - {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 && } + + + b.id)} strategy={horizontalListSortingStrategy}> + {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/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index 3a149f5..409f73b 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -88,7 +88,6 @@ export const WorkspaceItemCard: React.FC = ({ const [openViewItem, setOpenViewItem] = React.useState(false); const [expanded, setExpanded] = React.useState(false); - const [selectedTags, setSelectedTags] = React.useState([]); const [editingName, setEditingName] = React.useState(false); const [nameDraft, setNameDraft] = React.useState(item.name); @@ -119,14 +118,6 @@ 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])); - }); - }; - const handleStartEditName = (e: React.SyntheticEvent) => { e.stopPropagation(); if (disabled) return; @@ -506,9 +497,7 @@ export const WorkspaceItemCard: React.FC = ({ item={item} data={reconstructions} state={editor} - selectedTags={selectedTags} - onToggleMotif={handleToggleMotif} - labelWidth={110} + selectedTags={[]} /> @@ -529,7 +518,7 @@ export const WorkspaceItemCard: React.FC = ({ ) : ( - )} From f61388c5ea9371e6f99ccc199d59b1a74808829f Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sat, 8 Aug 2026 01:57:13 +0200 Subject: [PATCH 09/26] STY: make dialog windows have lighter backgrounds --- gui/src/client/src/theme/customizations/feedback.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gui/src/client/src/theme/customizations/feedback.tsx b/gui/src/client/src/theme/customizations/feedback.tsx index b61952f..a74926d 100644 --- a/gui/src/client/src/theme/customizations/feedback.tsx +++ b/gui/src/client/src/theme/customizations/feedback.tsx @@ -26,6 +26,13 @@ export const feedbackCustomizations: Components = { borderRadius: "10px", border: "1px solid", borderColor: (theme.vars || theme).palette.divider, + // Dialog content leans on subtle divider-colored lines/borders (e.g. the + // primary-sequence connecting line) that barely register against the + // app's near-black default paper background -- lighten just the dialog + // surface in dark mode so those low-contrast details stay legible. + ...theme.applyStyles("dark", { + backgroundColor: "hsl(220, 20%, 13%)", + }), }, }), }, From 39caa26524883d9dd1d7c416a3bd041ba7c62bd4 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sat, 8 Aug 2026 01:58:15 +0200 Subject: [PATCH 10/26] STY: add some padding at bottom import gene cluster dialog --- .../src/components/workspace/DialogImportGeneClusters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx b/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx index daee2ac..f146b43 100644 --- a/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx +++ b/gui/src/client/src/components/workspace/DialogImportGeneClusters.tsx @@ -47,7 +47,7 @@ export const DialogImportGeneCluster: React.FC = ( { label: "Import", variant: "contained", color: "primary", onClick: handleImport, disabled: !canImport, autoFocus: true }, ]} > - + Select one or more GenBank files (.gbk, .gb, .genbank) containing gene cluster data to import into your workspace. Make sure the files are  From 465846abbe9b30a71b63e6c9827c602a4c0b1b7b Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sat, 8 Aug 2026 02:41:29 +0200 Subject: [PATCH 11/26] ENH: improve visualization RetroMol results in compound view --- .../components/workspace/DialogViewItem.tsx | 251 +++++++++------ .../workspace/PrimarySequenceEditor.tsx | 46 ++- .../workspace/WorkspaceItemCard.tsx | 10 +- .../src/features/reconstruction/types.ts | 12 +- gui/src/server/routes/jobs.py | 4 +- src/retromol_synthesis/reconstruction.py | 301 +++++++++++------- 6 files changed, 402 insertions(+), 222 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index d93cd5f..716f21d 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -128,6 +128,21 @@ export const DialogViewItem: React.FC = ({ ? (reconstructionQuery.error as Error).message || "Unknown error" : null; + // Four possible outcomes from the backend (see retromol_synthesis.reconstruction): + // 1. hasReconstructions && allHaveBackbone: full structure -> backbone -> sequence flow. + // 2. hasReconstructions && !isUnordered && !allHaveBackbone: a primary sequence was + // found, but the hardcoded backbone-fusion chemistry couldn't rebuild a backbone + // structure for it -- skip the backbone step and go straight from structure to + // sequence. + // 3. hasReconstructions && isUnordered: building blocks were identified individually + // (e.g. a branched or cyclic assembly), but couldn't be threaded into a single + // order -- show them as an unordered set, not a sequence. + // 4. !hasReconstructions: nothing identified at all -- show the structure on its own. + const hasReconstructions = (data?.length ?? 0) > 0; + const isUnordered = hasReconstructions && (data ?? []).every((r) => r.ordered === false); + const allHaveBackbone = hasReconstructions && !isUnordered && (data ?? []).every((r) => !!r.tagged_backbone_smiles); + const backboneWarning = (data ?? []).find((r) => r.backbone_warning)?.backbone_warning ?? null; + const clusterReadoutQuery = useQuery({ queryKey: ["getClusterReadout", sessionId, item.id], queryFn: ({ signal }) => getClusterReadout(sessionId, item.id, signal), @@ -169,7 +184,7 @@ export const DialogViewItem: React.FC = ({ variant: "outlined" as const, color: "primary" as const, onClick: () => setEditing(true), - disabled: loading || !data || data.length === 0, + disabled: loading || !data || data.length === 0 || isUnordered, }, ] : []), @@ -219,7 +234,23 @@ export const DialogViewItem: React.FC = ({ {(isCompound && !loading && !error) && ( - {(itemScore < 0.5 || (data?.length ?? 0) === 0) && ( + {!hasReconstructions && ( + + This compound couldn't be parsed with RetroMol's current rule set, so no + primary sequence could be derived from it. The structure is shown below as-is. + + )} + + {hasReconstructions && !allHaveBackbone && ( + + {backboneWarning ?? + (isUnordered + ? "RetroMol identified these building blocks individually, but couldn't determine a biosynthetic order between them." + : "The linear backbone could not be reconstructed for this structure.")} + + )} + + {hasReconstructions && itemScore < 0.5 && ( = ({ - Low coverage -- parts of the structure below may be sparse or empty + Low coverage: parts of the structure below may be sparse or empty @@ -275,110 +306,142 @@ export const DialogViewItem: React.FC = ({ }> - - - - {(data ?? []).map((reconstruction, idx) => ( - - Could not render this structure. - }> - - - - {idx < (data?.length ?? 0) - 1 && ( - + + {allHaveBackbone && ( + <> + + - + - - )} - - ))} - - - - - + {(data ?? []).map((reconstruction, idx) => ( + + Could not render this structure. + }> + + + + {idx < (data?.length ?? 0) - 1 && ( + + + + + )} + + ))} + + + + + )} - + + + + + - - + + )} - + {hasReconstructions && ( + + )} )} diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx index 565ded9..49f1ddc 100644 --- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -147,6 +147,7 @@ function PrimarySequenceChips({ sequence, selectedTags, onToggleMotif, + ordered = true, }: { sequence: PrimarySequenceItem[]; selectedTags: number[]; @@ -154,30 +155,39 @@ function PrimarySequenceChips({ // preview in the Upload list, which has no molecule view alongside it for a // highlight to make sense against. Only the "View item" dialog wires this up. onToggleMotif?: (tags: number[]) => void; + // False renders this as an unordered bag of motifs instead of a sequence: no + // connecting line, no fixed reading order (wraps instead of scrolling as one + // row) -- see Reconstruction.ordered. Must stay visually distinct from the + // ordered case so it's never mistaken for a real primary sequence. + ordered?: boolean; }) { const selectable = !!onToggleMotif; return ( - + {sequence.map(([name, tags], idx) => { @@ -273,6 +283,7 @@ export function PrimarySequenceRows({ blocksFromSequence(override ?? reconstruction.primary_sequence); const dirty = isRowDirty(idx, reconstruction.primary_sequence); + const ordered = reconstruction.ordered !== false; return ( - {`primary sequence ${idx + 1}`} + {ordered ? `primary sequence ${idx + 1}` : "parsed motifs (unordered)"} {override && !editing && ( @@ -328,7 +339,7 @@ export function PrimarySequenceRows({ {/* Sequence column */} - {editing ? ( + {editing && ordered ? ( )} diff --git a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx index 409f73b..c440c38 100644 --- a/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceItemCard.tsx @@ -163,6 +163,9 @@ export const WorkspaceItemCard: React.FC = ({ }); const reconstructions = reconstructionQuery.data ?? null; const editor = usePrimarySequenceEditor(session, setSession, item, reconstructions); + // See DialogViewItem's isUnordered -- an unordered "bag of motifs" result isn't a + // sequence, so there's nothing meaningful to drag-and-drop reorder. + const isUnordered = !!reconstructions?.length && reconstructions.every((r) => r.ordered === false); // Same idea as reconstructionQuery, but for gene clusters -- the parsed module // readout is computed server-side at submit time and only ever handed over via @@ -518,7 +521,12 @@ export const WorkspaceItemCard: React.FC = ({ ) : ( - )} diff --git a/gui/src/client/src/features/reconstruction/types.ts b/gui/src/client/src/features/reconstruction/types.ts index efd5ec3..f7fd5eb 100644 --- a/gui/src/client/src/features/reconstruction/types.ts +++ b/gui/src/client/src/features/reconstruction/types.ts @@ -5,8 +5,18 @@ export type PrimarySequenceItem = z.output; export const ReconstructionSchema = z.object({ tagged_input_smiles: z.string(), - tagged_backbone_smiles: z.string(), + // Null when RetroMol's hardcoded fusion chemistry couldn't rebuild a backbone + // for this path -- see `backbone_warning` in that case. The primary sequence is + // still meaningful (it's derived directly from the source structure), so a + // candidate is kept even without a backbone. + tagged_backbone_smiles: z.string().nullable(), primary_sequence: z.array(PrimarySequenceItemSchema), + backbone_warning: z.string().nullable().default(null), + // False when `primary_sequence` isn't a genuine biosynthetic order -- RetroMol + // identified every building block individually but couldn't connect them into a + // single path (e.g. a branched or cyclic assembly). Render as an unordered set, + // not a sequence. + ordered: z.boolean().default(true), }); export type Reconstruction = z.output; diff --git a/gui/src/server/routes/jobs.py b/gui/src/server/routes/jobs.py index a6cdf68..5c7adf8 100644 --- a/gui/src/server/routes/jobs.py +++ b/gui/src/server/routes/jobs.py @@ -278,9 +278,9 @@ def run_compound_reconstruction(item_payload: dict | None) -> tuple[dict, int]: reconstructions_as_dicts = [rec.to_dict() for rec in reconstructions] return {"ok": True, "status": "done", "data": reconstructions_as_dicts}, 200 - except Exception: + except Exception as e: logger.exception("run_compound_reconstruction: failed") - return {"ok": False, "error": "Item not found during update"}, 404 + return {"ok": False, "error": f"Failed to reconstruct compound: {e}"}, 500 @blp_reconstruct_compound.post("/api/reconstructCompound") diff --git a/src/retromol_synthesis/reconstruction.py b/src/retromol_synthesis/reconstruction.py index 4e3e2cc..9f68748 100644 --- a/src/retromol_synthesis/reconstruction.py +++ b/src/retromol_synthesis/reconstruction.py @@ -1,16 +1,39 @@ """Module contains functionality for reconstructing a linear backbone from RetroMol's linear readout.""" -import re +import logging from dataclasses import dataclass from enum import Enum from typing import Any +from rdkit.Chem import Mol + from retromol.chem.mol import encode_mol, smiles_to_mol, smarts_to_mol, mol_to_smiles from retromol.chem.tagging import get_tags_mol from retromol.chem.reaction import smarts_to_reaction from retromol.model.readout import LinearReadout from retromol.model.result import Result +logger = logging.getLogger(__name__) + + +class BackboneReconstructionError(RuntimeError): + """Raised when the hardcoded fusion chemistry below cannot combine two motifs into a backbone.""" + + +BACKBONE_WARNING = ( + "The linear backbone could not be reconstructed with RetroMol's current " + "hardcoded fusion chemistry. The primary sequence below is still derived " + "directly from the source structure, so individual units can still be " + "highlighted there, but no reconstructed backbone structure is shown." +) + +UNORDERED_WARNING = ( + "RetroMol identified all of these building blocks, but couldn't connect them " + "into a single biosynthetic order (e.g. a branched or cyclic assembly), so no " + "primary sequence could be assembled. They're shown below as an unordered set. " + "Click one to highlight its atoms in the structure." +) + pattern_pk_single = smarts_to_mol(r"OSC-CC(=O)[OH]") pattern_pk_double = smarts_to_mol("OSC=CC(=O)[OH]") @@ -37,64 +60,67 @@ class MotifType(Enum): PK_SINGLE = "PK_SINGLE" PK_DOUBLE = "PK_DOUBLE" AA_ALPHA = "AA_ALPHA" - # AA_BETA = "AA_BETA" -def determine_type(mol) -> MotifType | None: - print("determining...", mol_to_smiles(mol)) +def determine_type(mol: Mol) -> MotifType | None: + """ + Classify a building-block mol into one of the fusion-chemistry motif types below. + + :param mol: The building-block mol to classify. + :return: The matched motif type, or None if it doesn't match any known pattern. + """ if mol.HasSubstructMatch(pattern_aa_alpha): - print("AA alpha") return MotifType.AA_ALPHA - # elif mol.HasSubstructMatch(pattern_aa_beta): - # print("AA beta") - # return MotifType.AA_BETA elif mol.HasSubstructMatch(pattern_pk_single): - print("PK") return MotifType.PK_SINGLE elif mol.HasSubstructMatch(pattern_pk_double): - print("PK double") return MotifType.PK_DOUBLE else: return None -def fuse(mol, ext_mol, prev_type, curr_type): +def react(rxn, reactants: tuple) -> Mol: + """ + Apply an RDKit reaction to a set of reactants and return the first product. + + :param rxn: The RDKit reaction to apply. + :param reactants: The reactant mols. + :return: The first product mol of the first matched reactant combination. + :raises BackboneReconstructionError: If the reaction produced no products. + """ + products = rxn.RunReactants(reactants) + if not products: + raise BackboneReconstructionError(f"Reaction {rxn} produced no products for given reactants.") + return products[0][0] + + +def fuse(mol: Mol, ext_mol: Mol, prev_type: MotifType | None, curr_type: MotifType | None) -> Mol: + """ + Fuse two motifs into a single mol, using the fusion chemistry appropriate for + the pair of motif types. + + :param mol: The mol built up so far. + :param ext_mol: The next building-block mol to fuse onto it. + :param prev_type: The motif type of the tail of `mol`. + :param curr_type: The motif type of `ext_mol`. + :return: The fused mol. + :raises BackboneReconstructionError: If there is no fusion rule for this pair of motif types. + """ match prev_type, curr_type: case MotifType.STARTER, MotifType.PK_SINGLE | MotifType.PK_DOUBLE: - print("FUSE STARTER-PK", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_starter_pk, (mol, ext_mol,)) + return react(rxn_fuse_starter_pk, (mol, ext_mol)) case MotifType.STARTER, MotifType.AA_ALPHA: - print("FUSE STARTER-AA alpha", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_starter_aa_alpha, (mol, ext_mol,)) + return react(rxn_fuse_starter_aa_alpha, (mol, ext_mol)) case MotifType.PK_SINGLE | MotifType.PK_DOUBLE, MotifType.PK_SINGLE | MotifType.PK_DOUBLE: - print("FUSE PK-PK", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_pk_pk, (mol, ext_mol,)) + return react(rxn_fuse_pk_pk, (mol, ext_mol)) case MotifType.AA_ALPHA, MotifType.PK_SINGLE | MotifType.PK_DOUBLE: - print("FUSE AA alpha-PK", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_aa_alpha_pk, (mol, ext_mol,)) + return react(rxn_fuse_aa_alpha_pk, (mol, ext_mol)) case MotifType.PK_SINGLE | MotifType.PK_DOUBLE, MotifType.AA_ALPHA: - print("FUSE PK-AA alpha", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_pk_aa_alpha, (mol, ext_mol,)) + return react(rxn_fuse_pk_aa_alpha, (mol, ext_mol)) case MotifType.AA_ALPHA, MotifType.AA_ALPHA: - print("FUSE AA alpha-AA alpha", prev_type, curr_type) - print(mol_to_smiles(mol), mol_to_smiles(ext_mol)) - return react(rxn_fuse_aa_alpha_aa_alpha, (mol, ext_mol,)) + return react(rxn_fuse_aa_alpha_aa_alpha, (mol, ext_mol)) case _: - print("DIFF") - print(prev_type, curr_type) - return mol - - -def react(rxn, reactants): - products = rxn.RunReactants(reactants) - print(products) - product = products[0][0] - return product + raise BackboneReconstructionError(f"No fusion rule for motif transition {prev_type} -> {curr_type}.") @dataclass(frozen=True) @@ -103,13 +129,23 @@ class Reconstruction: Reconstruct a linear backbone from RetroMol's linear readout. :param tagged_input_smiles: Input SMILES string with tagged atoms. - :param tagged_backbone_smiles: Reconstructed backbone SMILES string with tagged atoms, corresponding to tagged_input_smiles. - :param primary_sequence: Per-module name and tags for items present in backbone SMILES. + :param tagged_backbone_smiles: Reconstructed backbone SMILES string with tagged atoms, + corresponding to tagged_input_smiles, or None if the backbone could not be + reconstructed (see `backbone_warning` in that case). + :param primary_sequence: Per-module name and tags for items present in the primary sequence. + :param backbone_warning: Set when `tagged_backbone_smiles` is None, explaining why the + backbone is missing even though a primary sequence was found. + :param ordered: False when `primary_sequence` isn't a genuine biosynthetic order -- + RetroMol identified every item individually, but couldn't connect them into a + single path (e.g. a branched or cyclic assembly), so they're just the set of + building blocks found, in no particular order. """ tagged_input_smiles: str - tagged_backbone_smiles: str + tagged_backbone_smiles: str | None primary_sequence: list[tuple[str, set[int]]] + backbone_warning: str | None = None + ordered: bool = True def to_dict(self) -> dict[str, Any]: """ @@ -121,38 +157,97 @@ def to_dict(self) -> dict[str, Any]: "tagged_input_smiles": self.tagged_input_smiles, "tagged_backbone_smiles": self.tagged_backbone_smiles, "primary_sequence": [(x, list(y)) for x, y in self.primary_sequence], + "backbone_warning": self.backbone_warning, + "ordered": self.ordered, } +def _reconstruct_backbone(starter: str | None, building_blocks: list[str]) -> Mol: + """ + Build the linear backbone product mol for one ordered list of building blocks. + + :param starter: SMILES of the non-eligible starter unit, or None if every unit + in the path is itself an eligible building block. + :param building_blocks: Ordered building-block SMILES to fuse onto the starter. + :return: The fused backbone mol. + :raises BackboneReconstructionError: If any step of the fusion chemistry fails. + """ + prev_type: MotifType | None = None + prod: Mol | None = None + + if starter is not None: + prev_type = MotifType.STARTER + prod = react(rxn_starter, (smiles_to_mol(starter),)) + + for curr_smi in building_blocks: + curr_mol = smiles_to_mol(curr_smi) + curr_type = determine_type(curr_mol) + + if prod is None: + if curr_type == MotifType.PK_SINGLE: + prod = react(rxn_pk_start_single, (curr_mol,)) + elif curr_type == MotifType.PK_DOUBLE: + prod = react(rxn_pk_start_double, (curr_mol,)) + else: + prod = curr_mol + else: + if curr_type == MotifType.PK_SINGLE: + curr_mol = react(rxn_pk_single, (curr_mol,)) + prod = fuse(prod, curr_mol, prev_type, curr_type) + elif curr_type == MotifType.PK_DOUBLE: + curr_mol = react(rxn_pk_double, (curr_mol,)) + prod = fuse(prod, curr_mol, prev_type, curr_type) + elif curr_type == MotifType.AA_ALPHA: + prod = fuse(prod, curr_mol, prev_type, curr_type) + else: + prod = curr_mol + + prev_type = curr_type + + if prod is None: + raise BackboneReconstructionError("No backbone product could be built for this path.") + + return prod + + def reconstruct_linear_readout(result: Result) -> list[Reconstruction]: """ - Reconstruct a linear backbone from RetroMol's linear readout. + Reconstruct primary sequences (and, where possible, a linear backbone) from + RetroMol's linear readout. + + A candidate path can yield a primary sequence without a reconstructed backbone: + the backbone is rebuilt with a small hardcoded set of fusion reactions that + doesn't cover every motif combination, and any failure there is reported via + `Reconstruction.backbone_warning` rather than dropping the whole candidate. + + If no path could be threaded into a sequence at all (e.g. the molecule's + building blocks don't form a single linear chain), but building blocks were + still individually identified, a single unordered `Reconstruction` + (`ordered=False`) listing them is returned instead of an empty list -- see + `UNORDERED_WARNING`. :param result: The result object returned by RetroMol, containing the reaction graph and the original molecule. - :return: The reconstructed backbone. - :raises ValueError: If no successfull reconstructions are found. + :return: The reconstructed candidates, one per eligible path. Empty if no path + in the readout consists of (mostly) eligible primary-sequence building blocks. """ root_enc = encode_mol(result.submission.mol) readout = LinearReadout.from_reaction_graph(root_enc, reaction_graph=result.reaction_graph, identified_only=True) + tagged_input_smiles = mol_to_smiles(result.submission.mol, include_tags=True) reconstructions: list[Reconstruction] = [] - # Get building blocks - paths = readout.paths - paths.sort(key=lambda x: len(x), reverse=True) - for path in readout.paths: - - building_blocks = [] + paths = sorted(readout.paths, key=lambda x: len(x), reverse=True) + for path in paths: + building_blocks: list[str] = [] primary_sequence: list[tuple[str, set[int]]] = [] - eligible = [False for _ in range(len(path))] + for item_idx, item in enumerate(path): item_name: str = item.identity.matched_rule.name if item.identified else "X" item_tags: set[int] = get_tags_mol(item.mol) item_smiles = mol_to_smiles(item.mol, include_tags=True) - # For every item in path check if it is an eligible primary sequence building block - if any([item.mol.HasSubstructMatch(pattern) for pattern in eligible_patterns]): + if any(item.mol.HasSubstructMatch(pattern) for pattern in eligible_patterns): eligible[item_idx] = True primary_sequence.append((item_name, item_tags)) @@ -166,10 +261,9 @@ def reconstruct_linear_readout(result: Result) -> list[Reconstruction]: ): continue - print(eligible) # Re-orient path to have non-eligible item at start of sequence if all(eligible): - # Nothing to orient, all aligible building blocks + # Nothing to orient, all eligible building blocks starter = None elif all(eligible[1:]): # Already good orientation, but remove the starter @@ -177,65 +271,58 @@ def reconstruct_linear_readout(result: Result) -> list[Reconstruction]: building_blocks = building_blocks[1:] else: # Flip orientation - building_blocks.reverse() - primary_sequence.reverse() + building_blocks = list(reversed(building_blocks)) + primary_sequence = list(reversed(primary_sequence)) # Remove starter starter = building_blocks[0] building_blocks = building_blocks[1:] - # Reconstruct linear backbone, we can have either PK-PK, PK-AA, AA-AA or AA-AA - if not len(building_blocks): - continue - - prev_type = None - prod = None - - if starter is not None: - prev_type = MotifType.STARTER - prod = react(rxn_starter, (smiles_to_mol(starter),)) - - while building_blocks: - curr_smi = building_blocks.pop(0) - curr_mol = smiles_to_mol(curr_smi) - curr_type = determine_type(curr_mol) - if prod is None: - if curr_type == MotifType.PK_SINGLE: - prod = react(rxn_pk_start_single, (curr_mol,)) - elif curr_type == MotifType.PK_DOUBLE: - prod = react(rxn_pk_start_double, (curr_mol,)) - else: - prod = curr_mol - else: - if curr_type == MotifType.PK_SINGLE: - curr_mol = react(rxn_pk_single, (curr_mol,)) - prod = fuse(prod, curr_mol, prev_type, curr_type) - elif curr_type == MotifType.PK_DOUBLE: - curr_mol = react(rxn_pk_double, (curr_mol,)) - prod = fuse(prod, curr_mol, prev_type, curr_type) - elif curr_type == MotifType.AA_ALPHA: - prod = fuse(prod, curr_mol, prev_type, curr_type) - else: - prod = curr_mol - prev_type = curr_type - - if prod is None: + if not building_blocks: continue - # If product contains Sn atom, replace it with wildcard - print(mol_to_smiles(prod)) - - product_smi = mol_to_smiles(prod, include_tags=True) - input_smi = mol_to_smiles(result.submission.mol, include_tags=True) - - reconstruction = Reconstruction( - tagged_input_smiles=input_smi, - tagged_backbone_smiles=product_smi, - primary_sequence=primary_sequence, + tagged_backbone_smiles: str | None = None + backbone_warning: str | None = None + try: + backbone_mol = _reconstruct_backbone(starter, building_blocks) + tagged_backbone_smiles = mol_to_smiles(backbone_mol, include_tags=True) + except Exception: + logger.warning( + "reconstruct_linear_readout: backbone reconstruction failed for a candidate path", + exc_info=True, + ) + backbone_warning = BACKBONE_WARNING + + reconstructions.append( + Reconstruction( + tagged_input_smiles=tagged_input_smiles, + tagged_backbone_smiles=tagged_backbone_smiles, + primary_sequence=primary_sequence, + backbone_warning=backbone_warning, + ) ) - reconstructions.append(reconstruction) - if len(reconstructions) == 0: - raise ValueError("No successfull reconstructions!") + # No path threaded its identified building blocks into a usable primary + # sequence (e.g. a branched or cyclic assembly RetroMol's path-finding + # doesn't linearize), but individual building blocks may still have been + # identified across the molecule. Surface those as an explicitly unordered + # set rather than reporting nothing, so a fully (or mostly) covered compound + # doesn't look unparsed just because no single order could be determined. + if not reconstructions: + monomer_nodes = readout.assembly_graph.monomer_nodes() + if monomer_nodes: + unordered_sequence = [ + (node.identity.matched_rule.name if node.identified else "X", get_tags_mol(node.mol)) + for node in monomer_nodes + ] + reconstructions.append( + Reconstruction( + tagged_input_smiles=tagged_input_smiles, + tagged_backbone_smiles=None, + primary_sequence=unordered_sequence, + backbone_warning=UNORDERED_WARNING, + ordered=False, + ) + ) return reconstructions From 505fbe11ae39dd13ee7ad79442264e612156b9d7 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sat, 8 Aug 2026 03:18:00 +0200 Subject: [PATCH 12/26] ENH: download compound view as png --- gui/src/client/package-lock.json | 31 +++++------- gui/src/client/package.json | 3 +- .../src/components/ExportImageButton.tsx | 48 +++++++++++++++++++ gui/src/client/src/components/exportImage.ts | 44 +++++++++++++++++ .../components/workspace/DialogViewItem.tsx | 10 ++++ 5 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 gui/src/client/src/components/ExportImageButton.tsx create mode 100644 gui/src/client/src/components/exportImage.ts diff --git a/gui/src/client/package-lock.json b/gui/src/client/package-lock.json index 3129ae6..3f28225 100644 --- a/gui/src/client/package-lock.json +++ b/gui/src/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "retromol-gui", - "version": "0.1.0", + "version": "1.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "retromol-gui", - "version": "0.1.0", + "version": "1.0.0-dev", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -24,6 +24,7 @@ "dayjs": "^1.11.19", "dompurify": "^3.2.4", "framer-motion": "^12.6.3", + "html-to-image": "^1.11.13", "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "^7.9.5", @@ -10637,6 +10638,12 @@ "node": ">=12" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-webpack-plugin": { "version": "5.6.8", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", @@ -18159,24 +18166,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -18686,6 +18675,7 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -19233,6 +19223,7 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", diff --git a/gui/src/client/package.json b/gui/src/client/package.json index df2c4dc..6a933ee 100644 --- a/gui/src/client/package.json +++ b/gui/src/client/package.json @@ -19,6 +19,7 @@ "dayjs": "^1.11.19", "dompurify": "^3.2.4", "framer-motion": "^12.6.3", + "html-to-image": "^1.11.13", "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "^7.9.5", @@ -63,4 +64,4 @@ ] }, "proxy": "http://localhost:4000" -} \ No newline at end of file +} diff --git a/gui/src/client/src/components/ExportImageButton.tsx b/gui/src/client/src/components/ExportImageButton.tsx new file mode 100644 index 0000000..5d95da5 --- /dev/null +++ b/gui/src/client/src/components/ExportImageButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useNotifications } from "./NotificationProvider"; +import { exportElementAsPng } from "./exportImage"; + +// Small "export this view as a PNG" affordance. Captures whatever is currently +// rendered inside `targetRef`, including any live highlight state, exactly as +// shown on screen. +export function ExportImageButton({ + targetRef, + filename, + label = "Download this view as a PNG", +}: { + targetRef: React.RefObject; + filename: string; + label?: string; +}) { + const { pushNotification } = useNotifications(); + const [exporting, setExporting] = React.useState(false); + + const handleExport = async () => { + const node = targetRef.current; + if (!node) return; + + setExporting(true); + try { + await exportElementAsPng(node, filename); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to export image: ${msg}`, "error"); + } finally { + setExporting(false); + } + }; + + return ( + + + + {exporting ? : } + + + + ); +} diff --git a/gui/src/client/src/components/exportImage.ts b/gui/src/client/src/components/exportImage.ts new file mode 100644 index 0000000..a355bbf --- /dev/null +++ b/gui/src/client/src/components/exportImage.ts @@ -0,0 +1,44 @@ +import { toBlob } from "html-to-image"; + +// Walks up from the captured node to find the first non-transparent background +// -- the node itself is usually a plain, unstyled Box, so without this the +// export would default to a hardcoded color instead of matching the dialog's +// actual (light/dark) paper background. +function resolveBackgroundColor(node: HTMLElement): string { + let el: HTMLElement | null = node; + while (el) { + const bg = getComputedStyle(el).backgroundColor; + if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg; + el = el.parentElement; + } + return "#ffffff"; +} + +// Renders `node` (and whatever the user currently has highlighted in it) to a +// downloadable PNG. +export async function exportElementAsPng(node: HTMLElement, filename: string): Promise { + const blob = await toBlob(node, { + backgroundColor: resolveBackgroundColor(node), + cacheBust: true, + pixelRatio: 2, + // Fonts are loaded from Google Fonts (see public/index.html); the live DOM + // already renders with them, so embedding is only needed for fidelity in a + // *different* environment later. Skipped to avoid a network fetch (and + // possible failure) on every export. + skipFonts: true, + // `node` scrolls horizontally when its content (e.g. a wide backbone + // structure) overflows the dialog -- capture the full content, not just + // whatever's currently scrolled into view. + width: node.scrollWidth, + height: node.scrollHeight, + style: { overflow: "visible" }, + }); + if (!blob) throw new Error("Could not render this view to an image."); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}.png`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/gui/src/client/src/components/workspace/DialogViewItem.tsx b/gui/src/client/src/components/workspace/DialogViewItem.tsx index 716f21d..29bc9c5 100644 --- a/gui/src/client/src/components/workspace/DialogViewItem.tsx +++ b/gui/src/client/src/components/workspace/DialogViewItem.tsx @@ -12,6 +12,7 @@ import { reconstructCompound } from "../../features/reconstruction/api"; import { getClusterReadout } from "../../features/clusters/api"; import { DialogWindow } from "../DialogWindow"; import { ErrorBoundary } from "../ErrorBoundary"; +import { ExportImageButton } from "../ExportImageButton"; import SmilesDrawerContainer from "../SmilesDrawerContainer.js"; import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor"; import { ClusterReadoutRows } from "./ClusterReadoutRows"; @@ -90,6 +91,7 @@ export const DialogViewItem: React.FC = ({ const itemScore = typeof item.score === "number" ? item.score : 0; const [selectedTags, setSelectedTags] = React.useState([]); + const diagramRef = React.useRef(null); const handleToggleMotif = (tags: number[]) => { setSelectedTags((prev) => { @@ -266,7 +268,15 @@ export const DialogViewItem: React.FC = ({ )} + + + Date: Sat, 8 Aug 2026 03:32:40 +0200 Subject: [PATCH 13/26] ENH: server up time on homepage --- gui/src/client/src/components/Hero.tsx | 2 + .../client/src/components/ServerUptime.tsx | 42 +++++++++++++++++++ gui/src/client/src/features/server/api.ts | 6 +++ gui/src/client/src/features/server/types.ts | 8 ++++ gui/src/client/src/features/server/utils.ts | 10 +++++ gui/src/client/src/pages/NotFound.tsx | 41 ++++-------------- 6 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 gui/src/client/src/components/ServerUptime.tsx create mode 100644 gui/src/client/src/features/server/api.ts create mode 100644 gui/src/client/src/features/server/types.ts create mode 100644 gui/src/client/src/features/server/utils.ts diff --git a/gui/src/client/src/components/Hero.tsx b/gui/src/client/src/components/Hero.tsx index 5295b77..965ec5b 100644 --- a/gui/src/client/src/components/Hero.tsx +++ b/gui/src/client/src/components/Hero.tsx @@ -2,6 +2,7 @@ import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; +import { ServerUptime } from "./ServerUptime"; export default function Hero() { return ( @@ -74,6 +75,7 @@ export default function Hero() { Perform cross-modal retrieval between natural product compounds and BGCs. + diff --git a/gui/src/client/src/components/ServerUptime.tsx b/gui/src/client/src/components/ServerUptime.tsx new file mode 100644 index 0000000..e5eece9 --- /dev/null +++ b/gui/src/client/src/components/ServerUptime.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { useQuery } from "@tanstack/react-query"; +import { getServerStartup } from "../features/server/api"; +import { formatUptime } from "../features/server/utils"; + +// The startup epoch this reads (see /api/startup) is stored in Redis, the +// same store sessions live in -- it only resets when Redis itself was +// restarted/flushed, not on every backend process restart. So a short uptime +// here is a real signal that older sessions may be gone, not just noise. +export function ServerUptime() { + const { data, isLoading, isError } = useQuery({ + queryKey: ["serverStartup"], + queryFn: ({ signal }) => getServerStartup(signal), + refetchInterval: 60_000, + retry: false, + }); + + if (isLoading || isError || !data) return null; + + return ( + + + + + Server up for {formatUptime(data.uptime)} + + + + ); +} diff --git a/gui/src/client/src/features/server/api.ts b/gui/src/client/src/features/server/api.ts new file mode 100644 index 0000000..3ba5a66 --- /dev/null +++ b/gui/src/client/src/features/server/api.ts @@ -0,0 +1,6 @@ +import { getJson } from "../http"; +import { ServerStartup, ServerStartupRespSchema } from "./types"; + +export async function getServerStartup(signal?: AbortSignal): Promise { + return getJson("/api/startup", ServerStartupRespSchema, signal); +} diff --git a/gui/src/client/src/features/server/types.ts b/gui/src/client/src/features/server/types.ts new file mode 100644 index 0000000..ec980bb --- /dev/null +++ b/gui/src/client/src/features/server/types.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ServerStartupRespSchema = z.object({ + startup: z.number(), + current: z.number(), + uptime: z.number(), +}); +export type ServerStartup = z.output; diff --git a/gui/src/client/src/features/server/utils.ts b/gui/src/client/src/features/server/utils.ts new file mode 100644 index 0000000..2769820 --- /dev/null +++ b/gui/src/client/src/features/server/utils.ts @@ -0,0 +1,10 @@ +const pad = (num: number): string => num.toString().padStart(2, "0"); + +// Formats a duration in seconds as "N day(s) and HH:MM:SS hours". +export function formatUptime(uptimeSeconds: number): string { + const days = Math.floor(uptimeSeconds / (24 * 3600)); + const hours = Math.floor((uptimeSeconds % (24 * 3600)) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const seconds = Math.floor(uptimeSeconds % 60); + return `${days} day${days !== 1 ? "s" : ""} and ${pad(hours)}:${pad(minutes)}:${pad(seconds)} hours`; +} diff --git a/gui/src/client/src/pages/NotFound.tsx b/gui/src/client/src/pages/NotFound.tsx index 9d4e83a..88ef162 100644 --- a/gui/src/client/src/pages/NotFound.tsx +++ b/gui/src/client/src/pages/NotFound.tsx @@ -1,44 +1,21 @@ -import React from "react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; import CssBaseline from "@mui/material/CssBaseline"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; +import { useQuery } from "@tanstack/react-query"; import AppTheme from "../theme/AppTheme"; import Footer from "../components/Footer"; - -// Helper function to pad numbers with a leading zero if needed -const pad = (num: number): string => num.toString().padStart(2, "0") - -// Helper function to format seconds into days:hours:minutes:seconds -const formatUptime = (uptime: number): string => { - const days = Math.floor(uptime / (24 * 3600)); - const hours = Math.floor((uptime % (24 * 3600)) / 3600); - const minutes = Math.floor((uptime % 3600) / 60); - const seconds = uptime % 60; - return `${days} day${days !== 1 ? "s" : ""} and ${pad(hours)}:${pad(minutes)}:${pad(seconds)} hours`; -} +import { getServerStartup } from "../features/server/api"; +import { formatUptime } from "../features/server/utils"; const Hero = ({ title, subtitle }: { title?: string; subtitle?: string }) => { - const [uptime, setUptime] = React.useState(0); - - // Fetch uptime from api/startup endpoint - React.useEffect(() => { - const fetchUptime = async () => { - try { - const response = await fetch("/api/startup"); - if (!response.ok) { - throw new Error("network response was not ok"); - } - const data = await response.json(); - setUptime(data.uptime); - } catch (err) { - console.error("error fetching uptime:", err); - } - }; - fetchUptime(); - }, []) + const { data } = useQuery({ + queryKey: ["serverStartup"], + queryFn: ({ signal }) => getServerStartup(signal), + retry: false, + }); return ( @@ -84,7 +61,7 @@ const Hero = ({ title, subtitle }: { title?: string; subtitle?: string }) => { width: { sm: "100%", md: "80%" }, }} > - {`Server uptime: ${formatUptime(uptime)}`} + {`Server uptime: ${formatUptime(data?.uptime ?? 0)}`} ); } From 8b278350bc99a63ef5dcae900a7b7cb1192bd2b7 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 00:19:24 +0200 Subject: [PATCH 17/26] WIP: styling discovery tab --- .../components/workspace/SequenceEditor.tsx | 19 +++++ .../workspace/WorkspaceDiscovery.tsx | 69 ++++++++++++++----- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/gui/src/client/src/components/workspace/SequenceEditor.tsx b/gui/src/client/src/components/workspace/SequenceEditor.tsx index 580289c..4509a98 100644 --- a/gui/src/client/src/components/workspace/SequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/SequenceEditor.tsx @@ -222,6 +222,25 @@ function AddBlockControl({ disabled, onAdd }: { disabled?: boolean; onAdd: (name }} value={selected} onChange={(_, value) => setSelected(value)} + slotProps={{ + popupIndicator: { + sx: { + p: 0, + m: 0, + minWidth: 0, + minHeight: 0, + width: "auto", + height: "auto", + border: "none", + borderRadius: 0, + background: "transparent", + boxShadow: "none", + "&:hover": { + background: "transparent", + }, + }, + }, + }} renderInput={(params) => ( )} diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index d2132e9..d77a30c 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -61,24 +61,49 @@ function ReconstructionPreview({ sequence }: { sequence: PrimarySequenceItem[] } // just names -- see retromol_antismash.modules.bgc_primary_sequence. function NamesPreview({ names }: { names: string[] }) { return ( - - {names.map((name, idx) => ( - - - - ))} + + + {names.map((name, idx) => ( + + + + ))} + ); } @@ -375,14 +400,20 @@ export const WorkspaceDiscovery: React.FC = ({ session, flexWrap: "wrap", }} > - + + {`primary sequence ${idx+1}`} + {override && ( )} + + + From 2a3b257dcd29cfdfd33315e23fc28662e21a2be6 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 01:57:59 +0200 Subject: [PATCH 18/26] STY: update user experience query settings --- .../workspace/DialogViewDiscoveryQuery.tsx | 2 +- .../workspace/WorkspaceDiscovery.tsx | 254 +++++++++++------- .../src/theme/customizations/inputs.tsx | 38 ++- .../src/theme/customizations/navigation.tsx | 2 +- 4 files changed, 189 insertions(+), 107 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx index 90b6ccd..647accc 100644 --- a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx +++ b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx @@ -43,7 +43,7 @@ type DialogViewDiscoveryQueryProps = { }; function disabledReason(flagLabel: string): string { - return `Not computed for this query -- submit a new query with "${flagLabel}" enabled`; + return `Not computed for this query. Submit a new query with "${flagLabel}" enabled.`; } export const DialogViewDiscoveryQuery: React.FC = ({ item, sessionId, open, onClose }) => { diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index d77a30c..a69a440 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -9,6 +9,8 @@ import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; +import Collapse from "@mui/material/Collapse"; +import Divider from "@mui/material/Divider"; import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import ListSubheader from "@mui/material/ListSubheader"; @@ -18,6 +20,7 @@ import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; import DeleteIcon from "@mui/icons-material/Delete"; +import SettingsIcon from "@mui/icons-material/Settings"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import ViewIcon from "@mui/icons-material/Visibility"; import { useQuery } from "@tanstack/react-query"; @@ -38,6 +41,7 @@ import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { SequenceEditor, type SequenceBlock } from "./SequenceEditor"; import { DialogViewDiscoveryQuery } from "./DialogViewDiscoveryQuery"; +import {MinimalIconButton} from "../MinimalIconButton"; type WorkspaceDiscoveryProps = { session: Session; @@ -214,6 +218,7 @@ export const WorkspaceDiscovery: React.FC = ({ session, const [computeMsa, setComputeMsa] = React.useState(true); const [computeCompare, setComputeCompare] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); + const [queryOptionsOpen, setQueryOptionsOpen] = React.useState(false); const [deletingIds, setDeletingIds] = React.useState>(new Set()); const [viewingItemId, setViewingItemId] = React.useState(null); @@ -455,8 +460,15 @@ export const WorkspaceDiscovery: React.FC = ({ session, {region.id} - - @@ -492,7 +504,7 @@ export const WorkspaceDiscovery: React.FC = ({ session, Query - + = ({ session, Both - setScoreMode(e.target.value as DiscoveryScoreMode)} - disabled={submitting} - sx={{ width: 220 }} - > - {DISCOVERY_SCORE_MODE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - { - const value = Math.max(1, Math.min(1000, Number(e.target.value) || 1)); - setN(value); - setTopX((prev) => Math.min(prev, Math.max(1, Math.min(value, MAX_TOP_X)))); - }} - slotProps={{ htmlInput: { min: 1, max: 1000 } }} - sx={{ width: 160 }} - disabled={submitting} - /> - - setTopX(Math.max(1, Math.min(maxTopX, Number(e.target.value) || 1)))} - slotProps={{ htmlInput: { min: 1, max: maxTopX } }} - sx={{ width: 140 }} - disabled={submitting} - /> + + + setQueryOptionsOpen((prev) => !prev)} + disabled={submitting} + aria-expanded={queryOptionsOpen} + aria-label="Toggle query options" + > + + + + + - + + + setIncludeUserUploads(e.target.checked)} - disabled={submitting || compoundItems.length === 0 || onlyUserUploads} - /> - } - label="Include my uploads" - title="Uploaded compounds and gene clusters compete for a spot among the nearest N candidates, then follow the usual top-X ranking." - /> - - setScoreMode(e.target.value as DiscoveryScoreMode)} + disabled={submitting} + helperText={ + scoreMode === "subsequence" + ? "Scores by the query's own length, surfacing subsequence matches." + : "Normalizes by the longer sequence, favoring similarly-sized matches." + } + sx={{ width: 240 }} + > + {DISCOVERY_SCORE_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + setOnlyUserUploads(e.target.checked)} - disabled={submitting || compoundItems.length === 0} + type="number" + label="Retrieve closest N" + value={n} + onChange={(e) => { + const value = Math.max(1, Math.min(1000, Number(e.target.value) || 1)); + setN(value); + setTopX((prev) => Math.min(prev, Math.max(1, Math.min(value, MAX_TOP_X)))); + }} + slotProps={{ htmlInput: { min: 1, max: 1000 } }} + helperText="Nearest neighbors pulled from the database before ranking." + sx={{ width: 190 }} + disabled={submitting} /> - } - label="Only use my uploads" - title="Skip the shared database entirely and align only against your own uploaded compounds and gene clusters." - /> - - - Precompute for this query - - - Pick which extra views to compute up front so they open instantly once the query is done. A view you - didn't flag here is disabled in the results, not silently missing -- run another query with it enabled - if you need it later. - - - - setComputeMsa(e.target.checked)} disabled={submitting} /> - } - label="Compute MSA" - /> - setComputeCompare(e.target.checked)} + type="number" + label="Show top X" + value={topX} + onChange={(e) => setTopX(Math.max(1, Math.min(maxTopX, Number(e.target.value) || 1)))} + slotProps={{ htmlInput: { min: 1, max: maxTopX } }} + helperText="How many top-ranked results to display." + sx={{ width: 170 }} disabled={submitting} /> - } - label="Compute compound comparison" - /> - + + + + + setIncludeUserUploads(e.target.checked)} + disabled={submitting || compoundItems.length === 0 || onlyUserUploads} + /> + } + label="Include my uploads" + /> + + + + setOnlyUserUploads(e.target.checked)} + disabled={submitting || compoundItems.length === 0} + /> + } + label="Only use my uploads" + /> + + + + + + + + Compute for this query + + + Pick which extra views to compute up front so they open instantly once the query is done. A view + you didn't flag here is disabled in the results, not silently missing. Run another query with it + enabled if you need it later. + + + + setComputeMsa(e.target.checked)} + disabled={submitting} + /> + } + label="Compute MSA" + /> + setComputeCompare(e.target.checked)} + disabled={submitting} + /> + } + label="Compute compound comparison" + /> + + + + - + Queries stay here (and survive switching tabs or reloading) until you delete them, up to{" "} {MAX_DISCOVERY_QUERY_ITEMS} at a time. "Load result file" opens a previously downloaded result for - viewing only -- it doesn't count against this limit. + viewing only. It doesn't count against this limit. {queryItems.length === 0 ? ( diff --git a/gui/src/client/src/theme/customizations/inputs.tsx b/gui/src/client/src/theme/customizations/inputs.tsx index f8a945d..92945d8 100644 --- a/gui/src/client/src/theme/customizations/inputs.tsx +++ b/gui/src/client/src/theme/customizations/inputs.tsx @@ -1,6 +1,6 @@ import React from "react"; import { alpha, Theme, Components } from "@mui/material/styles"; -import { outlinedInputClasses, svgIconClasses, toggleButtonGroupClasses, toggleButtonClasses } from "@mui/material"; +import { outlinedInputClasses, selectClasses, svgIconClasses, toggleButtonGroupClasses, toggleButtonClasses } from "@mui/material"; import { CheckBoxOutlineBlankRounded as CheckBoxOutlineBlankRoundedIcon, CheckRounded as CheckRoundedIcon, @@ -381,23 +381,27 @@ export const inputsCustomizations: Components = { styleOverrides: { input: { padding: 0, + // Select reuses this same input slot for its display box, but needs its + // native padding back so the value text doesn't run under the dropdown arrow. + [`&.${selectClasses.select}`]: { + paddingRight: 24, + }, }, root: ({ theme }) => ({ padding: "8px 12px", color: (theme.vars || theme).palette.text.primary, borderRadius: (theme.vars || theme).shape.borderRadius, - border: `1px solid ${(theme.vars || theme).palette.divider}`, backgroundColor: (theme.vars || theme).palette.background.default, - transition: "border 120ms ease-in", - "&:hover": { + transition: "color 120ms ease-in", + [`&:hover .${outlinedInputClasses.notchedOutline}`]: { borderColor: gray[400], }, - [`&.${outlinedInputClasses.focused}`]: { - outline: `3px solid ${alpha(brand[500], 0.5)}`, + [`&.${outlinedInputClasses.focused} .${outlinedInputClasses.notchedOutline}`]: { borderColor: brand[400], + borderWidth: "2px", }, ...theme.applyStyles("dark", { - "&:hover": { + [`&:hover .${outlinedInputClasses.notchedOutline}`]: { borderColor: gray[500], }, }), @@ -420,9 +424,10 @@ export const inputsCustomizations: Components = { }, ], }), - notchedOutline: { - border: "none", - }, + notchedOutline: ({ theme }) => ({ + borderColor: (theme.vars || theme).palette.divider, + transition: "border-color 120ms ease-in", + }), }, }, MuiInputAdornment: { @@ -443,4 +448,17 @@ export const inputsCustomizations: Components = { }), }, }, + MuiInputLabel: { + styleOverrides: { + root: { + // The OutlinedInput root above uses an explicit 8px vertical padding + // (instead of MUI's default), so the un-shrunk label -- which sits on + // top of the input's content rather than the notch -- needs the same + // offset to stay vertically centered in the box. + "&.MuiInputLabel-outlined.MuiInputLabel-sizeSmall:not(.MuiFormLabel-filled):not(.Mui-focused)": { + transform: "translate(14px, 8px) scale(1)", + }, + }, + }, + }, } diff --git a/gui/src/client/src/theme/customizations/navigation.tsx b/gui/src/client/src/theme/customizations/navigation.tsx index 8db5ae2..a846961 100644 --- a/gui/src/client/src/theme/customizations/navigation.tsx +++ b/gui/src/client/src/theme/customizations/navigation.tsx @@ -15,7 +15,7 @@ export const navigationCustomizations: Components = { MuiMenuItem: { styleOverrides: { root: ({ theme }) => ({ - borderRadius: (theme.vars || theme).shape.borderRadius, + borderRadius: 0, padding: "6px 8px", [`&.${menuItemClasses.focusVisible}`]: { backgroundColor: "transparent", From e4555db89496358fa0d231ef7a3aaa7e93eb3d84 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 02:56:02 +0200 Subject: [PATCH 19/26] UPD: query results list user experience --- .../workspace/WorkspaceDiscovery.tsx | 329 +++++++++++++++--- 1 file changed, 283 insertions(+), 46 deletions(-) diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index a69a440..f9f2706 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -19,13 +19,17 @@ import TextField from "@mui/material/TextField"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; import SettingsIcon from "@mui/icons-material/Settings"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import ViewIcon from "@mui/icons-material/Visibility"; +import { alpha } from "@mui/material/styles"; import { useQuery } from "@tanstack/react-query"; import { Session, SessionItem, DiscoveryQueryItem, DiscoveryQueryItemSchema } from "../../features/session/types"; -import { deleteSessionItem } from "../../features/session/api"; +import { deleteSessionItem, renameSessionItem } from "../../features/session/api"; import { reconstructCompound } from "../../features/reconstruction/api"; import type { PrimarySequenceItem } from "../../features/reconstruction/types"; import { reconstructGeneCluster } from "../../features/clusters/api"; @@ -55,6 +59,13 @@ function blocksFromNames(names: string[]): SequenceBlock[] { return names.map((name) => ({ id: crypto.randomUUID(), name })); } +// Default query name -- a block-name preview reads as noise once there are a few +// queries in the list, so name by submission time instead ("Query 2026-08-09 14:32:05"). +function formatQueryTimestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + // Read-only preview of a candidate reconstruction path, for picking which one seeds the editor. function ReconstructionPreview({ sequence }: { sequence: PrimarySequenceItem[] }) { return name)} />; @@ -112,20 +123,39 @@ function NamesPreview({ names }: { names: string[] }) { ); } -// One row in the "Saved queries" list -- mirrors WorkspaceItemCard's status-chip -// language (queued/processing/done/error) so a query reads the same way an uploaded -// compound/cluster does, just without the score gauge or expand/collapse panel. +// One row in the "Query results" list -- mirrors WorkspaceItemCard's look (selection +// checkbox, tinted background, inline rename) and status-chip language +// (queued/processing/done/error), just without the score gauge or expand/collapse panel. function DiscoveryQueryListItem({ + session, + setSession, item, + selected, deleting, + onToggleSelect, onView, onDelete, }: { + session: Session; + setSession: React.Dispatch>; item: DiscoveryQueryItem; + selected: boolean; deleting: boolean; + onToggleSelect: (id: string) => void; onView: () => void; onDelete: () => void; }) { + const { pushNotification } = useNotifications(); + + const [editingName, setEditingName] = React.useState(false); + const [nameDraft, setNameDraft] = React.useState(item.name); + const [savingName, setSavingName] = React.useState(false); + + // Keep the draft in sync with the persisted name as long as we're not mid-edit. + React.useEffect(() => { + if (!editingName) setNameDraft(item.name); + }, [item.name, editingName]); + const isQueued = item.status === "queued"; const isProcessing = item.status === "processing"; const isDone = item.status === "done"; @@ -136,27 +166,147 @@ function DiscoveryQueryListItem({ item.flags.computeCompare ? "Compare" : null, ].filter((label): label is string => label !== null); + const handleToggle = (e?: React.SyntheticEvent) => { + if (e) e.stopPropagation(); + if (deleting) return; + onToggleSelect(item.id); + }; + + const handleStartEditName = (e: React.SyntheticEvent) => { + e.stopPropagation(); + if (deleting) return; + setNameDraft(item.name); + setEditingName(true); + }; + + const handleCancelEditName = () => { + setNameDraft(item.name); + setEditingName(false); + }; + + const handleSaveName = async () => { + const trimmed = nameDraft.trim(); + if (!trimmed) { + pushNotification("Name cannot be empty.", "error"); + return; + } + if (trimmed === item.name) { + setEditingName(false); + return; + } + + setSavingName(true); + try { + const nextSession = await renameSessionItem(session, item.id, trimmed); + setSession(() => nextSession); + setEditingName(false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to rename query: ${msg}`, "error"); + } finally { + setSavingName(false); + } + }; + return ( - { const t = theme.vars || theme; return { borderRadius: 1, - border: `1px solid ${t.palette.divider}`, + border: `1px solid ${selected ? t.palette.primary.main : "transparent"}`, p: 1.5, - display: "flex", - alignItems: "center", gap: 1.5, + // Matches WorkspaceItemCard's rendered height (driven there by its 70px score + // gauge), so the two lists read as one consistent row height across tabs. + minHeight: 96, cursor: "pointer", - "&:hover": { boxShadow: 4 }, + "&:hover": { boxShadow: 10 }, + backgroundColor: selected ? alpha("#000000", 0.04) : alpha("#000000", 0.02), + ...theme.applyStyles("dark", { backgroundColor: selected ? alpha("#ffffff", 0.06) : alpha("#ffffff", 0.03) }), }; }} > + { + e.stopPropagation(); + onToggleSelect(item.id); + }} + /> + - - {item.name} - + {editingName ? ( + e.stopPropagation()} + > + setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveName(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelEditName(); + } + }} + size="small" + variant="standard" + autoFocus + disabled={savingName} + sx={{ minWidth: 0, width: "auto" }} + /> + + + + {savingName ? : } + + + + + + + + + + + + ) : ( + + + {item.name} + + + + + + + + + + )} + {flagChips.map((label) => ( @@ -165,6 +315,12 @@ function DiscoveryQueryListItem({ + {deleting && ( + <> + + + + )} {isQueued && } {isProcessing && } {isDone && } @@ -174,27 +330,35 @@ function DiscoveryQueryListItem({ )} - { - e.stopPropagation(); - onView(); - }} - > - - - { - e.stopPropagation(); - onDelete(); - }} - > - {deleting ? : } - - + + + { + e.stopPropagation(); + onView(); + }} + > + + + + + + + { + e.stopPropagation(); + onDelete(); + }} + > + {deleting ? : } + + + + ); } @@ -221,19 +385,22 @@ export const WorkspaceDiscovery: React.FC = ({ session, const [queryOptionsOpen, setQueryOptionsOpen] = React.useState(false); const [deletingIds, setDeletingIds] = React.useState>(new Set()); + const [selectedQueryIds, setSelectedQueryIds] = React.useState>(new Set()); const [viewingItemId, setViewingItemId] = React.useState(null); const [uploadedViewItem, setUploadedViewItem] = React.useState(null); - // Clean up deletingIds when session items change (mirrors WorkspaceUpload). + // Clean up deletingIds/selectedQueryIds when session items change (mirrors WorkspaceUpload). React.useEffect(() => { - setDeletingIds((prev) => { - const liveIds = new Set(session.items.map((it) => it.id)); + const liveIds = new Set(session.items.map((it) => it.id)); + const keepLive = (prev: Set) => { const next = new Set(); prev.forEach((id) => { if (liveIds.has(id)) next.add(id); }); return next; - }); + }; + setDeletingIds(keepLive); + setSelectedQueryIds(keepLive); }, [session.items]); const reconstructionQuery = useQuery({ @@ -256,14 +423,16 @@ export const WorkspaceDiscovery: React.FC = ({ session, const atQueryCap = queryItems.length >= MAX_DISCOVERY_QUERY_ITEMS; const canQuery = blocks.length > 0 && !submitting && !atQueryCap; + const anyQuerySelected = selectedQueryIds.size > 0; + const allQueriesSelected = queryItems.length > 0 && selectedQueryIds.size === queryItems.length; + const queryOriginSmiles = selectedItem?.kind === "compound" ? selectedItem.smiles : null; const handleSubmitQuery = async () => { if (!canQuery) return; setSubmitting(true); - const preview = blocks.slice(0, 4).map((b) => b.name).join(" "); - const name = blocks.length > 4 ? `${preview} …` : preview || "Discovery query"; + const name = `Query ${formatQueryTimestamp(new Date())}`; try { const item = await submitDiscoveryQuery({ @@ -305,6 +474,48 @@ export const WorkspaceDiscovery: React.FC = ({ session, } }; + // Query-selection helpers -- mirrors WorkspaceUpload's select-all/clear/delete-selected trio. + const toggleSelectQueryItem = (id: string) => { + if (deletingIds.has(id)) return; + setSelectedQueryIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleSelectAllQueries = () => { + if (!queryItems.length) return; + setSelectedQueryIds(new Set(queryItems.map((item) => item.id))); + }; + + const handleClearQuerySelection = () => { + setSelectedQueryIds(new Set()); + }; + + const handleDeleteSelectedQueries = async () => { + if (selectedQueryIds.size === 0) return; + + const ids = Array.from(selectedQueryIds); + setSelectedQueryIds(new Set()); + setDeletingIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => next.add(id)); + return next; + }); + + try { + for (const id of ids) { + await deleteSessionItem(session.sessionId, id); + } + // SSE will update the session state + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + pushNotification(`Failed to delete selected queries: ${msg}`, "error"); + } + }; + const handleUploadResultFile = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; // allow re-selecting the same file later @@ -687,14 +898,36 @@ export const WorkspaceDiscovery: React.FC = ({ session, - + - Saved queries ({queryItems.length}/{MAX_DISCOVERY_QUERY_ITEMS}) + Query results ({queryItems.length}/{MAX_DISCOVERY_QUERY_ITEMS}) - + + + + + + Queries stay here (and survive switching tabs or reloading) until you delete them, up to{" "} @@ -711,8 +944,12 @@ export const WorkspaceDiscovery: React.FC = ({ session, {queryItems.map((item) => ( { setUploadedViewItem(null); setViewingItemId(item.id); From f0a9cb443cf8235fa579dbcc3e49ee50d1e21370 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 02:57:52 +0200 Subject: [PATCH 20/26] UPD: description query window --- gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx index f9f2706..195ca14 100644 --- a/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx +++ b/gui/src/client/src/components/workspace/WorkspaceDiscovery.tsx @@ -714,6 +714,9 @@ export const WorkspaceDiscovery: React.FC = ({ session, Query + + Choose to query either compounds, BGCs, or both. Additional query settings can be revealed using the cog wheel. + Date: Sun, 9 Aug 2026 11:16:43 +0200 Subject: [PATCH 21/26] ENH: add motif hover cards --- .../client/src/components/MotifHoverCard.tsx | 115 ++++++++++++++++++ .../components/workspace/AlignmentGrid.tsx | 12 +- .../workspace/PrimarySequenceEditor.tsx | 11 +- .../components/workspace/SequenceEditor.tsx | 18 ++- gui/src/client/src/features/motifs/api.ts | 10 ++ gui/src/client/src/features/motifs/types.ts | 9 ++ gui/src/server/app.py | 2 + gui/src/server/routes/discovery.py | 26 ++++ src/retromol/model/rules.py | 7 ++ 9 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 gui/src/client/src/components/MotifHoverCard.tsx create mode 100644 gui/src/client/src/features/motifs/api.ts create mode 100644 gui/src/client/src/features/motifs/types.ts diff --git a/gui/src/client/src/components/MotifHoverCard.tsx b/gui/src/client/src/components/MotifHoverCard.tsx new file mode 100644 index 0000000..9306fef --- /dev/null +++ b/gui/src/client/src/components/MotifHoverCard.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import { useQuery } from "@tanstack/react-query"; +import { fetchMotifStructures } from "../features/motifs/api"; +import { MotifName } from "./MotifName"; +import SmilesDrawerContainer from "./SmilesDrawerContainer.js"; + +const DRAWING_SIZE = 100; + +// Content of the popup -- a separate component (rather than inlined in the +// Tooltip's title) so its useQuery only actually runs once the Tooltip mounts it, +// which MUI's Popper does lazily on open. queryKey is shared across every +// instance on the page, so the name -> SMILES map is fetched once and cached. +function MotifHoverContent({ name, hint }: { name: string; hint?: string }) { + const structuresQuery = useQuery({ + queryKey: ["motifStructures"], + queryFn: ({ signal }) => fetchMotifStructures(signal), + staleTime: Infinity, + gcTime: Infinity, + }); + + const smiles = structuresQuery.data?.[name]; + + // Must be unique per *mounted instance*, not derived from `name` alone -- + // SmilesDrawerContainer draws into a DOM node it looks up by this id, and the + // same motif name commonly appears in many cells at once (a whole column of + // an MSA, a run of identical residues). Two same-named hover cards can end up + // mounted at the same moment -- e.g. dragging the pointer straight down a + // repeated column, where the outgoing tooltip's exit transition briefly + // overlaps the incoming one's mount -- and a shared id means the lookup can + // resolve to the wrong (closing) node, leaving the one you're actually + // hovering empty until it flashes into view as the other one unmounts. + const reactId = React.useId(); + + return ( + + {smiles ? ( + + ) : ( + + {structuresQuery.isLoading ? ( + + ) : ( + + No structure available + + )} + + )} + + + + {hint && ( + + {hint} + + )} + + ); +} + +// Wraps a motif chip/cell (primary sequence chips, the sequence editor's blocks, +// and the shared pairwise/MSA AlignmentGrid all render a bare motif name) so +// hovering it shows a small structure preview, its name, and optionally whatever +// contextual hint the caller already showed as a plain-text tooltip. +export function MotifHoverCard({ + name, + hint, + children, +}: { + name: string; + hint?: string; + children: React.ReactElement; +}) { + return ( + } + arrow + enterDelay={400} + slotProps={{ + tooltip: { + sx: { + bgcolor: "background.paper", + color: "text.primary", + border: "1px solid", + borderColor: "divider", + boxShadow: 3, + }, + }, + arrow: { + sx: { + color: "background.paper", + "&::before": { + border: "1px solid", + borderColor: "divider", + }, + }, + }, + }} + > + {children} + + ); +} diff --git a/gui/src/client/src/components/workspace/AlignmentGrid.tsx b/gui/src/client/src/components/workspace/AlignmentGrid.tsx index cce5d17..dd55bb5 100644 --- a/gui/src/client/src/components/workspace/AlignmentGrid.tsx +++ b/gui/src/client/src/components/workspace/AlignmentGrid.tsx @@ -17,6 +17,7 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import DownloadIcon from "@mui/icons-material/Download"; import { useTheme, alpha, type Theme } from "@mui/material/styles"; import type { DiscoveryResult } from "../../features/discovery/types"; +import { MotifHoverCard } from "../MotifHoverCard"; import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { buildAlignmentSvg, downloadSvg, type AlignmentSvgRow } from "./alignmentSvgExport"; @@ -127,11 +128,18 @@ export function AlignmentGrid({ rows }: { rows: AlignmentGridRow[] }) { {columnWidths.map((width, idx) => { const name = row.sequence[idx] ?? null; const matchColor = similarityColor(theme, row.matchStrengths?.[idx]); - return ( - + const cell = ( + {name === null ? "–" : } ); + return name === null ? ( + {cell} + ) : ( + + {cell} + + ); })} ))} diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx index 49f1ddc..6cee0be 100644 --- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -10,6 +10,7 @@ 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 { MotifHoverCard } from "../MotifHoverCard"; import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { SequenceEditor, type SequenceBlock } from "./SequenceEditor"; @@ -228,18 +229,16 @@ function PrimarySequenceChips({ // Same rule as the drag-and-drop editor's blocks: only claim it's // clickable when it actually is (see SortableBlock in SequenceEditor). - const title = !linked + const hint = !linked ? "Not linked to the original structure (added or edited by hand)" : selectable ? "Click to highlight the source atoms" - : ""; - - if (!title) return chip; + : undefined; return ( - + {chip} - + ); })} diff --git a/gui/src/client/src/components/workspace/SequenceEditor.tsx b/gui/src/client/src/components/workspace/SequenceEditor.tsx index 4509a98..fb683af 100644 --- a/gui/src/client/src/components/workspace/SequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/SequenceEditor.tsx @@ -2,7 +2,6 @@ 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"; @@ -25,6 +24,7 @@ import { sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { MotifHoverCard } from "../MotifHoverCard"; import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { searchMonomerNames } from "../../features/discovery/api"; @@ -132,23 +132,21 @@ function SortableBlock({ ); - if (!showProvenance) return content; - // Only claim it's clickable when it actually is -- e.g. the inline Upload-list // editor has no molecule view alongside it to highlight, so onClick is omitted - // there and linked blocks get no tooltip at all. - const title = !linked + // there and linked blocks get no click hint. + const hint = !showProvenance + ? undefined + : !linked ? "Not linked to the original structure (added or edited by hand)" : clickable ? "Click to highlight the source atoms" - : ""; - - if (!title) return content; + : undefined; return ( - + {content} - + ); } diff --git a/gui/src/client/src/features/motifs/api.ts b/gui/src/client/src/features/motifs/api.ts new file mode 100644 index 0000000..c3040c4 --- /dev/null +++ b/gui/src/client/src/features/motifs/api.ts @@ -0,0 +1,10 @@ +import { getJson } from "../http"; +import { MotifStructuresRespSchema, type MotifStructures } from "./types"; + +// The whole name -> SMILES vocabulary, fetched once and cached by the caller +// (see MotifHoverCard) -- it's small and effectively static for the life of +// the server process, same rationale as searchMonomerNames' rule list. +export async function fetchMotifStructures(signal?: AbortSignal): Promise { + const data = await getJson("/api/motifStructures", MotifStructuresRespSchema, signal); + return data.structures; +} diff --git a/gui/src/client/src/features/motifs/types.ts b/gui/src/client/src/features/motifs/types.ts new file mode 100644 index 0000000..1d4d0b2 --- /dev/null +++ b/gui/src/client/src/features/motifs/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +// name -> SMILES to depict for that motif. Absent keys mean "no matching rule +// for this name" (unidentified "X" blocks, hand-edited names, PK_GROUP_TOKENS). +export const MotifStructuresRespSchema = z.object({ + structures: z.record(z.string()), +}); + +export type MotifStructures = z.output["structures"]; diff --git a/gui/src/server/app.py b/gui/src/server/app.py index 01a5600..a0e559d 100644 --- a/gui/src/server/app.py +++ b/gui/src/server/app.py @@ -35,6 +35,7 @@ blp_discovery_compare_tanimoto, blp_submit_discovery_query, blp_get_discovery_query_result, + blp_motif_structures, get_discovery_context, ) from routes.rate_limit import limiter, RATE_LIMIT_REJECTIONS @@ -245,6 +246,7 @@ def ready() -> tuple[dict[str, str], int]: app.register_blueprint(blp_events) app.register_blueprint(blp_sse_ticket) app.register_blueprint(blp_discovery_monomer_names) +app.register_blueprint(blp_motif_structures) app.register_blueprint(blp_discovery_query) app.register_blueprint(blp_discovery_msa) app.register_blueprint(blp_discovery_compare_tanimoto) diff --git a/gui/src/server/routes/discovery.py b/gui/src/server/routes/discovery.py index 9d18b9a..f8d9dfa 100644 --- a/gui/src/server/routes/discovery.py +++ b/gui/src/server/routes/discovery.py @@ -38,6 +38,7 @@ from routes.session_store import load_session_with_items, save_item, update_item blp_discovery_monomer_names = Blueprint("discovery_monomer_names", __name__) +blp_motif_structures = Blueprint("motif_structures", __name__) blp_discovery_query = Blueprint("discovery_query", __name__) blp_discovery_msa = Blueprint("discovery_msa", __name__) blp_discovery_compare_tanimoto = Blueprint("discovery_compare_tanimoto", __name__) @@ -493,6 +494,31 @@ def discovery_monomer_names() -> tuple[Response, int]: return jsonify({"rows": [{"name": n} for n in ordered], "rowCount": len(ordered)}), 200 +@blp_motif_structures.get("/api/motifStructures") +def motif_structures() -> tuple[Response, int]: + """ + Every matching rule's depiction SMILES, keyed by rule name. + + Powers the motif hover preview shown in the primary sequence editor, pairwise + alignment, and MSA views: given a block/token name, the frontend draws this + SMILES client-side (via smiles-drawer) instead of round-tripping to the server + per hover. Prefers a rule's `display_smiles` (a friendlier depiction, e.g. + without a reactive leaving-group placeholder) over its matching `smiles` when + one is set. Names with no matching rule (unidentified "X" blocks, hand-edited + names, PK_GROUP_TOKENS) are simply absent from the map. + + The whole vocabulary is returned in one shot rather than per-name, since it's + small (on the order of a few hundred names) and effectively static for the + life of the process -- same rationale as rule_names_sorted powering the + autocomplete above. + + :return: a tuple containing a dictionary with the name -> SMILES map and an HTTP status code + """ + ctx = get_discovery_context() + structures = {name: (rule.display_smiles or rule.smiles) for name, rule in ctx.name_to_rule.items()} + return jsonify({"structures": structures}), 200 + + def run_discovery_query( primary_sequence: list[str], entry_type: str, diff --git a/src/retromol/model/rules.py b/src/retromol/model/rules.py index aaaacc5..2514fed 100644 --- a/src/retromol/model/rules.py +++ b/src/retromol/model/rules.py @@ -342,6 +342,10 @@ class MatchingRule: :cvar pseudonyms: Pseudonyms associated with the rule. :cvar terminal: Whether this rule is terminal (i.e., should not be expanded further). :cvar stereochemistry: Whether to consider stereochemistry in matching. + :cvar display_smiles: Optional friendlier SMILES to show when depicting this motif (e.g. + in the GUI's motif hover preview) instead of `smiles`, which is written for + substructure matching (e.g. carrying a reactive leaving-group placeholder) and + may not be the clearest depiction of the motif on its own. """ name: str @@ -350,6 +354,7 @@ class MatchingRule: pseudonyms: list[str] terminal: bool = True stereochemistry: bool = False + display_smiles: str | None = None mol: Mol = field(init=False, repr=False) @@ -382,6 +387,7 @@ def to_dict(self) -> dict[str, Any]: "pseudonyms": self.pseudonyms, "terminal": self.terminal, "stereochemistry": self.stereochemistry, + "display_smiles": self.display_smiles, } @classmethod @@ -399,6 +405,7 @@ def from_dict(cls, data: dict[str, Any]) -> "MatchingRule": pseudonyms=data.get("pseudonyms", []), terminal=data.get("terminal", True), stereochemistry=data.get("stereochemistry", False), + display_smiles=data.get("display_smiles"), ) return matching_rule From 04b46f01ed845dfdf4c03518221c82b6ab323349 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 11:39:08 +0200 Subject: [PATCH 22/26] STY: change styling result dialog --- .../src/components/workspace/DialogViewDiscoveryQuery.tsx | 8 ++++---- gui/src/client/src/theme/customizations/feedback.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx index 647accc..7d13e3a 100644 --- a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx +++ b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx @@ -128,8 +128,8 @@ export const DialogViewDiscoveryQuery: React.FC = actions={[ { key: "download", - label: "Download", - variant: "outlined", + label: "Download result JSON", + variant: "contained", color: "primary", disabled: item.status !== "done" || !payload, startIcon: , @@ -236,10 +236,10 @@ export const DialogViewDiscoveryQuery: React.FC = ) : ( - - + + ) ) : selectedResults.length === 0 ? ( diff --git a/gui/src/client/src/theme/customizations/feedback.tsx b/gui/src/client/src/theme/customizations/feedback.tsx index a74926d..9970ff8 100644 --- a/gui/src/client/src/theme/customizations/feedback.tsx +++ b/gui/src/client/src/theme/customizations/feedback.tsx @@ -31,7 +31,7 @@ export const feedbackCustomizations: Components = { // app's near-black default paper background -- lighten just the dialog // surface in dark mode so those low-contrast details stay legible. ...theme.applyStyles("dark", { - backgroundColor: "hsl(220, 20%, 13%)", + backgroundColor: "hsl(220, 1%, 1%)", }), }, }), From c302327fb09c611f0d51269421403c57b8aa92e2 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 11:44:34 +0200 Subject: [PATCH 23/26] STY: write out fp in alignmen grid --- gui/src/client/src/components/workspace/AlignmentGrid.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/src/client/src/components/workspace/AlignmentGrid.tsx b/gui/src/client/src/components/workspace/AlignmentGrid.tsx index dd55bb5..a68b980 100644 --- a/gui/src/client/src/components/workspace/AlignmentGrid.tsx +++ b/gui/src/client/src/components/workspace/AlignmentGrid.tsx @@ -230,11 +230,11 @@ export function ResultRow({ sx={{ fontSize: "0.7rem" }} /> - - fp {(result.fingerprintSimilarity * 100).toFixed(1)}% + + fingerprint {(result.fingerprintSimilarity * 100).toFixed(1)}% - + align {result.normalizedAlignmentScorePct.toFixed(1)}% From 3d1d739702cf20ddb1a9211b81e9650af6bc7ca0 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 12:18:47 +0200 Subject: [PATCH 24/26] FIX: remove unused import --- .../client/src/components/workspace/PrimarySequenceEditor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx index 6cee0be..f743b47 100644 --- a/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx +++ b/gui/src/client/src/components/workspace/PrimarySequenceEditor.tsx @@ -2,7 +2,6 @@ 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 Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; From 779ebb70e7ea3ebde6906037e439efe2f4092236 Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 12:39:00 +0200 Subject: [PATCH 25/26] STY: update styling and user experience of result dialog --- .../components/workspace/AlignmentGrid.tsx | 58 +++++++++++++--- .../workspace/DialogViewDiscoveryQuery.tsx | 69 ++++++++++--------- 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/gui/src/client/src/components/workspace/AlignmentGrid.tsx b/gui/src/client/src/components/workspace/AlignmentGrid.tsx index a68b980..f324813 100644 --- a/gui/src/client/src/components/workspace/AlignmentGrid.tsx +++ b/gui/src/client/src/components/workspace/AlignmentGrid.tsx @@ -21,6 +21,7 @@ import { MotifHoverCard } from "../MotifHoverCard"; import { MotifName } from "../MotifName"; import { horizontalScrollSx } from "../../theme/scrollbarSx"; import { buildAlignmentSvg, downloadSvg, type AlignmentSvgRow } from "./alignmentSvgExport"; +import { MinimalIconButton} from "../MinimalIconButton"; // Shared vertical sizing (padding, border width, font size, line height) so every // row -- label, sequence cells, and score -- resolves to the exact same height. @@ -53,16 +54,51 @@ export function similarityColor(theme: Theme, similarity: number | null | undefi } function alignedCellSx(name: string | null, columnWidthCh: number, matchColor: string | undefined) { + const isGap = name === null; return { ...ROW_CELL_BASE_SX, - borderColor: name === null ? "divider" : "primary.main", - bgcolor: name === null ? "transparent" : matchColor ?? "action.hover", + borderColor: isGap ? "divider" : "primary.main", + // A gap cell has no fill of its own, so the row's connecting line (see + // ROW_LINE_SX) shows straight through it -- that's the point, it's what + // marks the cell as empty. Every occupied cell, matched or not, gets an + // opaque fill instead: layering the (semi-transparent) match tint as a + // backgroundImage over an opaque backgroundColor composites down to a + // fully solid color, so the line can never bleed through legible content + // the way a plain alpha-blended fill would. + bgcolor: isGap ? "transparent" : "background.paper", + backgroundImage: !isGap && matchColor ? `linear-gradient(${matchColor}, ${matchColor})` : "none", width: `${columnWidthCh}ch`, textAlign: "center" as const, flexShrink: 0, + // Sits above the row's connecting line (see ROW_LINE_SX). + position: "relative" as const, + zIndex: 1, }; } +// The "string" that runs behind one row of aligned units -- the same trick +// PrimarySequenceChips uses for a sequence of chips -- so the eye can follow a +// row across its gaps without losing the thread. Deliberately NOT extended to +// the label/score columns: those are plain, variable-width text (not a fixed +// row of bordered chips), so hiding the line behind them would need an opaque +// backdrop wide enough to cover arbitrarily long/short labels, which either +// shows as a mismatched-looking box around the text or (worse, if given its +// own wrapper element) risks throwing off the shared row height those columns +// depend on to stay aligned with the sequence column beside them. +const ROW_LINE_SX = { + position: "relative" as const, + "&::before": { + content: '""', + position: "absolute" as const, + left: 0, + right: 0, + top: "50%", + height: "2px", + bgcolor: "divider", + zIndex: 0, + }, +}; + export type AlignmentGridRow = { id: string; label: string; @@ -124,7 +160,7 @@ export function AlignmentGrid({ rows }: { rows: AlignmentGridRow[] }) { {rows.map((row) => ( - + {columnWidths.map((width, idx) => { const name = row.sequence[idx] ?? null; const matchColor = similarityColor(theme, row.matchStrengths?.[idx]); @@ -149,11 +185,13 @@ export function AlignmentGrid({ rows }: { rows: AlignmentGridRow[] }) { {rows.map((row) => ( - {/* a non-breaking space (not "") keeps a text node present so this box's - line-height strut matches its siblings' -- an empty string renders no - inline content, so the browser omits the strut and the box collapses - shorter, throwing off every row below it in this column. */} - {row.score ?? " "} + {/* A non-breaking space -- written as an escape, not a literal character, since a + literal nbsp glyph in source is invisible and indistinguishable from a plain + space in an editor, exactly how this regressed silently before: a plain " " + collapses under normal whitespace rules (it has nothing non-whitespace to + anchor to), so the box loses its line-height strut and shrinks, throwing every + row below it in this column out of alignment with the label/sequence columns. */} + {row.score ?? "\u00A0"} ))} @@ -238,9 +276,9 @@ export function ResultRow({ align {result.normalizedAlignmentScorePct.toFixed(1)}% - setExpanded((e) => !e)}> + setExpanded((e) => !e)}> {expanded ? : } - + diff --git a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx index 7d13e3a..1c651b3 100644 --- a/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx +++ b/gui/src/client/src/components/workspace/DialogViewDiscoveryQuery.tsx @@ -173,41 +173,46 @@ export const DialogViewDiscoveryQuery: React.FC = ) : ( <> - - value && setResultsView(value)} - > - Pairwise + + + value && setResultsView(value)} + > + Pairwise - - - - Multiple sequence alignment - - - + + + + Multiple sequence alignment + + + - - - - Compare compounds - - - - + + + + Compare compounds + + + + - - {selectedForMsa.size} of {payload.results.length} selected - - - + + {selectedForMsa.size} of {payload.results.length} selected + + + + + + + {resultsView !== "compare" && ( From 14532ec8c9edf605e9b4ea89f5fde375eca6f63d Mon Sep 17 00:00:00 2001 From: David Meijer Date: Sun, 9 Aug 2026 22:15:57 +0200 Subject: [PATCH 26/26] FIX: removed unused import --- gui/src/client/src/components/workspace/AlignmentGrid.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/gui/src/client/src/components/workspace/AlignmentGrid.tsx b/gui/src/client/src/components/workspace/AlignmentGrid.tsx index f324813..a79d9fd 100644 --- a/gui/src/client/src/components/workspace/AlignmentGrid.tsx +++ b/gui/src/client/src/components/workspace/AlignmentGrid.tsx @@ -8,7 +8,6 @@ import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import Checkbox from "@mui/material/Checkbox"; import Collapse from "@mui/material/Collapse"; -import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import MuiLink from "@mui/material/Link";