This plan implements History Notebooks - markdown documents tied to Galaxy histories that use HID-relative references. The feature enables human-AI collaborative analysis documentation with paths to Pages and Workflow Reports.
Reference Documents:
THE_PROBLEM_AND_GOAL.md- Vision and motivationRESEARCH_FOR_PLANNING.md- Backend implementation researchRESEARCH_FOR_PLANNING_UX.md- Frontend/UX implementation researchFEATURE_DEPENDENCIES.md- Dependency graph and parallel tracks
The MVP delivers functional history notebooks that users can create, edit, save, and view (multiple notebooks per history). It includes:
- Database models (HistoryNotebook, HistoryNotebookRevision) - no unique constraint on history_id
- API endpoints (list, CRUD operations)
- HID parsing support in markdown_parse.py
- HID resolution in markdown_util.py
- Frontend notebook list and editor views
- HID insertion toolbox (scoped to current history)
- Routes and entry point from history panel
Not MVP: Window manager, revision UI, drag-and-drop, chat/agent, extraction to Pages/Workflows.
Goal: Create HistoryNotebook and HistoryNotebookRevision models mirroring Page/PageRevision.
Files to modify:
lib/galaxy/model/__init__.py(after line 11217, near PageRevision)lib/galaxy/model/migrations/(new Alembic migration)
Reference Pattern: Page model at lib/galaxy/model/__init__.py:11108-11193
Design Note: A history can have multiple notebooks. Each notebook has revisions, with the title stored on the revision (following the Page pattern).
Tasks:
class HistoryNotebook(Base, Dictifiable, RepresentById, UsesCreateAndUpdateTime):
__tablename__ = "history_notebook"
id: Mapped[int] = mapped_column(primary_key=True)
create_time: Mapped[datetime] = mapped_column(default=now, nullable=True)
update_time: Mapped[datetime] = mapped_column(default=now, onupdate=now, nullable=True)
history_id: Mapped[int] = mapped_column(
ForeignKey("history.id"), index=True, nullable=False
) # No unique constraint - multiple notebooks per history allowed
latest_revision_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("history_notebook_revision.id", use_alter=True,
name="history_notebook_latest_revision_id_fk"),
index=True
)
# Soft delete pattern (standard Galaxy pattern)
deleted: Mapped[Optional[bool]] = mapped_column(index=True, default=False)
purged: Mapped[Optional[bool]] = mapped_column(index=True, default=False)
history: Mapped["History"] = relationship(back_populates="notebooks")
revisions: Mapped[list["HistoryNotebookRevision"]] = relationship(
cascade="all, delete-orphan",
primaryjoin=(lambda: HistoryNotebook.id == HistoryNotebookRevision.notebook_id),
back_populates="notebook",
)
latest_revision: Mapped[Optional["HistoryNotebookRevision"]] = relationship(
post_update=True,
primaryjoin=(lambda: HistoryNotebook.latest_revision_id == HistoryNotebookRevision.id),
lazy=False,
)
dict_element_visible_keys = [
"id", "history_id", "latest_revision_id", "deleted", "create_time", "update_time"
]
def to_dict(self, view="element"):
rval = super().to_dict(view=view)
rev = [a.id for a in self.revisions]
rval["revision_ids"] = rev
return rvalclass HistoryNotebookRevision(Base, Dictifiable, RepresentById):
__tablename__ = "history_notebook_revision"
id: Mapped[int] = mapped_column(primary_key=True)
create_time: Mapped[datetime] = mapped_column(default=now, nullable=True)
update_time: Mapped[datetime] = mapped_column(default=now, onupdate=now, nullable=True)
notebook_id: Mapped[int] = mapped_column(
ForeignKey("history_notebook.id"), index=True
)
title: Mapped[Optional[str]] = mapped_column(TEXT)
content: Mapped[Optional[str]] = mapped_column(TEXT)
content_format: Mapped[Optional[str]] = mapped_column(TrimmedString(32))
# For agent integration (Phase 10)
edit_source: Mapped[Optional[str]] = mapped_column(
TrimmedString(16), default="user"
) # 'user' or 'agent'
notebook: Mapped["HistoryNotebook"] = relationship(
primaryjoin=(lambda: HistoryNotebook.id == HistoryNotebookRevision.notebook_id)
)
DEFAULT_CONTENT_FORMAT = "markdown"
dict_element_visible_keys = [
"id", "notebook_id", "title", "content", "content_format",
"edit_source", "create_time", "update_time"
]
def __init__(self):
self.content_format = HistoryNotebookRevision.DEFAULT_CONTENT_FORMAT
def to_dict(self, view="element"):
rval = super().to_dict(view=view)
rval["create_time"] = self.create_time.isoformat()
rval["update_time"] = self.update_time.isoformat()
return rvalLocation: lib/galaxy/model/__init__.py in History class (around line 3200)
# In History class, add:
notebooks: Mapped[list["HistoryNotebook"]] = relationship(
"HistoryNotebook", back_populates="history"
)# lib/galaxy/model/migrations/alembic/versions/XXXX_add_history_notebook.py
def upgrade():
op.create_table(
'history_notebook',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('create_time', sa.DateTime()),
sa.Column('update_time', sa.DateTime()),
sa.Column('history_id', sa.Integer(), sa.ForeignKey('history.id'),
nullable=False, index=True), # No unique - multiple notebooks per history
sa.Column('latest_revision_id', sa.Integer(), index=True),
sa.Column('deleted', sa.Boolean(), default=False, index=True),
sa.Column('purged', sa.Boolean(), default=False, index=True),
)
op.create_table(
'history_notebook_revision',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('create_time', sa.DateTime()),
sa.Column('update_time', sa.DateTime()),
sa.Column('notebook_id', sa.Integer(),
sa.ForeignKey('history_notebook.id'), index=True),
sa.Column('title', sa.TEXT()),
sa.Column('content', sa.TEXT()),
sa.Column('content_format', sa.String(32)),
sa.Column('edit_source', sa.String(16), default='user'),
)
op.create_foreign_key(
'history_notebook_latest_revision_id_fk',
'history_notebook', 'history_notebook_revision',
['latest_revision_id'], ['id']
)
def downgrade():
op.drop_constraint('history_notebook_latest_revision_id_fk', 'history_notebook')
op.drop_table('history_notebook_revision')
op.drop_table('history_notebook')Location: lib/galaxy/schema/schema.py (after PageDetails around line 4091)
# Enum for content format
class NotebookContentFormat(str, Enum):
markdown = "markdown"
# Input schemas
class CreateHistoryNotebookPayload(Model):
title: Optional[str] = Field(
default=None,
title="Title",
description="Optional title for the notebook. Defaults to history name.",
)
content: Optional[str] = Field(
default="",
title="Content",
description="Initial markdown content.",
)
content_format: NotebookContentFormat = Field(
default=NotebookContentFormat.markdown,
title="Content format",
)
class UpdateHistoryNotebookPayload(Model):
title: Optional[str] = Field(default=None, title="Title")
content: str = Field(..., title="Content", description="New markdown content.")
content_format: NotebookContentFormat = Field(
default=NotebookContentFormat.markdown
)
# Output schemas
class HistoryNotebookSummary(Model):
id: EncodedDatabaseIdField
history_id: EncodedDatabaseIdField
latest_revision_id: Optional[EncodedDatabaseIdField]
revision_ids: list[EncodedDatabaseIdField]
deleted: bool = Field(default=False)
create_time: datetime
update_time: datetime
class HistoryNotebookDetails(HistoryNotebookSummary):
title: Optional[str]
content: Optional[str]
content_format: NotebookContentFormat
edit_source: Optional[str] = Field(default="user")
class HistoryNotebookRevisionSummary(Model):
id: EncodedDatabaseIdField
notebook_id: EncodedDatabaseIdField
title: Optional[str]
edit_source: Optional[str]
create_time: datetime
update_time: datetime
class HistoryNotebookRevisionList(RootModel):
root: list[HistoryNotebookRevisionSummary] = Field(default=[])
class HistoryNotebookList(RootModel):
"""List of notebooks for a history."""
root: list[HistoryNotebookSummary] = Field(default=[])Tests:
- Unit tests for model creation in
test/unit/data/model/ - Test multiple notebooks per history allowed
- Test revision creation and latest_revision update
- Test cascade delete (delete notebook → delete revisions)
Goal: Business logic for notebook operations.
Files to create:
lib/galaxy/managers/history_notebooks.py
Reference Pattern: lib/galaxy/managers/pages.py:128-386
Tasks:
# lib/galaxy/managers/history_notebooks.py
from typing import Optional, Union
from galaxy import model
from galaxy.managers import base
from galaxy.managers.context import ProvidesUserContext
from galaxy.managers.markdown_util import (
ready_galaxy_markdown_for_import,
ready_galaxy_markdown_for_export,
resolve_history_markdown,
)
from galaxy.schema.schema import (
CreateHistoryNotebookPayload,
UpdateHistoryNotebookPayload,
)
class HistoryNotebookManager:
"""Manager for history notebook operations."""
def __init__(self, app):
self.app = app
def list_notebooks(
self, trans: ProvidesUserContext, history_id: int, include_deleted: bool = False
) -> list[model.HistoryNotebook]:
"""List all notebooks for a history."""
stmt = (
select(model.HistoryNotebook)
.filter_by(history_id=history_id)
.order_by(model.HistoryNotebook.update_time.desc())
)
if not include_deleted:
stmt = stmt.filter(model.HistoryNotebook.deleted == false())
return list(trans.sa_session.scalars(stmt))
def get_notebook_by_id(
self, trans: ProvidesUserContext, notebook_id: int, include_deleted: bool = False
) -> model.HistoryNotebook:
"""Get notebook by ID, raises if not found."""
notebook = trans.sa_session.get(model.HistoryNotebook, notebook_id)
if not notebook:
raise base.ObjectNotFound(f"Notebook {notebook_id} not found")
if notebook.deleted and not include_deleted:
raise base.ObjectNotFound(f"Notebook {notebook_id} not found")
return notebook
def create_notebook(
self,
trans: ProvidesUserContext,
history: model.History,
payload: CreateHistoryNotebookPayload,
) -> model.HistoryNotebook:
"""Create a new notebook for a history (multiple notebooks allowed)."""
# Create notebook
notebook = model.HistoryNotebook()
notebook.history = history
# Create initial revision
title = payload.title or history.name
content = payload.content or ""
content_format = payload.content_format or "markdown"
# Process content for storage
if content:
content = ready_galaxy_markdown_for_import(trans, content)
revision = model.HistoryNotebookRevision()
revision.notebook = notebook
revision.title = title
revision.content = content
revision.content_format = content_format
revision.edit_source = "user"
notebook.latest_revision = revision
session = trans.sa_session
session.add(notebook)
session.commit()
return notebook
def save_new_revision(
self,
trans: ProvidesUserContext,
notebook: model.HistoryNotebook,
payload: UpdateHistoryNotebookPayload,
edit_source: str = "user",
) -> model.HistoryNotebookRevision:
"""Create a new revision for the notebook."""
content = payload.content
if not content:
raise base.RequestParameterMissingException("content required")
title = payload.title or notebook.latest_revision.title
content_format = payload.content_format or notebook.latest_revision.content_format
# Process content for storage
content = ready_galaxy_markdown_for_import(trans, content)
revision = model.HistoryNotebookRevision()
revision.notebook = notebook
revision.title = title
revision.content = content
revision.content_format = content_format
revision.edit_source = edit_source
notebook.latest_revision = revision
session = trans.sa_session
session.commit()
return revision
def list_revisions(
self, trans: ProvidesUserContext, notebook: model.HistoryNotebook
) -> list[model.HistoryNotebookRevision]:
"""List all revisions for a notebook."""
stmt = (
select(model.HistoryNotebookRevision)
.filter_by(notebook_id=notebook.id)
.order_by(model.HistoryNotebookRevision.create_time.desc())
)
return list(trans.sa_session.scalars(stmt))
def get_revision(
self, trans: ProvidesUserContext, revision_id: int
) -> model.HistoryNotebookRevision:
"""Get a specific revision by ID."""
revision = trans.sa_session.get(model.HistoryNotebookRevision, revision_id)
if not revision:
raise base.ObjectNotFound(f"Revision {revision_id} not found")
return revision
def rewrite_content_for_export(
self, trans: ProvidesUserContext, history: model.History, rval: dict
) -> None:
"""Process notebook content for API response."""
content = rval.get("content")
if content:
# First resolve HID references to internal IDs
resolved = resolve_history_markdown(trans, history, content)
# Then encode for export
export_content, _, _ = ready_galaxy_markdown_for_export(trans, resolved)
rval["content"] = export_content
def delete_notebook(
self, trans: ProvidesUserContext, notebook: model.HistoryNotebook
) -> None:
"""Soft-delete a notebook (sets deleted=True)."""
notebook.deleted = True
trans.sa_session.commit()
def undelete_notebook(
self, trans: ProvidesUserContext, notebook: model.HistoryNotebook
) -> None:
"""Restore a soft-deleted notebook."""
notebook.deleted = False
trans.sa_session.commit()Goal: REST API for history notebooks (multiple notebooks per history).
Files to create:
lib/galaxy/webapps/galaxy/api/history_notebooks.py
Files to modify:
lib/galaxy/webapps/galaxy/api/__init__.py(register router)
Reference Pattern: lib/galaxy/webapps/galaxy/api/pages.py:98-339
API Routes:
GET /api/histories/{history_id}/notebooks- List all notebooks for historyPOST /api/histories/{history_id}/notebooks- Create new notebookGET /api/histories/{history_id}/notebooks/{notebook_id}- Get single notebookPUT /api/histories/{history_id}/notebooks/{notebook_id}- Update notebookDELETE /api/histories/{history_id}/notebooks/{notebook_id}- Soft-delete notebookPUT /api/histories/{history_id}/notebooks/{notebook_id}/undelete- Restore notebookGET /api/histories/{history_id}/notebooks/{notebook_id}/revisions- List revisions
Tasks:
# lib/galaxy/webapps/galaxy/api/history_notebooks.py
from typing import Annotated, Optional
from fastapi import Body, Path, Response, status
from galaxy.managers.context import ProvidesUserContext
from galaxy.managers.histories import HistoryManager
from galaxy.managers.history_notebooks import HistoryNotebookManager
from galaxy.schema.fields import DecodedDatabaseIdField
from galaxy.schema.schema import (
CreateHistoryNotebookPayload,
UpdateHistoryNotebookPayload,
HistoryNotebookDetails,
HistoryNotebookList,
HistoryNotebookSummary,
HistoryNotebookRevisionList,
HistoryNotebookRevisionSummary,
)
from galaxy.webapps.galaxy.api import (
DependsOnTrans,
Router,
depends,
)
from galaxy.webapps.galaxy.api.common import get_object
router = Router(tags=["history_notebooks"])
HistoryIdPathParam = Annotated[
DecodedDatabaseIdField,
Path(..., title="History ID", description="The ID of the History."),
]
NotebookIdPathParam = Annotated[
DecodedDatabaseIdField,
Path(..., title="Notebook ID", description="The ID of the Notebook."),
]
def get_history_notebook_manager(trans: ProvidesUserContext = DependsOnTrans):
return HistoryNotebookManager(trans.app)
def get_history_manager(trans: ProvidesUserContext = DependsOnTrans):
return HistoryManager(trans.app)
@router.cbv
class FastAPIHistoryNotebooks:
manager: HistoryNotebookManager = depends(get_history_notebook_manager)
history_manager: HistoryManager = depends(get_history_manager)
@router.get(
"/api/histories/{history_id}/notebooks",
summary="List all notebooks for a history.",
response_description="List of notebook summaries.",
)
def index(
self,
history_id: HistoryIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
) -> HistoryNotebookList:
"""List all notebooks for this history."""
history = get_object(
trans, history_id, "History",
check_ownership=False, check_accessible=True
)
notebooks = self.manager.list_notebooks(trans, history.id)
return HistoryNotebookList(
root=[
HistoryNotebookSummary(
id=nb.id,
history_id=nb.history_id,
latest_revision_id=nb.latest_revision_id,
revision_ids=[r.id for r in nb.revisions],
deleted=nb.deleted or False,
create_time=nb.create_time,
update_time=nb.update_time,
)
for nb in notebooks
]
)
@router.get(
"/api/histories/{history_id}/notebooks/{notebook_id}",
summary="Get a specific notebook.",
response_description="The notebook details including content.",
)
def show(
self,
history_id: HistoryIdPathParam,
notebook_id: NotebookIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
) -> HistoryNotebookDetails:
"""Get notebook by ID."""
history = get_object(
trans, history_id, "History",
check_ownership=False, check_accessible=True
)
notebook = self.manager.get_notebook_by_id(trans, notebook_id)
# Verify notebook belongs to this history
if notebook.history_id != history.id:
raise ObjectNotFound(f"Notebook {notebook_id} not found in history {history_id}")
rval = notebook.to_dict()
rval["title"] = notebook.latest_revision.title
rval["content"] = notebook.latest_revision.content
rval["content_format"] = notebook.latest_revision.content_format
rval["edit_source"] = notebook.latest_revision.edit_source
self.manager.rewrite_content_for_export(trans, history, rval)
return HistoryNotebookDetails(**rval)
@router.post(
"/api/histories/{history_id}/notebooks",
summary="Create a new notebook for a history.",
response_description="The created notebook.",
)
def create(
self,
history_id: HistoryIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
payload: CreateHistoryNotebookPayload = Body(...),
) -> HistoryNotebookDetails:
"""Create a new notebook for the history (multiple notebooks allowed)."""
history = get_object(
trans, history_id, "History",
check_ownership=True, check_accessible=True
)
notebook = self.manager.create_notebook(trans, history, payload)
rval = notebook.to_dict()
rval["title"] = notebook.latest_revision.title
rval["content"] = notebook.latest_revision.content
rval["content_format"] = notebook.latest_revision.content_format
rval["edit_source"] = notebook.latest_revision.edit_source
self.manager.rewrite_content_for_export(trans, history, rval)
return HistoryNotebookDetails(**rval)
@router.put(
"/api/histories/{history_id}/notebooks/{notebook_id}",
summary="Update notebook content (creates new revision).",
response_description="The updated notebook.",
)
def update(
self,
history_id: HistoryIdPathParam,
notebook_id: NotebookIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
payload: UpdateHistoryNotebookPayload = Body(...),
) -> HistoryNotebookDetails:
"""Update notebook content. Creates a new revision."""
history = get_object(
trans, history_id, "History",
check_ownership=True, check_accessible=True
)
notebook = self.manager.get_notebook_by_id(trans, notebook_id)
if notebook.history_id != history.id:
raise ObjectNotFound(f"Notebook {notebook_id} not found in history {history_id}")
self.manager.save_new_revision(trans, notebook, payload)
rval = notebook.to_dict()
rval["title"] = notebook.latest_revision.title
rval["content"] = notebook.latest_revision.content
rval["content_format"] = notebook.latest_revision.content_format
rval["edit_source"] = notebook.latest_revision.edit_source
self.manager.rewrite_content_for_export(trans, history, rval)
return HistoryNotebookDetails(**rval)
@router.delete(
"/api/histories/{history_id}/notebooks/{notebook_id}",
summary="Soft-delete a notebook.",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete(
self,
history_id: HistoryIdPathParam,
notebook_id: NotebookIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
):
"""Soft-delete notebook (sets deleted=True)."""
history = get_object(
trans, history_id, "History",
check_ownership=True, check_accessible=True
)
notebook = self.manager.get_notebook_by_id(trans, notebook_id)
if notebook.history_id != history.id:
raise ObjectNotFound(f"Notebook {notebook_id} not found in history {history_id}")
self.manager.delete_notebook(trans, notebook)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.put(
"/api/histories/{history_id}/notebooks/{notebook_id}/undelete",
summary="Restore a soft-deleted notebook.",
status_code=status.HTTP_204_NO_CONTENT,
)
def undelete(
self,
history_id: HistoryIdPathParam,
notebook_id: NotebookIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
):
"""Restore a soft-deleted notebook."""
history = get_object(
trans, history_id, "History",
check_ownership=True, check_accessible=True
)
notebook = self.manager.get_notebook_by_id(trans, notebook_id, include_deleted=True)
if notebook.history_id != history.id:
raise ObjectNotFound(f"Notebook {notebook_id} not found in history {history_id}")
if not notebook.deleted:
raise RequestParameterInvalidException("Notebook is not deleted")
self.manager.undelete_notebook(trans, notebook)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/api/histories/{history_id}/notebooks/{notebook_id}/revisions",
summary="List all revisions for a notebook.",
response_description="List of revision summaries.",
)
def list_revisions(
self,
history_id: HistoryIdPathParam,
notebook_id: NotebookIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
) -> HistoryNotebookRevisionList:
"""List all revisions for a notebook."""
history = get_object(
trans, history_id, "History",
check_ownership=False, check_accessible=True
)
notebook = self.manager.get_notebook_by_id(trans, notebook_id)
if notebook.history_id != history.id:
raise ObjectNotFound(f"Notebook {notebook_id} not found in history {history_id}")
revisions = self.manager.list_revisions(trans, notebook)
return HistoryNotebookRevisionList(
root=[
HistoryNotebookRevisionSummary(
id=r.id,
notebook_id=r.notebook_id,
title=r.title,
edit_source=r.edit_source,
create_time=r.create_time,
update_time=r.update_time,
)
for r in revisions
]
)In lib/galaxy/webapps/galaxy/api/__init__.py, add:
from galaxy.webapps.galaxy.api.history_notebooks import router as history_notebooks_router
# ... in router registration section:
include_router(history_notebooks_router)Tests:
- API integration tests for all endpoints
- Permission tests (can't access notebook for inaccessible history)
- Test revision creation on update
- Test 404 when notebook doesn't exist
Goal: Allow hid=N argument in Galaxy markdown directives.
Files to modify:
lib/galaxy/managers/markdown_parse.py(lines 26-69)
Tasks:
Location: lib/galaxy/managers/markdown_parse.py:26-69
Add "hid" to these 10 directives:
VALID_ARGUMENTS: dict[str, Union[list[str], DynamicArguments]] = {
# ... existing entries ...
"history_dataset_as_image": ["hid", "history_dataset_id", "input", "invocation_id", "output", "path"],
"history_dataset_as_table": [
"compact",
"footer",
"hid", # ADD
"history_dataset_id",
"input",
"invocation_id",
"output",
"path",
"show_column_headers",
"title",
],
"history_dataset_collection_display": ["hid", "history_dataset_collection_id", "input", "invocation_id", "output"],
"history_dataset_display": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
"history_dataset_embedded": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
"history_dataset_index": ["hid", "history_dataset_id", "input", "invocation_id", "output", "path"],
"history_dataset_info": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
"history_dataset_link": ["hid", "history_dataset_id", "input", "invocation_id", "output", "path", "label"],
"history_dataset_name": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
"history_dataset_peek": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
"history_dataset_type": ["hid", "history_dataset_id", "input", "invocation_id", "output"],
# ... rest unchanged ...
}No validation function changes needed - the existing _validate_arg() at line 181 works generically.
Tests:
- Parse markdown with
hid=42- should pass validation - Parse markdown with both
hidandhistory_dataset_id- should pass (validation is additive) - Parse markdown with invalid hid format - handled by regex
Goal: Convert hid=N to history_dataset_id=X or history_dataset_collection_id=X.
Files to modify:
lib/galaxy/managers/markdown_util.py
Reference Pattern: resolve_invocation_markdown() at lines 1048-1182
Tasks:
Location: lib/galaxy/managers/markdown_util.py (near line 72, with other patterns)
HID_PATTERN = re.compile(r"hid=(\d+)")Location: lib/galaxy/managers/markdown_util.py (after resolve_invocation_markdown, around line 1183)
def _resolve_hid(history: model.History, hid: int) -> tuple[str, int]:
"""
Look up HID in history, return (arg_name, internal_id).
HIDs can reference either HDA or HDCA - they share namespace.
Raises:
ValueError: If HID not found or references deleted item
"""
# Check active HDAs
for hda in history.active_datasets:
if hda.hid == hid:
return ("history_dataset_id", hda.id)
# Check active HDCAs
for hdca in history.active_dataset_collections:
if hdca.hid == hid:
return ("history_dataset_collection_id", hdca.id)
# Check if HID exists but is deleted
for hda in history.datasets:
if hda.hid == hid:
raise ValueError(f"HID {hid} references deleted dataset")
for hdca in history.dataset_collections:
if hdca.hid == hid:
raise ValueError(f"HID {hid} references deleted collection")
raise ValueError(f"HID {hid} not found in history {history.id}")
def resolve_history_markdown(
trans: ProvidesUserContext,
history: model.History,
markdown_content: str
) -> str:
"""
Resolve hid=N references to internal IDs.
Args:
trans: Transaction context
history: History containing the referenced items
markdown_content: Raw markdown with hid references
Returns:
Markdown with hid=N replaced by history_dataset_id=X or
history_dataset_collection_id=X depending on item type.
Raises:
ValueError: If HID doesn't exist in history or is deleted
"""
def _remap(container: str, line: str) -> tuple[str, bool]:
hid_match = HID_PATTERN.search(line)
if hid_match:
hid = int(hid_match.group(1))
arg_name, internal_id = _resolve_hid(history, hid)
line = line.replace(hid_match.group(0), f"{arg_name}={internal_id}")
return (line, False)
return _remap_galaxy_markdown_calls(_remap, markdown_content)Location: lib/galaxy/managers/markdown_util.py:77-82
# Update to include hid (though hid won't be encoded - it stays as-is in storage)
UNENCODED_ID_PATTERN = re.compile(
r"(history_id|workflow_id|history_dataset_id|history_dataset_collection_id|job_id|implicit_collection_jobs_id|invocation_id)=([\d]+)"
)
# Note: hid is NOT added here because we want to preserve hid= in storage
# and only resolve it at render timeTests:
- Resolve HID that exists as HDA → returns history_dataset_id
- Resolve HID that exists as HDCA → returns history_dataset_collection_id
- Resolve HID that doesn't exist → raises ValueError
- Resolve HID for deleted item → raises ValueError with clear message
- Resolve multiple HIDs in one document
- Resolve with no HID references → returns unchanged
Can start once API exists. Does not require HID resolution to be complete.
Goal: TypeScript client for notebook API (multiple notebooks per history).
Files to create:
client/src/api/historyNotebooks.ts
Tasks:
// client/src/api/historyNotebooks.ts
import { fetcher } from "@/api/schema";
export interface HistoryNotebookSummary {
id: string;
history_id: string;
latest_revision_id: string | null;
revision_ids: string[];
deleted: boolean;
create_time: string;
update_time: string;
}
export interface HistoryNotebook extends HistoryNotebookSummary {
title: string | null;
content: string | null;
content_format: "markdown";
edit_source: "user" | "agent";
}
export interface HistoryNotebookRevision {
id: string;
notebook_id: string;
title: string | null;
edit_source: "user" | "agent";
create_time: string;
update_time: string;
}
export interface CreateNotebookPayload {
title?: string;
content?: string;
content_format?: "markdown";
}
export interface UpdateNotebookPayload {
title?: string;
content: string;
content_format?: "markdown";
}
// API fetchers
const listNotebooks = fetcher.path("/api/histories/{history_id}/notebooks").method("get").create();
const getNotebook = fetcher.path("/api/histories/{history_id}/notebooks/{notebook_id}").method("get").create();
const createNotebook = fetcher.path("/api/histories/{history_id}/notebooks").method("post").create();
const updateNotebook = fetcher.path("/api/histories/{history_id}/notebooks/{notebook_id}").method("put").create();
const deleteNotebook = fetcher.path("/api/histories/{history_id}/notebooks/{notebook_id}").method("delete").create();
const undeleteNotebook = fetcher.path("/api/histories/{history_id}/notebooks/{notebook_id}/undelete").method("put").create();
const listRevisions = fetcher.path("/api/histories/{history_id}/notebooks/{notebook_id}/revisions").method("get").create();
export async function fetchHistoryNotebooks(historyId: string): Promise<HistoryNotebookSummary[]> {
const { data } = await listNotebooks({ history_id: historyId });
return data;
}
export async function fetchHistoryNotebook(
historyId: string,
notebookId: string
): Promise<HistoryNotebook> {
const { data } = await getNotebook({ history_id: historyId, notebook_id: notebookId });
return data;
}
export async function createHistoryNotebook(
historyId: string,
payload: CreateNotebookPayload
): Promise<HistoryNotebook> {
const { data } = await createNotebook({ history_id: historyId }, payload);
return data;
}
export async function updateHistoryNotebook(
historyId: string,
notebookId: string,
payload: UpdateNotebookPayload
): Promise<HistoryNotebook> {
const { data } = await updateNotebook({ history_id: historyId, notebook_id: notebookId }, payload);
return data;
}
export async function deleteHistoryNotebook(historyId: string, notebookId: string): Promise<void> {
await deleteNotebook({ history_id: historyId, notebook_id: notebookId });
}
export async function undeleteHistoryNotebook(historyId: string, notebookId: string): Promise<void> {
await undeleteNotebook({ history_id: historyId, notebook_id: notebookId });
}
export async function fetchNotebookRevisions(
historyId: string,
notebookId: string
): Promise<HistoryNotebookRevision[]> {
const { data } = await listRevisions({ history_id: historyId, notebook_id: notebookId });
return data;
}Goal: State management for notebook list and editing (multiple notebooks per history).
Files to create:
client/src/stores/historyNotebookStore.ts
Tasks:
// client/src/stores/historyNotebookStore.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import {
fetchHistoryNotebooks,
fetchHistoryNotebook,
createHistoryNotebook,
updateHistoryNotebook,
deleteHistoryNotebook,
type HistoryNotebook,
type HistoryNotebookSummary,
type CreateNotebookPayload,
type UpdateNotebookPayload,
} from "@/api/historyNotebooks";
export const useHistoryNotebookStore = defineStore("historyNotebook", () => {
// State
const notebooks = ref<HistoryNotebookSummary[]>([]);
const currentNotebook = ref<HistoryNotebook | null>(null);
const originalContent = ref<string>("");
const currentContent = ref<string>("");
const currentTitle = ref<string>("");
const isLoadingList = ref(false);
const isLoadingNotebook = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const historyId = ref<string | null>(null);
// Getters
const hasNotebooks = computed(() => notebooks.value.length > 0);
const hasCurrentNotebook = computed(() => currentNotebook.value !== null);
const isDirty = computed(() => currentContent.value !== originalContent.value);
const canSave = computed(() => isDirty.value && !isSaving.value);
// Actions
async function loadNotebooks(newHistoryId: string) {
historyId.value = newHistoryId;
isLoadingList.value = true;
error.value = null;
try {
notebooks.value = await fetchHistoryNotebooks(newHistoryId);
} catch (e: any) {
error.value = e.message || "Failed to load notebooks";
} finally {
isLoadingList.value = false;
}
}
async function loadNotebook(notebookId: string) {
if (!historyId.value) return;
isLoadingNotebook.value = true;
error.value = null;
try {
const data = await fetchHistoryNotebook(historyId.value, notebookId);
currentNotebook.value = data;
originalContent.value = data.content || "";
currentContent.value = data.content || "";
currentTitle.value = data.title || "";
} catch (e: any) {
error.value = e.message || "Failed to load notebook";
} finally {
isLoadingNotebook.value = false;
}
}
async function createNotebook(payload?: CreateNotebookPayload): Promise<HistoryNotebook | null> {
if (!historyId.value) return null;
isLoadingNotebook.value = true;
error.value = null;
try {
const data = await createHistoryNotebook(historyId.value, payload || {});
currentNotebook.value = data;
originalContent.value = data.content || "";
currentContent.value = data.content || "";
currentTitle.value = data.title || "";
// Refresh list
await loadNotebooks(historyId.value);
return data;
} catch (e: any) {
error.value = e.message || "Failed to create notebook";
throw e;
} finally {
isLoadingNotebook.value = false;
}
}
async function saveNotebook() {
if (!historyId.value || !currentNotebook.value || !isDirty.value) return;
isSaving.value = true;
error.value = null;
try {
const payload: UpdateNotebookPayload = {
content: currentContent.value,
title: currentTitle.value || undefined,
};
const data = await updateHistoryNotebook(
historyId.value,
currentNotebook.value.id,
payload
);
currentNotebook.value = data;
originalContent.value = data.content || "";
} catch (e: any) {
error.value = e.message || "Failed to save notebook";
throw e;
} finally {
isSaving.value = false;
}
}
async function deleteCurrentNotebook() {
if (!historyId.value || !currentNotebook.value) return;
try {
await deleteHistoryNotebook(historyId.value, currentNotebook.value.id);
currentNotebook.value = null;
originalContent.value = "";
currentContent.value = "";
currentTitle.value = "";
// Refresh list
await loadNotebooks(historyId.value);
} catch (e: any) {
error.value = e.message || "Failed to delete notebook";
throw e;
}
}
function updateContent(content: string) {
currentContent.value = content;
}
function updateTitle(title: string) {
currentTitle.value = title;
}
function discardChanges() {
currentContent.value = originalContent.value;
}
function clearCurrentNotebook() {
currentNotebook.value = null;
originalContent.value = "";
currentContent.value = "";
currentTitle.value = "";
}
function $reset() {
notebooks.value = [];
currentNotebook.value = null;
originalContent.value = "";
currentContent.value = "";
currentTitle.value = "";
isLoadingList.value = false;
isLoadingNotebook.value = false;
isSaving.value = false;
error.value = null;
historyId.value = null;
}
return {
// State
notebooks,
currentNotebook,
currentContent,
currentTitle,
isLoadingList,
isLoadingNotebook,
isSaving,
error,
historyId,
// Getters
hasNotebooks,
hasCurrentNotebook,
isDirty,
canSave,
// Actions
loadNotebooks,
loadNotebook,
createNotebook,
saveNotebook,
deleteCurrentNotebook,
updateContent,
updateTitle,
discardChanges,
clearCurrentNotebook,
$reset,
};
});Goal: Main notebook view with list and editor (multiple notebooks per history).
Files to create:
client/src/components/HistoryNotebook/HistoryNotebookView.vue(main container)client/src/components/HistoryNotebook/HistoryNotebookList.vue(notebook list)
Tasks:
<!-- client/src/components/HistoryNotebook/HistoryNotebookList.vue -->
<template>
<div class="history-notebook-list">
<div class="list-header d-flex justify-content-between align-items-center p-3 border-bottom">
<h4 class="mb-0">Notebooks</h4>
<BButton variant="primary" size="sm" @click="$emit('create')">
<FontAwesomeIcon :icon="faPlus" />
New Notebook
</BButton>
</div>
<div v-if="notebooks.length === 0" class="empty-state text-center p-4">
<p class="text-muted">No notebooks yet</p>
<p class="text-muted small">
Create a notebook to document your analysis with rich markdown,
embedded datasets, and visualizations.
</p>
</div>
<div v-else class="notebook-items">
<div
v-for="notebook in notebooks"
:key="notebook.id"
class="notebook-item p-3 border-bottom cursor-pointer"
@click="$emit('select', notebook.id)">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="notebook-title fw-bold">
{{ getNotebookTitle(notebook) }}
</div>
<div class="notebook-meta text-muted small">
Updated {{ formatDate(notebook.update_time) }}
</div>
</div>
<FontAwesomeIcon :icon="faChevronRight" class="text-muted" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BButton } from "bootstrap-vue-next";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faPlus, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import type { HistoryNotebookSummary } from "@/api/historyNotebooks";
defineProps<{
notebooks: HistoryNotebookSummary[];
historyName: string;
}>();
defineEmits<{
(e: "select", notebookId: string): void;
(e: "create"): void;
}>();
function getNotebookTitle(notebook: HistoryNotebookSummary): string {
// Title comes from revision, not available in summary - use create time as identifier
return `Notebook ${notebook.id.slice(-6)}`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<style scoped lang="scss">
.notebook-item:hover {
background: var(--panel-header-bg);
}
.cursor-pointer {
cursor: pointer;
}
</style><!-- client/src/components/HistoryNotebook/HistoryNotebookView.vue -->
<template>
<div class="history-notebook-view d-flex flex-column h-100">
<!-- Loading state -->
<BAlert v-if="store.isLoadingList" variant="info" show>
<FontAwesomeIcon :icon="faSpinner" spin />
Loading notebooks...
</BAlert>
<!-- Error state -->
<BAlert v-else-if="store.error" variant="danger" show dismissible @dismissed="store.error = null">
{{ store.error }}
</BAlert>
<!-- No notebook selected - show list -->
<template v-else-if="!notebookId">
<HistoryNotebookList
:notebooks="store.notebooks"
:history-name="historyName"
@select="handleSelect"
@create="handleCreate"
/>
</template>
<!-- Notebook selected - show editor -->
<template v-else-if="store.hasCurrentNotebook">
<!-- Toolbar -->
<div class="notebook-toolbar d-flex align-items-center p-2 border-bottom">
<BButton variant="link" size="sm" @click="handleBack">
<FontAwesomeIcon :icon="faArrowLeft" />
Back
</BButton>
<span class="flex-grow-1 text-center fw-bold">
{{ store.currentTitle || "Untitled Notebook" }}
</span>
<BButton
variant="primary"
size="sm"
:disabled="!store.canSave"
@click="handleSave">
<FontAwesomeIcon :icon="store.isSaving ? faSpinner : faSave" :spin="store.isSaving" />
Save
</BButton>
<span v-if="store.isDirty" class="ms-2 text-warning small">
Unsaved
</span>
</div>
<!-- Editor -->
<div class="notebook-content flex-grow-1 overflow-auto">
<HistoryNotebookEditor
:history-id="historyId"
:content="store.currentContent"
@update:content="store.updateContent"
/>
</div>
</template>
<!-- Loading specific notebook -->
<BAlert v-else-if="store.isLoadingNotebook" variant="info" show>
<FontAwesomeIcon :icon="faSpinner" spin />
Loading notebook...
</BAlert>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { BAlert, BButton } from "bootstrap-vue-next";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faSpinner, faSave, faArrowLeft } from "@fortawesome/free-solid-svg-icons";
import { useHistoryNotebookStore } from "@/stores/historyNotebookStore";
import { useHistoryStore } from "@/stores/historyStore";
import HistoryNotebookList from "./HistoryNotebookList.vue";
import HistoryNotebookEditor from "./HistoryNotebookEditor.vue";
const props = defineProps<{
historyId: string;
notebookId?: string;
displayOnly?: boolean;
}>();
const router = useRouter();
const store = useHistoryNotebookStore();
const historyStore = useHistoryStore();
const historyName = computed(() => {
const history = historyStore.getHistoryById(props.historyId);
return history?.name || "History";
});
onMounted(async () => {
await store.loadNotebooks(props.historyId);
if (props.notebookId) {
await store.loadNotebook(props.notebookId);
}
});
onUnmounted(() => {
store.$reset();
});
watch(() => props.historyId, async (newId) => {
await store.loadNotebooks(newId);
if (props.notebookId) {
await store.loadNotebook(props.notebookId);
}
});
watch(() => props.notebookId, async (newId) => {
if (newId) {
await store.loadNotebook(newId);
} else {
store.clearCurrentNotebook();
}
});
function handleSelect(notebookId: string) {
router.push(`/histories/${props.historyId}/notebooks/${notebookId}`);
}
async function handleCreate() {
const notebook = await store.createNotebook();
if (notebook) {
router.push(`/histories/${props.historyId}/notebooks/${notebook.id}`);
}
}
function handleBack() {
store.clearCurrentNotebook();
router.push(`/histories/${props.historyId}/notebooks`);
}
async function handleSave() {
await store.saveNotebook();
}
</script>
<style scoped lang="scss">
.history-notebook-view {
background: var(--body-bg);
}
.notebook-toolbar {
background: var(--panel-header-bg);
}
.notebook-content {
padding: 1rem;
}
</style>Goal: Wrap MarkdownEditor with history context.
Files to create:
client/src/components/HistoryNotebook/HistoryNotebookEditor.vue
Tasks:
<!-- client/src/components/HistoryNotebook/HistoryNotebookEditor.vue -->
<template>
<div class="history-notebook-editor">
<MarkdownEditor
:markdown-text="content"
mode="history_notebook"
:title="editorTitle"
@update="handleUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import MarkdownEditor from "@/components/Markdown/MarkdownEditor.vue";
import { useHistoryStore } from "@/stores/historyStore";
const props = defineProps<{
historyId: string;
content: string;
}>();
const emit = defineEmits<{
(e: "update:content", content: string): void;
}>();
const historyStore = useHistoryStore();
const editorTitle = computed(() => {
const history = historyStore.getHistoryById(props.historyId);
return history?.name || "History Notebook";
});
function handleUpdate(newContent: string) {
emit("update:content", newContent);
}
</script>
<style scoped lang="scss">
.history-notebook-editor {
height: 100%;
}
</style>Goal: Add routes for notebook list and editor views.
Files to modify:
client/src/entry/analysis/router.js(after line 404, near history routes)
Tasks:
// In the Analysis children array, after histories/:historyId/invocations
// Both routes use the same view component - HistoryNotebookView acts as a
// smart container that conditionally renders HistoryNotebookList (when no
// notebookId) or HistoryNotebookEditor (when notebookId present).
// Notebook list route (no notebookId → shows list)
{
path: "histories/:historyId/notebooks",
component: () => import("@/components/HistoryNotebook/HistoryNotebookView.vue"),
props: (route) => ({
historyId: route.params.historyId,
}),
},
// Specific notebook route (notebookId present → shows editor)
{
path: "histories/:historyId/notebooks/:notebookId",
component: () => import("@/components/HistoryNotebook/HistoryNotebookView.vue"),
props: (route) => ({
historyId: route.params.historyId,
notebookId: route.params.notebookId,
displayOnly: route.query.displayOnly === "true",
}),
},Goal: Add "Notebooks" button to history panel (links to notebook list).
Files to modify:
client/src/components/History/HistoryOptions.vue(after line 217, near Extract Workflow)
Tasks:
<!-- Add after the "Extract Workflow" dropdown item -->
<BDropdownItem
v-if="historyStore.currentHistoryId === history.id"
data-description="history notebooks"
:disabled="isAnonymous"
:title="userTitle('View and Create History Notebooks')"
:to="`/histories/${history.id}/notebooks`">
<FontAwesomeIcon fixed-width :icon="faBook" />
<span v-localize>History Notebooks</span>
</BDropdownItem>// In script section, add to imports:
import { faBook } from "@fortawesome/free-solid-svg-icons";Requires HID parsing support in backend.
Files to modify:
client/src/components/Markdown/MarkdownEditor.vue(line 58)
Tasks:
// Change from:
const props = defineProps<{
markdownText: string;
mode: "report" | "page";
// ...
}>();
// To:
const props = defineProps<{
markdownText: string;
mode: "report" | "page" | "history_notebook";
// ...
}>();<h2 v-if="mode === 'page'" class="mb-0">Markdown Help for Pages</h2>
<h2 v-else-if="mode === 'history_notebook'" class="mb-0">Markdown Help for History Notebooks</h2>
<h2 v-else class="mb-0">Markdown Help for Invocation Reports</h2>Files to modify:
client/src/components/Markdown/directives.ts(line 10)
Tasks:
// Change from:
type DirectiveMode = "page" | "report";
// To:
type DirectiveMode = "page" | "report" | "history_notebook";Files to modify:
client/src/components/Markdown/MarkdownToolBox.vue(lines 92-96, 201-206)
Tasks:
props: {
steps: {
type: Object,
default: null,
},
notebookMode: {
type: Boolean,
default: false,
},
historyId: {
type: String,
default: null,
},
},computed: {
isWorkflow() {
return !!this.steps;
},
isHistoryNotebook() {
return this.notebookMode;
},
mode() {
if (this.isWorkflow) return "report";
if (this.isHistoryNotebook) return "history_notebook";
return "page";
},
},// In data or computed, add:
historyNotebookSection: {
title: "History",
name: "history",
elems: [
...historySharedElements("history_notebook"),
],
},<ToolSection v-if="isWorkflow" :category="historyInEditorSection" :expanded="true" @onClick="onClick" />
<ToolSection v-else-if="isHistoryNotebook" :category="historyNotebookSection" :expanded="true" @onClick="onClick" />
<ToolSection v-else :category="historySection" :expanded="true" @onClick="onClick" />Files to modify:
client/src/components/Markdown/MarkdownDialog.vue
Tasks:
props: {
// existing props...
mode: {
type: String,
default: "page",
},
historyId: {
type: String,
default: null,
},
},In the selection handler:
function handleSelection(item) {
if (props.mode === "history_notebook") {
// Emit HID reference for history notebooks
emit("onInsert", `${directiveName}(hid=${item.hid})`);
} else {
// Existing: emit encoded ID for pages
emit("onInsert", `${directiveName}(history_dataset_id=${item.id})`);
}
}When opening DataDialog for history_notebook mode:
// Pass history filter to DataDialog
<DataDialog
v-if="showDataDialog"
:history="mode === 'history_notebook' ? historyId : null"
@onSelect="handleSelection"
/>Tests:
- In history_notebook mode, insertion emits
hid=N - In page mode, insertion emits
history_dataset_id=X(unchanged) - DataDialog only shows items from current history when historyId provided
Tasks:
4.1.1 Test full workflow:
- Create history with datasets
- Create notebook for history via API
- Open notebook view
- Insert HID references via toolbox
- Verify emitted format is
hid=N - Save notebook
- Reload - content persists with HIDs
- Preview renders correctly (HID resolved to actual data)
4.1.2 Test edge cases:
- Create notebook for empty history
- Reference deleted dataset (should show error on render)
- Reference collection vs dataset
- Large documents with many HID references
4.1.3 Permission testing:
- Can't create notebook for history you don't own
- Can view notebook for shared history
- Can't edit notebook for history you can only view
At this point, users can:
- Create a notebook for any history they own
- Write Galaxy markdown with HID references (
hid=42) - Insert references via toolbox (scoped to current history)
- Save revisions (each save creates new revision)
- View rendered notebook with resolved HID content
- Access notebook via history panel dropdown menu
These phases can proceed independently after MVP is complete.
Dependency: MVP complete
Files to modify:
client/src/components/HistoryNotebook/HistoryNotebookView.vue
Tasks:
5.1.1 Conditionally hide toolbar in display mode:
<div v-if="!displayOnly" class="notebook-toolbar ...">
<!-- full toolbar -->
</div>5.1.2 Add minimal toolbar for windowed mode
Files to modify:
client/src/components/History/HistoryOptions.vue
Tasks:
5.2.1 Add "Open Notebook in Window" option:
<BDropdownItem
@click="openNotebookWindowed">
<FontAwesomeIcon :icon="faWindowRestore" />
Open Notebook in Window
</BDropdownItem>function openNotebookWindowed() {
router.push(`/histories/${history.id}/notebook`, {
title: `Notebook: ${history.name}`,
});
}Dependency: MVP complete
Files to create:
client/src/components/HistoryNotebook/NotebookRevisionList.vueclient/src/components/HistoryNotebook/NotebookRevisionView.vueclient/src/components/Grid/configs/notebookRevisions.ts
// client/src/components/Grid/configs/notebookRevisions.ts
export const notebookRevisionFields = [
{ key: "create_time", title: "Date", type: "date" },
{ key: "title", title: "Title", type: "text" },
{ key: "edit_source", title: "Source", type: "text" },
{ key: "operations", title: "", type: "operations" },
];- Add "Revisions" tab or panel to notebook view
- Show revision count badge
- Add "Restore" action to revision list
Dependency: MVP complete, Phase 3 (HID Toolbox)
Files to modify:
client/src/components/History/Content/ContentItem.vue
Add notebook-specific drag data:
function handleDragStart(event, item) {
event.dataTransfer.setData("application/x-galaxy-hid", String(item.hid));
event.dataTransfer.setData("application/x-galaxy-item-type", item.history_content_type);
}Files to modify:
client/src/components/Markdown/Editor/TextEditor.vue
Add drop handling for history_notebook mode:
function handleDrop(event) {
if (props.mode !== "history_notebook") return;
const hid = event.dataTransfer.getData("application/x-galaxy-hid");
const itemType = event.dataTransfer.getData("application/x-galaxy-item-type");
if (hid) {
const directive = itemType === "dataset_collection"
? "history_dataset_collection_display"
: "history_dataset_display";
insertAtCursor(`\`\`\`galaxy\n${directive}(hid=${hid})\n\`\`\``);
}
}Dependency: MVP complete
Files to modify:
lib/galaxy/managers/history_notebooks.py
Add method:
def extract_to_page(
self,
trans: ProvidesUserContext,
notebook: model.HistoryNotebook,
title: str,
) -> model.Page:
"""Create a Page from notebook, resolving all HIDs."""
history = notebook.history
content = notebook.latest_revision.content
# Resolve HIDs to internal IDs
resolved = resolve_history_markdown(trans, history, content)
# Encode for Page storage
encoded = ready_galaxy_markdown_for_export(trans, resolved)
# Create Page
page = model.Page(user=trans.user, title=title, slug=slugify(title))
revision = model.PageRevision(
page=page,
content=encoded,
content_format="markdown",
)
page.latest_revision = revision
trans.sa_session.add(page)
trans.sa_session.commit()
return pageAdd to lib/galaxy/webapps/galaxy/api/history_notebooks.py:
@router.post(
"/api/histories/{history_id}/notebook/extract-to-page",
summary="Extract notebook to a Page.",
)
def extract_to_page(
self,
history_id: HistoryIdPathParam,
trans: ProvidesUserContext = DependsOnTrans,
payload: ExtractToPagePayload = Body(...),
) -> PageSummary:
# ... implementation- Add "Export to Page" button in notebook toolbar
- Title input modal
- Error handling for unresolved HIDs
- Success: navigate to new Page
Dependency: Phase 8, workflow extraction understanding
During workflow extraction from history, build mapping:
def build_hid_output_map(history, extracted_steps) -> dict[int, str]:
"""Map HIDs to workflow output labels."""
hid_map = {}
for step in extracted_steps:
for output in step.outputs:
if hasattr(output, 'hid'):
hid_map[output.hid] = output.label or f"{step.tool_id}_{output.name}"
return hid_mapdef transform_notebook_to_report(content: str, hid_map: dict) -> str:
"""Transform hid=N to output="label" for workflow report."""
def replace_hid(match):
hid = int(match.group(1))
if hid not in hid_map:
raise ValueError(f"HID {hid} not in workflow outputs")
return f'output="{hid_map[hid]}"'
return HID_PATTERN.sub(replace_hid, content)- Option in workflow extraction: "Include notebook as report"
- Preview transformed report
- Warning for unmapped HIDs
Dependency: Chat API branch merged, MVP complete
Files to create:
client/src/components/HistoryNotebook/HistoryNotebookSplit.vue
Files to create:
client/src/components/HistoryNotebook/ChatPanel.vueclient/src/components/HistoryNotebook/ChatMessage.vue
- Display proposed changes with diff preview
- "Apply" saves with
edit_source='agent' - "Reject" discards change
- Auto-save user changes before agent edit
| Component | Location | Coverage |
|---|---|---|
| HistoryNotebook model | test/unit/data/model/ |
CRUD, constraints |
| HistoryNotebookRevision | test/unit/data/model/ |
Creation, linking |
| resolve_history_markdown() | test/unit/managers/ |
All item types, errors |
| API endpoints | test/integration/ |
All methods, permissions |
| historyNotebookStore | client/src/stores/__tests__/ |
State transitions |
| HID toolbox emission | client/src/components/Markdown/__tests__/ |
Format verification |
| Scenario | Test File |
|---|---|
| Create and edit notebook | test/integration/test_history_notebooks.py |
| HID resolution pipeline | test/integration/test_history_markdown.py |
| Page extraction | test/integration/test_notebook_extraction.py |
| Flow | Description |
|---|---|
| New notebook | Create history → Create notebook → Verify empty state |
| Insert dataset | Open toolbox → Select dataset → Verify hid= format |
| Save and reload | Edit → Save → Reload → Verify persistence |
| Export to Page | Create notebook → Export → Verify Page created |
| File | Purpose |
|---|---|
lib/galaxy/managers/history_notebooks.py |
Manager layer |
lib/galaxy/webapps/galaxy/api/history_notebooks.py |
API endpoints |
lib/galaxy/model/migrations/alembic/versions/XXX_add_history_notebook.py |
DB migration |
| File | Change |
|---|---|
lib/galaxy/model/__init__.py |
Add HistoryNotebook, HistoryNotebookRevision models |
lib/galaxy/schema/schema.py |
Add Pydantic schemas |
lib/galaxy/managers/markdown_parse.py |
Add hid to VALID_ARGUMENTS |
lib/galaxy/managers/markdown_util.py |
Add resolve_history_markdown() |
lib/galaxy/webapps/galaxy/api/__init__.py |
Register router |
| File | Purpose |
|---|---|
client/src/api/historyNotebooks.ts |
API client (list + CRUD) |
client/src/stores/historyNotebookStore.ts |
State management (list + current) |
client/src/components/HistoryNotebook/HistoryNotebookView.vue |
Main view container |
client/src/components/HistoryNotebook/HistoryNotebookList.vue |
Notebook list view |
client/src/components/HistoryNotebook/HistoryNotebookEditor.vue |
Editor wrapper |
| File | Change |
|---|---|
client/src/entry/analysis/router.js |
Add notebook list + detail routes |
client/src/components/History/HistoryOptions.vue |
Add entry point (links to list) |
client/src/components/Markdown/MarkdownEditor.vue |
Add history_notebook mode |
client/src/components/Markdown/MarkdownToolBox.vue |
Add mode detection, HID emission |
client/src/components/Markdown/MarkdownDialog.vue |
Emit hid= format |
client/src/components/Markdown/directives.ts |
Add history_notebook mode type |
| Question | Decision |
|---|---|
| Notebooks per history | Multiple allowed - no unique constraint on history_id, list view shows all notebooks |
| Notebook title | Default to history name, allow user override via UI |
| Notebook deletion | Soft-delete with deleted/purged flags (standard Galaxy pattern). Notebooks not cascade-deleted when history is deleted. |
| HIDs outside history | Items from previous workflow steps outside history become workflow inputs on extraction |
| Content size limit | None - Pages use TEXT with no limit, notebooks follow same pattern |
| Concurrent editing | Not a concern - histories are user-scoped (same as Pages/Reports) |
| Search/indexing | Out of scope for this plan |
- Preview refresh? Auto-refresh preview on content change or manual button?