Workflow Comments: Architecture White Paper
Executive Summary
Workflow Comments is a visual annotation system for Galaxy’s Workflow Editor that enables users to document, explain, and structure workflows through text, markdown, geometric framing, and freehand drawing. Comments are optional and non-destructive—they don’t affect workflow execution but enhance human understanding.
1. System Overview
1.1 Purpose and Goals
Workflow Comments addresses a usability gap: workflows can be complex and difficult to understand at a glance, especially when shared. The system provides multiple visual annotation tools:
- Document workflow logic through text and markdown comments
- Visually group workflow steps using frame comments
- Create freehand diagrams for custom visual annotation
- Maintain workflow aesthetics without modifying workflow execution semantics
1.2 Design Philosophy
- Non-destructive: Comments are orthogonal to workflow execution
- Type-driven: Each comment type has its own data structure, Pydantic schema class, and Vue component
- Persistent: Comments are stored in the Galaxy database and serialized in
.gaworkflow exports - Interactive: Comments are fully editable in-place with rich UI affordances
1.3 Scope
- One-to-many relationship with workflows
- Optional parent-child hierarchy (frames can contain steps and other comments)
- Persisted server-side in
workflow_commentdatabase table - Serialized as part of the workflow payload (no dedicated REST endpoints)
2. Architecture Overview
2.1 Layered Design
┌─────────────────────────────────────────┐
│ User Interface Layer │
│ (Vue Components, Pinia Stores) │
├─────────────────────────────────────────┤
│ API / Serialization Layer │
│ (Workflow API, Pydantic schemas) │
├─────────────────────────────────────────┤
│ Persistence Layer │
│ (SQLAlchemy model, Database) │
└─────────────────────────────────────────┘
2.2 Component Interactions
WorkflowGraph.vue (renders all comments)
├── WorkflowComment.vue (routes to correct type component)
│ ├── TextComment.vue
│ ├── MarkdownComment.vue
│ ├── FrameComment.vue
│ └── FreehandComment.vue
├── ToolBar.vue (tool selection, comment options)
│ └── InputCatcher.vue (intercepts canvas pointer events)
├── workflowEditorCommentStore (Pinia, scoped per-workflow)
└── workflowEditorToolbarStore (Pinia, tool state)
3. Data Model
3.1 Database Schema
Table: workflow_comment
SQLAlchemy model: WorkflowComment in lib/galaxy/model/__init__.py
| Column | Type | Description |
|---|---|---|
id | Mapped[int] (PK) | Auto-incremented primary key |
order_index | Mapped[Optional[int]] | Rendering/serialization order |
workflow_id | Mapped[int] (FK → workflow.id) | Parent workflow (indexed) |
position | MutableJSONType | [x, y] coordinates stored as JSON |
size | JSONType | [width, height] stored as JSON |
type | String(16) | text, markdown, frame, or freehand |
color | String(16) | Color name (e.g., "none", "blue", "red") |
data | JSONType | Type-specific payload (text content, line coords, etc.) |
parent_comment_id | Mapped[Optional[int]] (FK → workflow_comment.id) | For frame hierarchies (indexed) |
Relationships:
workflow→Workflow.comments(back_populates)child_steps→WorkflowStep(steps contained in this frame)parent_comment/child_comments→ self-referential (frame nesting)
Key Design Decisions:
- JSON
datacolumn: Allows type-specific fields without schema explosion order_index: Explicitly tracks ordering; serialized as"id"in workflow export (not the database PK)- Color as string name: Decouples persistence from presentation hex values
3.2 Pydantic Schema Layer
File: lib/galaxy/schema/workflow/comments.py
class BaseComment(BaseModel):
id: int
color: Literal["none", "black", "blue", "turquoise", "green",
"lime", "orange", "yellow", "red", "pink"]
position: tuple[float, float]
size: tuple[float, float]
class TextCommentData(BaseModel):
text: str
size: int # 1-5 relative scale
bold: Optional[bool] = None
italic: Optional[bool] = None
class MarkdownCommentData(BaseModel):
text: str
class FrameCommentData(BaseModel):
title: str
class FreehandCommentData(BaseModel):
thickness: float
line: list[tuple[float, float]] # ordered [x, y] coordinate pairs
class TextComment(BaseComment):
type: Literal["text"]
data: TextCommentData
class FrameComment(BaseComment):
type: Literal["frame"]
data: FrameCommentData
child_comments: Optional[list[int]] = None
child_steps: Optional[list[int]] = None
# Similar for Markdown, Freehand
class WorkflowCommentModel(RootModel):
root: Union[TextComment, MarkdownComment, FrameComment, FreehandComment]
# discriminator="type" routes deserialization automatically
Pattern: Discriminated Union — Pydantic’s discriminator="type" automatically selects the correct subtype based on the type field. No explicit routing code needed.
4. Comment Types
4.1 Text Comments
Purpose: Simple, styled text annotations
Data: text, size (1-5), optional bold/italic flags
Rendering:
- Contenteditable
<span>for in-place editing - Dynamic font-size via CSS variable
- Color applied as
--font-colorCSS variable - Auto-removal if empty when unfocused
Interaction:
- Click to edit; toolbar provides bold/italic toggles, font size ±, color picker, delete
- Uses
DraggablePanfor repositioning,useResizablecomposable for resize
4.2 Markdown Comments
Purpose: Rich text documentation using Markdown syntax
Data: text (raw Markdown source)
Rendering:
- Uses
useMarkdowncomposable (wrapsmarkdown-itlibrary) - Heading levels incremented by 1 (
increaseHeadingLevelBy: 1) - Links open in new page
- Bordered container with overflow scroll
- Focus toggles between rendered view and raw
<textarea>editor
Editing Workflow:
- Click/focus → rendered markdown hides, textarea becomes visible
- Edit raw markdown in textarea
- Blur → textarea hides, rendered output reappears
- Changes emitted via
"change"event
DOMPurify: Used in FrameComment title sanitization, not directly in MarkdownComment rendering.
4.3 Frame Comments
Purpose: Visual grouping of workflow steps and other comments
Data: title (frame label)
Relationships (computed dynamically by the store, not persisted directly):
child_comments: IDs of comments spatially within the framechild_steps: IDs of steps spatially within the frame
Semantics:
- Frames define inclusive bounds; contained items are computed via
resolveCommentsInFrames()andresolveStepsInFrames()store actions - Frames CAN contain other frames — the store processes frames in reverse order, and a frame can appear in another frame’s
child_comments - Title sanitized with DOMPurify (
ALLOWED_TAGS: [])
Rendering:
- HTML div with CSS border and background color (using
brighterColorsfor fill,darkenedColorsfor border) - Contenteditable title at top
- Resizable and draggable; can snap children when moved
4.4 Freehand Comments
Purpose: Custom diagramming and visual annotation
Data: line (ordered [x, y] coordinate pairs), thickness (stroke width)
Rendering:
- SVG
<path>element (not HTML5 Canvas) - Uses d3’s
curveCatmullRomfor spline smoothing on completed strokes - Uses
curveLinearwhile actively drawing (just-created) - Fixed z-index of 1600 (renders above other comment types)
- Strokes stored as raw coordinates (resolution-independent)
Erasing:
freehandErasertool enables click/mouseover to delete individual freehand strokesdeleteFreehandComments()store action removes all freehand comments at once
5. State Management
5.1 Pinia Store: workflowEditorCommentStore
File: client/src/stores/workflowEditorCommentStore.ts
Uses defineScopedStore("workflowCommentStore", ...) — each workflow gets an isolated store instance.
const commentStore = useWorkflowCommentStore(workflowId)
State:
commentsRecord:Record<number, WorkflowComment>— all comments indexed by IDlocalCommentsMetadata:Record<number, CommentsMetadata>— transient UI state:multiSelected: highlighted for batch operationsjustCreated: triggers auto-focus
Key Actions:
createComment(comment)/deleteComment(id)— CRUDaddComments(array, defaultPosition?, select?)— bulk add with offsetchangePosition(id, position)— update positionchangeSize(id, size)— update dimensionschangeData(id, data)— update type-specific datachangeColor(id, color)— update coloraddPoint(id, point)— append coordinate to freehand strokeresolveCommentsInFrames()— compute which comments are inside which framesresolveStepsInFrames()— compute which steps are inside which framessetCommentMultiSelected(id, selected)/toggleCommentMultiSelected(id)/clearMultiSelectedComments()— selection managementmarkJustCreated(id)/clearJustCreated(id)— creation statedeleteFreehandComments()— bulk freehand removal
Computed Properties:
comments: ordered array of all commentshighestCommentId: maximum ID for allocationmultiSelectedCommentIds: IDs of selected commentsisJustCreated(id)/getComment(id)/getCommentMultiSelected(id): lookupsallCommentBounds():AxisAlignedBoundingBoxof all comments
5.2 Toolbar Store: workflowEditorToolbarStore
File: client/src/stores/workflowEditorToolbarStore.ts
type CommentTool = "textComment" | "markdownComment" | "frameComment"
| "freehandComment" | "freehandEraser"
type EditorTool = "pointer" | "boxSelect" | CommentTool
Comment Options (reactive defaults for new comments):
bold: false,italic: falsecolor: "none"(WorkflowCommentColor)textSize: 2lineThickness: 5smoothing: 2
Key State:
currentTool: activeEditorToolinputCatcherActive/inputCatcherEnabled/inputCatcherPressed: canvas input state- Snap settings for grid alignment
5.3 Server Synchronization
Comments are part of the standard workflow payload — no dedicated comment endpoints.
Save: Debounced PUT /api/workflows/{id} sends the full workflow payload including all comments.
Load: GET /api/workflows/{id} returns workflow object with comments array; store hydrates from this.
Serialization Detail: The to_dict() method on WorkflowComment serializes order_index as "id" (not the database primary key). Child relationships reference order_index values.
Error Handling: Last-write-wins. Failed saves keep changes in local store; user can retry via save button.
6. UI/UX Component Architecture
6.1 Component Hierarchy
WorkflowGraph.vue
├── WorkflowComment.vue (v-for over comments)
│ ├── TextComment.vue
│ │ ├── DraggablePan (drag handler)
│ │ ├── Contenteditable <span>
│ │ ├── Style toolbar (bold/italic/size/color/delete)
│ │ └── ColorSelector.vue
│ ├── MarkdownComment.vue
│ │ ├── DraggablePan
│ │ ├── <textarea> (edit mode)
│ │ ├── Rendered markdown <div> (display mode)
│ │ └── Color/delete buttons
│ ├── FrameComment.vue
│ │ ├── DraggablePan
│ │ ├── Contenteditable title
│ │ ├── Fit-to-content / select-children buttons
│ │ └── ColorSelector.vue
│ └── FreehandComment.vue
│ └── SVG <path> with d3 curve
└── ToolBar.vue
└── InputCatcher.vue (pointer event routing)
6.2 Input Handling
InputCatcher.vue (client/src/components/Workflow/Editor/Tools/InputCatcher.vue):
- Intercepts pointer events (pointerdown, pointerup, pointermove, pointerleave) on canvas
- Transforms viewport coordinates to workflow canvas space using
Transformgeometry - Broadcasts events via
toolbarStore.emitInputCatcherEvent() - Tool-specific handlers register via
toolbarStore.onInputCatcherEvent()
Comment Creation Flow:
- User selects comment tool type from toolbar (e.g.,
"textComment") currentToolstate updates- Click/drag on canvas defines position and initial size
- Comment created in store;
markJustCreatedtriggers auto-focus - For text/markdown: auto-selects text for immediate editing
Individual Comment Interaction:
- Each component handles drag (via
DraggablePan), resize (viauseResizable), editing - Emits structured events:
change,move,resize,remove,set-color,pan-by - Parent
WorkflowComment.vuerelays events to store actions
6.3 Resizing
Composable: useResizable.ts
export function useResizable(
target: Ref<HTMLElement | undefined | null>,
sizeControl: Ref<[number, number]>,
onResized: (size: [number, number]) => void,
): void
- Watches external size changes and applies to DOM
- Listens for mouseup to detect CSS-resize completion
- Applies snap behavior from toolbar if enabled
- Used by TextComment, MarkdownComment, FrameComment
- FreehandComment is not resizable (fixed bounding box from line coordinates)
6.4 Color System
File: client/src/components/Workflow/Editor/Comments/colors.ts
Base colors (9 named colors + "none"):
export const colors = {
black: "#000",
blue: "#004cec",
turquoise: "#00bbd9",
green: "#319400",
lime: "#68c000",
orange: "#f48400",
yellow: "#fdbd0b",
red: "#e31920",
pink: "#fb00a6",
} as const;
Derived color sets (computed at module load using HSLUV color space):
brightColors: 50% lightness interpolation toward whitebrighterColors: 95% lightness interpolation toward white (used for frame backgrounds)darkenedColors: manually overridden variants for markdown/frame borders:- turquoise →
#00a6c0, lime →#5eae00, yellow →#e9ad00 - Other colors use the base values
- turquoise →
Design Benefits:
- Persistence stores color names, not hex values — easy to retheme
- HSLUV-based derivation produces perceptually uniform brightness variants
ColorSelector.vuecomponent provides the picker UI
7. API Integration
7.1 Workflow Payload
Comments are embedded in the workflow JSON — there are no dedicated comment REST endpoints. All operations go through the workflow API:
GET /api/workflows/{id} # Returns workflow with "comments" array
PUT /api/workflows/{id} # Updates workflow including comments
7.2 Comment JSON Format
{
"comments": [
{
"id": 0,
"type": "text",
"color": "blue",
"position": [100, 200],
"size": [200, 50],
"data": {
"text": "This step is important",
"size": 2,
"bold": true
}
},
{
"id": 1,
"type": "frame",
"color": "green",
"position": [50, 50],
"size": [500, 400],
"data": { "title": "Preprocessing" },
"child_steps": [0, 1, 2],
"child_comments": [0]
}
]
}
Note: The "id" field in JSON is the order_index, not the database primary key.
7.3 Export/Import Format Support
-
GA format (
.gafiles): Comments ARE included. This is the native Galaxy format and preserves full workflow state. Backward compatible with pre-comment workflows. -
gxformat2 YAML (
.gxwf.yml): Comments NOT included. Thefrom_galaxy_native()conversion in gxformat2 does not handle comments. This is a known limitation. -
CWL exports (
.abstract.cwl): Comments not applicable — external format for CWL engines. -
Import behavior: Workflows without comments load normally; no compatibility issues.
8. Geometry and Rendering
8.1 Coordinate System
Comments use workflow canvas coordinates (not viewport coordinates):
Canvas space: Top-left is [0, 0]
position: [x, y] — top-left corner of comment
size: [width, height] — dimensions in canvas units
Zoom/pan transforms applied at render time, not stored.
8.2 ID Allocation
- Comment IDs are immutable and unique per workflow
- Allocated sequentially:
highestCommentId + 1 - Deleted IDs are never reused (gap-tolerant)
8.3 Z-Ordering
- Freehand comments render at a fixed CSS z-index of 1600 (above all other elements)
- Frame comments use z-index 50 on their resize container
- Other comment types use standard workflow element ordering
8.4 Snapping
Optional grid snapping when moving/resizing comments:
- Snap distance configurable via toolbar
Math.round(coord / snapDistance) * snapDistance
9. Key Design Patterns
9.1 Type-Driven Architecture
Each comment type is self-contained:
- Pydantic schema class (backend validation)
- Vue component (frontend rendering)
- TypeScript interface (frontend typing)
- Type-specific data payload
Adding a new comment type requires:
- Add Pydantic schema class + data model
- Add Vue component
- Update discriminated union
- Add
CommentToolvariant in toolbar store - Register in toolbar UI
9.2 Scoped Stores (Pinia)
export const useWorkflowCommentStore = defineScopedStore(
"workflowCommentStore",
(workflowId) => { /* store logic */ }
)
Each workflow gets an isolated store instance. Prevents cross-workflow state leakage.
9.3 Event Emitters
Comments emit structured events, never directly mutate the store:
emit("change", newData)
emit("move", newPosition)
emit("resize", newSize)
emit("remove")
emit("set-color", color)
emit("pan-by", delta)
WorkflowComment.vue receives these and calls the appropriate store action (changeData, changePosition, changeSize, deleteComment, changeColor).
9.4 Spatial Resolution
Frame child relationships are computed dynamically, not stored in the database:
resolveCommentsInFrames()checks which comments fall within each frame’s bounding boxresolveStepsInFrames()does the same for workflow steps- Uses
AxisAlignedBoundingBox.contains()for hit testing - Results written into
child_comments/child_stepsarrays on the frame objects
10. Testing
10.1 Frontend Tests
Location: client/src/components/Workflow/Editor/Comments/__tests__/
WorkflowComment.test.ts— component rendering, event forwarding, type routingColorSelector.test.js— color picker behavior
Store tests: client/src/stores/workflowEditorCommentStore.test.ts
- CRUD operations, multi-selection, frame resolution, reset
10.2 Backend Tests
- Pydantic schema validation for all comment types
to_dict()/from_dict()serialization roundtrip- Cascading deletes: workflow deletion removes associated comments
- Backward compatibility: workflows without comments load correctly
11. Migration History
11.1 Database Migrations
Initial table creation: ddbdbc40bdc1_add_workflow_comment_table.py (2023-08-14)
- Creates
workflow_commenttable with all columns - Adds
parent_comment_idcolumn toworkflow_steptable
Index addition: 2dc3386d091f_add_indexes_for_workflow_comment_.py (2024-03-13)
- Indexes on
workflow_step(parent_comment_id),workflow_comment(workflow_id),workflow_comment(parent_comment_id)
11.2 Framework Modernization
- SQLAlchemy 2.0: Updated to
Mappedannotations for type safety - Pydantic v2:
RootModelpattern for union types, modernizedFieldsyntax - Python 3.10+ syntax:
tuple[int, int]instead ofTuple[int, int] - Frontend: ESM modules, Vue 3 Composition API, JS → TS conversion
12. Future Extensions
12.1 Potential Enhancements
- gxformat2 support: Include comments in YAML workflow exports
- Comment versioning: Track edit history
- Connectors: Visual links between comments and steps
- Viewport culling: Only render comments visible in the current viewport
12.2 Architectural Flexibility
System designed to accommodate:
- Additional comment types (add Pydantic class + Vue component + toolbar entry)
- Different persistence backends (replace SQLAlchemy model)
- Alternative rendering engines (replace Vue components)