← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/use-sequence-workbench-commands.ts
24.8 KB · Sep 30, 2026 · 23:01 UTC
import {
useCallback,
useEffect,
useRef,
type Dispatch,
type MutableRefObject,
} from "react";
import { alignSequences, exportAlignedFasta } from "../msa/alignment-editing";
import type {
QueuedSequenceViewerCommand,
SequenceViewerCommandResult,
} from "../viewer-commands";
import { querySequenceViewer } from "../viewer-query";
import { exportSequenceWorkbench } from "../workbench-exports";
import {
createSequenceWorkbenchState,
parseWorkbenchSession,
sequenceWorkbenchReducer,
serializeWorkbenchSession,
settleRestoredJobs,
summarizeWorkbenchArtifact,
type SequenceWorkbenchAction,
type SequenceWorkbenchState,
type WorkbenchArtifact,
type WorkbenchJob,
type WorkbenchSession,
type WorkbenchViewState,
} from "../workbench-state";
import { parseSequenceTrack } from "./tracks";
import type {
SequenceDocument,
SequenceSearchHit,
SequenceSelection,
} from "./types";
import {
analyzeFastqQualityReport,
parseFastqAdapterSequence,
summarizeFastqQualityReport,
type FastqQualityReportState,
} from "./fastq-quality-analysis";
import {
applySequenceAnnotationRequest,
applySequenceEditRequest,
resolveSequenceRecord,
runSequenceAnalysis,
} from "./workbench-controller";
const OPERATION_ACTIONS = new Set([
"align_sequences",
"cancel_job",
"edit_copy",
"export_artifact",
"load_track",
"manage_annotations",
"query_viewer",
"restore_session",
"run_analysis",
"save_session",
]);
export type SequenceWorkbenchView = NonNullable<WorkbenchViewState["sequence"]>;
export type SequenceWorkbenchRestoreSource = Pick<
SequenceWorkbenchState,
"document" | "tracks"
>;
export function isWorkbenchOperationCommand(
command: QueuedSequenceViewerCommand | undefined,
): boolean {
return command != null && OPERATION_ACTIONS.has(command.action);
}
export function useSequenceWorkbenchCommands({
cancelledJobsRef,
command,
dispatch,
handledCommandIdRef,
hits,
onCommandResult,
onOpenAlignment,
onQualityReportChange,
onRestoreView,
selectedRecordId,
selection,
sourceStateKey,
state,
view,
}: {
cancelledJobsRef: MutableRefObject<Set<string>>;
command?: QueuedSequenceViewerCommand;
dispatch: Dispatch<SequenceWorkbenchAction>;
handledCommandIdRef: MutableRefObject<string | undefined>;
hits: Array<SequenceSearchHit>;
onCommandResult?: (
command: QueuedSequenceViewerCommand,
result: SequenceViewerCommandResult,
) => void | Promise<SequenceViewerCommandResult>;
onOpenAlignment?: (alignedFasta: string, name: string) => void;
onQualityReportChange?: (state: FastqQualityReportState) => void;
onRestoreView: (
view: SequenceWorkbenchView,
source: SequenceWorkbenchRestoreSource,
) => void;
selectedRecordId: string;
selection?: SequenceSelection;
sourceStateKey: string;
state: SequenceWorkbenchState;
view: SequenceWorkbenchView;
}): void {
const stateRef = useRef(state);
stateRef.current = state;
const complete = useCallback(
(
result: SequenceViewerCommandResult,
): void | Promise<SequenceViewerCommandResult> => {
if (command == null) return;
handledCommandIdRef.current = command.commandId;
return onCommandResult?.(command, result);
},
[command, handledCommandIdRef, onCommandResult],
);
useEffect(() => {
if (
command == null ||
!isWorkbenchOperationCommand(command) ||
handledCommandIdRef.current === command.commandId
) {
return;
}
try {
switch (command.action) {
case "query_viewer": {
const query = querySequenceViewer({
artifacts: state.artifacts,
document: state.document,
hits,
jobs: state.jobs,
request: command.request,
selectedRecordId,
tracks: state.tracks,
});
complete({
applied: true,
message: `Returned ${command.request.target} from the live Sequence viewer.`,
state: { query },
});
return;
}
case "run_analysis": {
const sourceDocument = state.document;
if (command.request.analysis === "quality-report") {
const job = scheduleQualityReportJob({
adapterSequence: command.request.adapterSequence,
cancelledJobsRef,
dispatch,
document: sourceDocument,
id: command.jobId,
isSourceCurrent: () =>
stateRef.current.document === sourceDocument,
onQualityReportChange,
});
complete({
applied: true,
message: `Started quality-report as job ${job.id}.`,
state: { job },
});
return;
}
const job = createJob(
command.jobId,
analysisJobKind(command.request.analysis),
command.request,
);
cancelledJobsRef.current.delete(job.id);
dispatch({ job, type: "start-job" });
scheduleJob({
cancelledJobsRef,
dispatch,
isSourceCurrent: () => stateRef.current.document === sourceDocument,
job,
run: () =>
runSequenceAnalysis({
document: state.document,
request: command.request,
selectedRecordId,
selection,
viewerGeneticCodeId: view.geneticCodeId,
}),
});
complete({
applied: true,
message: `Started ${command.request.analysis} as job ${job.id}.`,
state: { job },
});
return;
}
case "align_sequences": {
const sourceDocument = state.document;
const job = createJob(command.jobId, "align", {
algorithm: command.algorithm ?? null,
recordIds: command.recordIds ?? null,
});
cancelledJobsRef.current.delete(job.id);
dispatch({ job, type: "start-job" });
scheduleJob({
cancelledJobsRef,
dispatch,
isSourceCurrent: () => stateRef.current.document === sourceDocument,
job,
onCompleted: (result) => {
const artifact = result.artifact as WorkbenchArtifact | undefined;
if (artifact == null) return result;
dispatch({ artifact, type: "add-artifact" });
onOpenAlignment?.(artifact.content, artifact.name);
return {
...result,
artifact: summarizeWorkbenchArtifact(artifact),
};
},
run: () => {
const requested =
command.recordIds ?? state.document.records.map(({ id }) => id);
const records = requested.map((selector) =>
resolveSequenceRecord(state.document, selector),
);
if (records.length < 2) {
throw new Error(
"Alignment generation requires at least two sequence records.",
);
}
const alignment = alignSequences(
records.map((record) => ({
description: record.description,
id: record.id,
label: record.sourceLabel,
sequence: record.sequence,
})),
undefined,
command.algorithm,
);
const content = `${exportAlignedFasta(alignment.rows)}\n`;
return {
alignedLength: alignment.alignedLength,
artifact: createArtifact({
content,
engine: alignment.engine,
format: "aligned-fasta",
mediaType: "text/x-fasta",
name: `${baseName(state.document.fileName)}.aligned.fasta`,
parameters: alignment.parameters,
sourceRevision: state.revision,
}),
engine: alignment.engine,
rowCount: alignment.rows.length,
warning: alignment.warning,
};
},
});
complete({
applied: true,
message: `Started exploratory alignment as job ${job.id}.`,
state: { job },
});
return;
}
case "edit_copy": {
const change = applySequenceEditRequest({
document: state.document,
request: command.request,
selectedRecordId,
});
if ("historyOperation" in change) {
dispatch({
type:
change.historyOperation === "undo"
? "undo-sequence-document"
: "redo-sequence-document",
});
} else {
dispatch({
description: change.description,
document: change.document,
type: "apply-sequence-document",
});
}
complete({
applied: true,
message:
"historyOperation" in change
? `${change.historyOperation === "undo" ? "Undid" : "Redid"} the most recent sequence-copy edit.`
: change.description,
state: {
dirty: true,
operation:
"historyOperation" in change
? change.historyOperation
: change.operation,
sourceFileOverwritten: false,
},
});
return;
}
case "manage_annotations": {
const change = applySequenceAnnotationRequest({
document: state.document,
request: command.request,
selectedRecordId,
tracks: state.tracks,
});
dispatch({
description: change.description,
document: change.document,
type: "apply-sequence-document",
});
complete({
applied: true,
message: change.description,
state: { dirty: true, sourceFileOverwritten: false },
});
return;
}
case "load_track": {
const content = decodeTrackContent(command.content, command.encoding);
const track = parseSequenceTrack({
content,
displayName: command.displayName,
format: command.format,
id: command.trackId,
requestedReference: command.reference,
sourceContentHash: command.sourceContentHash,
sourceItemCount: command.sourceItemCount,
sourceTruncated: command.sourceTruncated,
sourceWorkspacePath: command.sourceWorkspacePath,
});
dispatch({ track, type: "add-track" });
complete({
applied: true,
message: `Loaded ${track.name} with ${track.summary.itemCount.toLocaleString()} items.`,
state: { track },
});
return;
}
case "export_artifact": {
if (
command.format === "a3m" ||
command.format === "aligned-fasta" ||
command.format === "clustal" ||
command.format === "newick" ||
command.format === "stockholm"
) {
throw new Error(
`${command.format} is available only while the viewer is in Alignment mode.`,
);
}
const output = exportSequenceWorkbench({
document: state.document,
format: command.format,
name: command.name,
recordId: selectedRecordId,
scope: command.scope,
selection,
tracks: state.tracks,
});
const artifact = createArtifact({
...output,
engine: "sequence-viewer-export-v1",
parameters: { scope: command.scope },
sourceRevision: state.revision,
});
const completion = complete({
applied: true,
message: `Prepared ${artifact.name} for download.`,
state: {
artifact: output,
provenance: artifact.provenance,
},
});
if (completion == null) {
dispatch({ artifact, type: "add-artifact" });
} else {
void completion.then(
({ applied }) => {
if (applied) dispatch({ artifact, type: "add-artifact" });
},
() => undefined,
);
}
return;
}
case "save_session": {
const session = createSequenceWorkbenchSession(
state,
view,
sourceStateKey,
);
complete({
applied: true,
message: "Serialized the current Sequence workbench session.",
state: { session: serializeWorkbenchSession(session) },
});
return;
}
case "restore_session": {
const session = parseWorkbenchSession(command.session);
if (
session.view.mode !== "sequence" ||
session.view.sequence == null
) {
throw new Error(
"The saved session is not a Sequence-mode workbench session.",
);
}
if (
session.source.format !== state.document.format ||
session.source.fileName !== (state.document.fileName ?? null) ||
session.source.stateKey !== sourceStateKey
) {
throw new Error(
"The saved session belongs to a different source artifact and was not applied.",
);
}
const snapshot = session.snapshot?.sequenceDocument;
const restoredDocument =
snapshot == null
? state.document
: createSequenceWorkbenchState(snapshot, state.sourceDocument)
.document;
// Validate source-bound presentation settings before changing the copy.
onRestoreView(session.view.sequence, {
document: restoredDocument,
tracks: session.tracks,
});
if (snapshot != null) {
dispatch({
description: "Restored saved sequence-copy state.",
document: restoredDocument,
type: "restore-sequence-document",
});
}
dispatch({
state: {
artifacts: session.artifacts,
dirty: session.dirty,
jobs: settleRestoredJobs(session.jobs),
revision: state.revision,
tracks: session.tracks,
},
type: "restore-shared",
});
complete({
applied: true,
message: "Restored the saved Sequence workbench session.",
state: { restoredRevision: session.revision },
});
return;
}
case "cancel_job": {
const job = state.jobs.find(({ id }) => id === command.jobId);
if (job == null)
throw new Error(`No workbench job matched ${command.jobId}.`);
if (job.status !== "running") {
complete({
applied: false,
message: `Job ${job.id} is already ${job.status}.`,
state: { job },
});
return;
}
cancelledJobsRef.current.add(job.id);
dispatch({ id: job.id, type: "cancel-job" });
complete({
applied: true,
message: `Cancelled ${job.kind} job ${job.id}.`,
state: { jobId: job.id, status: "cancelled" },
});
return;
}
}
} catch (error) {
complete({
applied: false,
message:
error instanceof Error
? error.message
: "The Sequence workbench command could not be completed.",
});
}
}, [
cancelledJobsRef,
command,
complete,
dispatch,
handledCommandIdRef,
hits,
onOpenAlignment,
onQualityReportChange,
onRestoreView,
selectedRecordId,
selection,
sourceStateKey,
state,
view,
]);
}
export function runSequenceWorkbenchJob({
analysis,
cancelledJobsRef,
dispatch,
onQualityReportChange,
selectedRecordId,
selection,
state,
isSourceCurrent = () => true,
viewerGeneticCodeId,
}: {
analysis: Parameters<typeof runSequenceAnalysis>[0]["request"];
cancelledJobsRef?: MutableRefObject<Set<string>>;
dispatch: Dispatch<SequenceWorkbenchAction>;
onQualityReportChange?: (state: FastqQualityReportState) => void;
selectedRecordId: string;
selection?: SequenceSelection;
state: SequenceWorkbenchState;
isSourceCurrent?: () => boolean;
viewerGeneticCodeId: number;
}): string {
const id = crypto.randomUUID();
if (analysis.analysis === "quality-report") {
scheduleQualityReportJob({
adapterSequence: analysis.adapterSequence,
cancelledJobsRef,
dispatch,
document: state.document,
id,
isSourceCurrent,
onQualityReportChange,
});
return id;
}
const job = createJob(id, analysisJobKind(analysis.analysis), analysis);
dispatch({ job, type: "start-job" });
window.setTimeout(() => {
if (!isSourceCurrent()) {
failStaleJob(dispatch, id);
return;
}
try {
const result = runSequenceAnalysis({
document: state.document,
request: analysis,
selectedRecordId,
selection,
viewerGeneticCodeId,
});
if (!isSourceCurrent()) {
failStaleJob(dispatch, id);
return;
}
dispatch({
completedAt: Date.now(),
id,
message: `${analysis.analysis} completed.`,
result: result as unknown as Record<string, unknown>,
type: "complete-job",
});
} catch (error) {
dispatch({
completedAt: Date.now(),
error: error instanceof Error ? error.message : "Analysis failed.",
id,
type: "fail-job",
});
}
}, 0);
return id;
}
function scheduleQualityReportJob({
adapterSequence: inputAdapter,
cancelledJobsRef,
dispatch,
document,
id,
isSourceCurrent,
onQualityReportChange,
}: {
adapterSequence?: string;
cancelledJobsRef?: MutableRefObject<Set<string>>;
dispatch: Dispatch<SequenceWorkbenchAction>;
document: SequenceDocument;
id: string;
isSourceCurrent: () => boolean;
onQualityReportChange?: (state: FastqQualityReportState) => void;
}): WorkbenchJob {
const adapterSequence = parseFastqAdapterSequence(inputAdapter);
const job = createJob(id, "quality-report", {
analysis: "quality-report",
...(adapterSequence == null ? {} : { adapterSequence }),
});
const pending: FastqQualityReportState = {
adapterSequence,
jobId: id,
pending: true,
report: null,
};
const isCancelled = (): boolean => cancelledJobsRef?.current.has(id) === true;
const publishFailure = (message: string): void => {
if (isSourceCurrent()) {
onQualityReportChange?.({ ...pending, error: message, pending: false });
}
};
cancelledJobsRef?.current.delete(id);
dispatch({ job, type: "start-job" });
if (isSourceCurrent()) onQualityReportChange?.(pending);
window.setTimeout(() => {
void (async () => {
if (!isSourceCurrent()) {
failStaleJob(dispatch, id);
return;
}
if (isCancelled()) {
publishFailure("Quality report cancelled.");
return;
}
try {
const report = summarizeFastqQualityReport(
await analyzeFastqQualityReport(document, {
adapterSequence: adapterSequence ?? undefined,
isCancelled: () => isCancelled() || !isSourceCurrent(),
}),
);
if (!isSourceCurrent()) {
failStaleJob(dispatch, id);
return;
}
if (isCancelled()) {
publishFailure("Quality report cancelled.");
return;
}
onQualityReportChange?.({ ...pending, pending: false, report });
dispatch({
completedAt: Date.now(),
id,
message: "quality-report completed.",
result: report,
type: "complete-job",
});
} catch (error) {
if (!isSourceCurrent()) {
failStaleJob(dispatch, id);
return;
}
if (isCancelled()) {
publishFailure("Quality report cancelled.");
return;
}
const message =
error instanceof Error ? error.message : "Quality analysis failed.";
publishFailure(message);
dispatch({
completedAt: Date.now(),
error: message,
id,
type: "fail-job",
});
}
})();
}, 0);
return job;
}
function createJob(
id: string,
kind: WorkbenchJob["kind"],
parameters: Record<string, unknown>,
): WorkbenchJob {
return {
id,
kind,
message: "Queued in the viewer",
parameters,
progress: 0,
startedAt: Date.now(),
status: "running",
};
}
function scheduleJob({
cancelledJobsRef,
dispatch,
isSourceCurrent,
job,
onCompleted,
run,
}: {
cancelledJobsRef: MutableRefObject<Set<string>>;
dispatch: Dispatch<SequenceWorkbenchAction>;
isSourceCurrent: () => boolean;
job: WorkbenchJob;
onCompleted?: (
result: Record<string, unknown>,
) => Record<string, unknown> | void;
run: () => Record<string, unknown>;
}): void {
window.setTimeout(() => {
if (cancelledJobsRef.current.has(job.id)) return;
if (!isSourceCurrent()) {
failStaleJob(dispatch, job.id);
return;
}
try {
const result = run();
if (cancelledJobsRef.current.has(job.id)) return;
if (!isSourceCurrent()) {
failStaleJob(dispatch, job.id);
return;
}
const completedResult = onCompleted?.(result) ?? result;
dispatch({
completedAt: Date.now(),
id: job.id,
message: `${job.kind} completed.`,
result: completedResult,
type: "complete-job",
});
} catch (error) {
if (cancelledJobsRef.current.has(job.id)) return;
dispatch({
completedAt: Date.now(),
error:
error instanceof Error
? error.message
: `${job.kind} could not be completed.`,
id: job.id,
type: "fail-job",
});
}
}, 0);
}
function failStaleJob(
dispatch: Dispatch<SequenceWorkbenchAction>,
id: string,
): void {
dispatch({
completedAt: Date.now(),
error:
"The source sequence changed before this job completed. Run it again on the current copy.",
id,
type: "fail-job",
});
}
function analysisJobKind(
analysis:
| "build-tree"
| "design-primers"
| "distance-matrix"
| "find-orfs"
| "quality-report"
| "restriction-analysis"
| "statistics"
| "translate",
): WorkbenchJob["kind"] {
switch (analysis) {
case "build-tree":
return "guide-tree";
case "design-primers":
return "primers";
case "distance-matrix":
return "distance-matrix";
case "find-orfs":
return "orfs";
case "quality-report":
return "quality-report";
case "restriction-analysis":
return "restriction-analysis";
case "statistics":
return "statistics";
case "translate":
return "translation";
}
}
export function createSequenceWorkbenchSession(
state: SequenceWorkbenchState,
view: SequenceWorkbenchView,
sourceStateKey: string,
): WorkbenchSession {
return {
artifacts: state.artifacts.filter(
({ format }) => format !== "sequence-viewer-session",
),
createdAt: Date.now(),
dirty: state.dirty,
jobs: state.jobs,
revision: state.revision,
schemaVersion: 1,
snapshot: { sequenceDocument: state.document },
source: {
fileName: state.document.fileName ?? null,
format: state.document.format,
stateKey: sourceStateKey,
},
tracks: state.tracks,
view: { mode: "sequence", sequence: view },
};
}
function createArtifact({
content,
engine,
format,
mediaType,
name,
parameters,
sourceRevision,
}: {
content: string;
engine: string;
format: string;
mediaType: string;
name: string;
parameters: Record<string, unknown>;
sourceRevision: number;
}): WorkbenchArtifact {
return {
content,
createdAt: Date.now(),
format,
id: crypto.randomUUID(),
mediaType,
name,
provenance: { engine, parameters, sourceRevision },
};
}
function decodeTrackContent(
content: string,
encoding: "base64" | "utf8",
): string | Uint8Array {
if (encoding === "utf8") return content;
const decoded = atob(content);
return Uint8Array.from(decoded, (symbol) => symbol.charCodeAt(0));
}
function baseName(fileName: string | undefined): string {
const leaf = fileName?.split(/[\\/]/u).at(-1) ?? "sequences";
return leaf.replace(/\.[^.]+$/u, "") || "sequences";
}
// Keep the reducer import live in this module's type surface so bundlers and
// tests exercise the exact reducer used by the hook rather than a parallel
// state implementation.
void sequenceWorkbenchReducer;
SHA-256: 57fb8ba3711c32d2e48062541852b7a50d9f549b4d5badcab01d590ed984c7b5