feat: add dual-mode tree editor with Code Mode, variables, and markdown sync

Implements the full dual-mode tree editor (Plan Phases 1-5):

Backend:
- JSONB↔Markdown bidirectional serializer/parser with mistune
- Markdown validator with line/column error reporting
- 3 API endpoints: export-markdown, import-markdown, validate-markdown
- Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS])
- Session variables JSONB column (migration 028)
- 39 tree markdown tests + variable service tests (403 total passing)

Frontend:
- Monaco-based Code Mode with custom Monarch tokenizer and dark theme
- Autocomplete for @node_id refs, type values, variable names
- Debounced validation (800ms) with inline Monaco error markers
- Syntax help panel (absolute overlay, toggleable)
- Starter template for new trees with valid cross-references
- Bidirectional metadata sync (name/description/category/tags frontmatter)
- Synchronous tree→markdown serializer (fixes async race condition)
- Pre-save validation blocks save on broken refs or missing tree name
- Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode
- Variable prompt modal and frontend resolver for session navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-10 09:45:26 -05:00
parent 2bd47004e7
commit eac6e184ec
32 changed files with 3369 additions and 52 deletions

View File

@@ -0,0 +1,37 @@
"""add session_variables JSONB column to sessions
Revision ID: 028
Revises: 027
Create Date: 2026-02-09
Adds session_variables JSONB column for storing variable values
collected during tree navigation (e.g., hostname, ticket_number).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '028'
down_revision: Union[str, None] = '027'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'sessions',
sa.Column(
'session_variables',
postgresql.JSONB(),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
)
)
def downgrade() -> None:
op.drop_column('sessions', 'session_variables')

View File

@@ -295,6 +295,12 @@ async def export_session(
content = generate_text_export(session, export_options)
media_type = "text/plain"
# Resolve variables in export output
session_vars = getattr(session, 'session_variables', None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Mark as exported
session.exported = True
await db.commit()

View File

@@ -0,0 +1,132 @@
"""API endpoints for tree markdown import/export."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.tree import Tree
from app.models.user import User
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_access_tree, can_edit_tree
from app.schemas.tree_markdown import (
TreeMarkdownExportResponse,
TreeMarkdownImportRequest,
TreeMarkdownValidationResponse,
MarkdownValidationError,
)
from app.services.tree_markdown_service import serialize_tree_to_markdown
from app.services.tree_markdown_parser import parse_markdown_to_tree
from app.services.tree_markdown_validator import validate_tree_markdown
router = APIRouter(prefix="/trees", tags=["tree-markdown"])
@router.get("/{tree_id}/export-markdown", response_model=TreeMarkdownExportResponse)
async def export_tree_markdown(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Export a tree's JSONB structure as ResolutionFlow markdown."""
result = await db.execute(
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
metadata = {
"name": tree.name or "",
"description": tree.description or "",
"category": tree.category or "",
}
markdown = serialize_tree_to_markdown(tree.tree_structure, metadata=metadata)
return TreeMarkdownExportResponse(markdown=markdown)
@router.put("/{tree_id}/import-markdown", response_model=TreeMarkdownValidationResponse)
async def import_tree_markdown(
tree_id: UUID,
body: TreeMarkdownImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)],
):
"""Parse markdown and update a tree's JSONB structure."""
result = await db.execute(
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_edit_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
parse_result = parse_markdown_to_tree(body.markdown)
has_errors = any(e.severity == "error" for e in parse_result.errors)
if has_errors:
return TreeMarkdownValidationResponse(
valid=False,
errors=[
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
for e in parse_result.errors
],
tree_structure=None,
)
# Apply the parsed tree structure
tree.tree_structure = parse_result.tree_structure
await db.commit()
return TreeMarkdownValidationResponse(
valid=True,
errors=[
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
for e in parse_result.errors
],
tree_structure=parse_result.tree_structure,
)
@router.post("/validate-markdown", response_model=TreeMarkdownValidationResponse)
async def validate_markdown(
body: TreeMarkdownImportRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Validate markdown without saving. Returns errors and preview JSONB."""
parse_result = parse_markdown_to_tree(body.markdown)
all_errors = validate_tree_markdown(body.markdown)
# Deduplicate errors by message
seen: set[str] = set()
unique_errors: list[MarkdownValidationError] = []
for e in all_errors:
key = f"{e.line}:{e.column}:{e.message}"
if key not in seen:
seen.add(key)
unique_errors.append(
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
)
has_hard_errors = any(e.severity == "error" for e in unique_errors)
return TreeMarkdownValidationResponse(
valid=not has_hard_errors,
errors=unique_errors,
tree_structure=parse_result.tree_structure,
metadata=parse_result.metadata,
)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -24,3 +24,4 @@ api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)

View File

@@ -48,6 +48,9 @@ class Session(Base):
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)
session_variables: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
)
scratchpad: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, server_default=sa.text("''")
)

View File

@@ -44,6 +44,7 @@ class SessionUpdate(BaseModel):
ticket_number: Optional[str] = Field(None, max_length=100)
client_name: Optional[str] = Field(None, max_length=255)
scratchpad: Optional[str] = None
session_variables: Optional[dict[str, str]] = None
class SessionResponse(BaseModel):
@@ -60,6 +61,7 @@ class SessionResponse(BaseModel):
client_name: Optional[str] = None
exported: bool
scratchpad: str = ""
session_variables: dict[str, str] = Field(default_factory=dict)
@validator('scratchpad', pre=True, always=True)
def normalize_scratchpad(cls, v):

View File

@@ -0,0 +1,28 @@
"""Pydantic schemas for tree markdown import/export."""
from pydantic import BaseModel
class TreeMarkdownExportResponse(BaseModel):
"""Response for markdown export endpoint."""
markdown: str
class TreeMarkdownImportRequest(BaseModel):
"""Request body for markdown import endpoint."""
markdown: str
class MarkdownValidationError(BaseModel):
"""A single validation error with location info."""
line: int
column: int
message: str
severity: str # 'error' or 'warning'
class TreeMarkdownValidationResponse(BaseModel):
"""Response for markdown validation endpoint."""
valid: bool
errors: list[MarkdownValidationError]
tree_structure: dict | None = None
metadata: dict | None = None

View File

@@ -0,0 +1,491 @@
"""
Markdown → JSONB parser for ResolutionFlow tree structures.
Parses ResolutionFlow Markdown format (frontmatter-delimited node blocks)
back into the recursive tree_structure JSONB dict.
"""
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ParseError:
"""A validation/parse error with location info."""
line: int
column: int
message: str
severity: str = "error" # 'error' or 'warning'
@dataclass
class ParseResult:
"""Result of parsing markdown into a tree structure."""
tree_structure: dict[str, Any] | None
errors: list[ParseError] = field(default_factory=list)
metadata: dict[str, Any] | None = None
# Regex patterns
FRONTMATTER_RE = re.compile(r"^---\s*$", re.MULTILINE)
OPTION_RE = re.compile(
r"^-\s*\[([A-Za-z0-9]+)\]\s*(.+?)(?:\s*→\s*@(\S+))?\s*$"
)
NEXT_NODE_RE = re.compile(r"^→\s*@(\S+)\s*$")
EXPECTED_RE = re.compile(r"^\*\*Expected:\*\*\s*(.+)$")
HEADING1_RE = re.compile(r"^#\s+(.+)$")
HEADING2_RE = re.compile(r"^##\s+(.+)$")
BLOCKQUOTE_RE = re.compile(r"^>\s*(.*)$")
ORDERED_LIST_RE = re.compile(r"^\d+\.\s+(.+)$")
COMMAND_BLOCK_START = re.compile(r"^```commands\s*$")
COMMAND_BLOCK_END = re.compile(r"^```\s*$")
def parse_markdown_to_tree(markdown: str) -> ParseResult:
"""Parse ResolutionFlow markdown into a tree structure JSONB dict.
Args:
markdown: The markdown string to parse.
Returns:
ParseResult with tree_structure, errors, and optional metadata.
"""
errors: list[ParseError] = []
raw_blocks = _split_into_blocks(markdown)
if not raw_blocks:
errors.append(ParseError(line=1, column=1, message="No node blocks found"))
return ParseResult(tree_structure=None, errors=errors)
# Check if the first block is a metadata block (has 'name' but no 'id'/'type')
metadata = None
node_blocks = raw_blocks
first_block_text, _ = raw_blocks[0]
meta = _try_parse_metadata_block(first_block_text)
if meta is not None:
metadata = meta
node_blocks = raw_blocks[1:]
if not node_blocks:
errors.append(ParseError(line=1, column=1, message="No node blocks found (only metadata)"))
return ParseResult(tree_structure=None, errors=errors, metadata=metadata)
# Parse each block into a flat node dict
flat_nodes: list[dict[str, Any]] = []
for block_text, start_line in node_blocks:
node, block_errors = _parse_block(block_text, start_line)
errors.extend(block_errors)
if node:
flat_nodes.append(node)
if not flat_nodes:
errors.append(ParseError(line=1, column=1, message="No valid nodes parsed"))
return ParseResult(tree_structure=None, errors=errors)
# Check for duplicate IDs
seen_ids: dict[str, int] = {}
for node in flat_nodes:
nid = node.get("id", "")
if nid in seen_ids:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Duplicate node ID: '{nid}'"
))
else:
seen_ids[nid] = node.get("_start_line", 1)
# Reconstruct recursive tree from flat nodes
tree, reconstruct_errors = _reconstruct_tree(flat_nodes)
errors.extend(reconstruct_errors)
return ParseResult(tree_structure=tree, errors=errors, metadata=metadata)
def _try_parse_metadata_block(block_text: str) -> dict[str, Any] | None:
"""Try to parse a block as tree metadata (name, description, category, tags).
Returns metadata dict if the block contains 'name' but no 'id'/'type'.
Returns None if it's a regular node block.
"""
lines = block_text.split("\n")
fm_start = None
fm_end = None
for i, line in enumerate(lines):
if line.strip() == "---":
if fm_start is None:
fm_start = i
else:
fm_end = i
break
if fm_start is None or fm_end is None:
return None
fm_data: dict[str, str] = {}
for i in range(fm_start + 1, fm_end):
line = lines[i].strip()
if not line:
continue
if ":" in line:
key, _, value = line.partition(":")
fm_data[key.strip()] = value.strip()
# It's a metadata block if it has 'name' but no 'id' and no 'type'
if "name" in fm_data and "id" not in fm_data and "type" not in fm_data:
metadata: dict[str, Any] = {"name": fm_data["name"]}
if "description" in fm_data:
metadata["description"] = fm_data["description"]
if "category" in fm_data:
metadata["category"] = fm_data["category"]
if "tags" in fm_data:
tags_str = fm_data["tags"].strip("[]")
metadata["tags"] = [t.strip() for t in tags_str.split(",") if t.strip()]
return metadata
return None
def _split_into_blocks(markdown: str) -> list[tuple[str, int]]:
"""Split markdown into blocks delimited by --- frontmatter markers.
Returns list of (block_text, start_line_number) tuples.
"""
lines = markdown.split("\n")
blocks: list[tuple[str, int]] = []
# Find frontmatter boundaries (--- on its own line)
fm_lines: list[int] = []
for i, line in enumerate(lines):
if line.strip() == "---":
fm_lines.append(i)
# Pair up frontmatter markers: each block starts at a `---` and the
# frontmatter ends at the next `---`. The body follows until the
# next block's first `---` (or end of file).
i = 0
while i < len(fm_lines) - 1:
start = fm_lines[i]
end_fm = fm_lines[i + 1]
# Find the next block start (or EOF)
next_block_start = len(lines)
if i + 2 < len(fm_lines):
next_block_start = fm_lines[i + 2]
block_lines = lines[start:next_block_start]
block_text = "\n".join(block_lines)
blocks.append((block_text, start + 1)) # 1-indexed line number
i += 2 # Jump to next frontmatter pair
return blocks
def _parse_block(block_text: str, start_line: int) -> tuple[dict[str, Any] | None, list[ParseError]]:
"""Parse a single frontmatter+body block into a node dict."""
errors: list[ParseError] = []
lines = block_text.split("\n")
# Extract frontmatter (between first and second ---)
fm_start = None
fm_end = None
for i, line in enumerate(lines):
if line.strip() == "---":
if fm_start is None:
fm_start = i
else:
fm_end = i
break
if fm_start is None or fm_end is None:
errors.append(ParseError(
line=start_line, column=1,
message="Block missing valid frontmatter delimiters"
))
return None, errors
# Parse YAML-like frontmatter (simple key: value)
fm_data: dict[str, str] = {}
for i in range(fm_start + 1, fm_end):
line = lines[i].strip()
if not line:
continue
if ":" in line:
key, _, value = line.partition(":")
fm_data[key.strip()] = value.strip()
node_id = fm_data.get("id", "")
node_type = fm_data.get("type", "")
parent_id = fm_data.get("parent")
if not node_id:
errors.append(ParseError(
line=start_line, column=1,
message="Node block missing 'id' in frontmatter"
))
return None, errors
if node_type not in ("decision", "action", "solution"):
errors.append(ParseError(
line=start_line, column=1,
message=f"Invalid node type: '{node_type}' (must be decision, action, or solution)"
))
return None, errors
# Parse body (everything after frontmatter)
body_lines = lines[fm_end + 1:]
body_text = "\n".join(body_lines)
node: dict[str, Any] = {
"id": node_id,
"type": node_type,
"_parent_id": parent_id,
"_start_line": start_line,
}
if node_type == "decision":
_parse_decision_body(body_lines, node, start_line + fm_end + 1, errors)
elif node_type == "action":
_parse_action_body(body_lines, node, start_line + fm_end + 1, errors)
elif node_type == "solution":
_parse_solution_body(body_lines, node, start_line + fm_end + 1, errors)
return node, errors
def _parse_decision_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of a decision node."""
question = ""
help_text_lines: list[str] = []
options: list[dict[str, Any]] = []
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# Check for heading (question)
m = HEADING1_RE.match(stripped)
if m:
question = m.group(1).strip()
continue
# Check for blockquote (help_text)
m = BLOCKQUOTE_RE.match(stripped)
if m:
help_text_lines.append(m.group(1))
continue
# Check for option
m = OPTION_RE.match(stripped)
if m:
opt_label = m.group(2).strip()
opt_next = m.group(3) or ""
options.append({
"id": f"opt_{node['id']}_{len(options)}",
"label": opt_label,
"next_node_id": opt_next,
})
continue
node["question"] = question
node["help_text"] = "\n".join(help_text_lines) if help_text_lines else ""
node["options"] = options
node["children"] = []
def _parse_action_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of an action node."""
title = ""
description_lines: list[str] = []
commands: list[str] = []
expected_outcome = ""
next_node_id = ""
in_command_block = False
for i, line in enumerate(lines):
stripped = line.strip()
# Command block handling
if in_command_block:
if COMMAND_BLOCK_END.match(stripped):
in_command_block = False
else:
commands.append(line.rstrip())
continue
if COMMAND_BLOCK_START.match(stripped):
in_command_block = True
continue
if not stripped:
# Blank lines are part of description
if title and not expected_outcome and not next_node_id:
description_lines.append("")
continue
# Title
m = HEADING2_RE.match(stripped)
if m:
title = m.group(1).strip()
continue
# Expected outcome
m = EXPECTED_RE.match(stripped)
if m:
expected_outcome = m.group(1).strip()
continue
# Next node reference
m = NEXT_NODE_RE.match(stripped)
if m:
next_node_id = m.group(1).strip()
continue
# Everything else is description
description_lines.append(stripped)
# Trim leading and trailing empty lines from description
while description_lines and not description_lines[-1].strip():
description_lines.pop()
while description_lines and not description_lines[0].strip():
description_lines.pop(0)
node["title"] = title
node["description"] = "\n".join(description_lines)
node["commands"] = commands if commands else []
node["expected_outcome"] = expected_outcome
node["next_node_id"] = next_node_id
node["children"] = []
def _parse_solution_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of a solution node."""
title = ""
description_lines: list[str] = []
resolution_steps: list[str] = []
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
if title:
description_lines.append("")
continue
# Title
m = HEADING2_RE.match(stripped)
if m:
title = m.group(1).strip()
continue
# Ordered list item (resolution step)
m = ORDERED_LIST_RE.match(stripped)
if m:
resolution_steps.append(m.group(1).strip())
continue
# Everything else is description
description_lines.append(stripped)
# Trim leading and trailing empty lines
while description_lines and not description_lines[-1].strip():
description_lines.pop()
while description_lines and not description_lines[0].strip():
description_lines.pop(0)
node["title"] = title
node["description"] = "\n".join(description_lines)
node["resolution_steps"] = resolution_steps
node["solution"] = title # solution field required for publishing
def _reconstruct_tree(flat_nodes: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[ParseError]]:
"""Reconstruct a recursive tree from flat nodes using parent references.
Returns (tree_structure, errors).
"""
errors: list[ParseError] = []
if not flat_nodes:
return None, errors
# Build lookup
node_map: dict[str, dict[str, Any]] = {}
for node in flat_nodes:
nid = node["id"]
# Clean node (remove internal fields)
clean = {k: v for k, v in node.items() if not k.startswith("_")}
if "children" not in clean:
clean["children"] = []
node_map[nid] = clean
# Find root (node with no parent)
root_id = None
for node in flat_nodes:
if node.get("_parent_id") is None:
if root_id is not None:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Multiple root nodes found: '{root_id}' and '{node['id']}'",
))
root_id = node["id"]
if root_id is None:
# Fall back to first node
root_id = flat_nodes[0]["id"]
errors.append(ParseError(
line=1, column=1,
message="No root node found (no node without a parent). Using first node as root.",
severity="warning"
))
# Build children relationships
for node in flat_nodes:
parent_id = node.get("_parent_id")
if parent_id and parent_id in node_map:
child = node_map[node["id"]]
node_map[parent_id]["children"].append(child)
elif parent_id and parent_id not in node_map:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Node '{node['id']}' references non-existent parent '{parent_id}'"
))
# Validate option references
for nid, node in node_map.items():
if node.get("type") == "decision":
for opt in node.get("options", []):
ref = opt.get("next_node_id", "")
if ref and ref not in node_map:
errors.append(ParseError(
line=1, column=1,
message=f"Option '{opt.get('label', '')}' in node '{nid}' references non-existent node '@{ref}'"
))
elif node.get("type") == "action":
ref = node.get("next_node_id", "")
if ref and ref not in node_map:
errors.append(ParseError(
line=1, column=1,
message=f"Action node '{nid}' references non-existent next node '@{ref}'"
))
root = node_map.get(root_id)
return root, errors

View File

@@ -0,0 +1,157 @@
"""
JSONB → Markdown serializer for ResolutionFlow tree structures.
Converts a tree_structure JSONB dict into the ResolutionFlow Markdown format
where each node is a block delimited by YAML frontmatter.
"""
from typing import Any
def serialize_tree_to_markdown(
tree_structure: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> str:
"""Convert a tree structure JSONB dict to ResolutionFlow Markdown format.
Args:
tree_structure: The recursive tree_structure dict from the database.
metadata: Optional tree metadata (name, description, category, tags)
to include as a frontmatter block at the top.
Returns:
Markdown string with YAML frontmatter blocks for each node.
"""
blocks: list[str] = []
if metadata:
meta_lines = ["---"]
if metadata.get("name"):
meta_lines.append(f"name: {metadata['name']}")
if metadata.get("description"):
meta_lines.append(f"description: {metadata['description']}")
if metadata.get("category"):
meta_lines.append(f"category: {metadata['category']}")
tags = metadata.get("tags")
if tags:
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
meta_lines.append(f"tags: [{tags_str}]")
meta_lines.append("---")
blocks.append("\n".join(meta_lines))
_serialize_node(tree_structure, parent_id=None, blocks=blocks)
return "\n\n".join(blocks) + "\n"
def _serialize_node(
node: dict[str, Any],
parent_id: str | None,
blocks: list[str],
) -> None:
"""Recursively serialize a single node and its children."""
node_type = node.get("type", "decision")
node_id = node.get("id", "unknown")
# Build frontmatter
frontmatter_lines = [
"---",
f"id: {node_id}",
f"type: {node_type}",
]
if parent_id is not None:
frontmatter_lines.append(f"parent: {parent_id}")
frontmatter_lines.append("---")
# Build body based on node type
body_lines: list[str] = []
if node_type == "decision":
_serialize_decision(node, body_lines)
elif node_type == "action":
_serialize_action(node, body_lines)
elif node_type == "solution":
_serialize_solution(node, body_lines)
block = "\n".join(frontmatter_lines) + "\n" + "\n".join(body_lines)
blocks.append(block)
# Recurse into children
children = node.get("children", [])
if children:
for child in children:
_serialize_node(child, parent_id=node_id, blocks=blocks)
def _serialize_decision(node: dict[str, Any], lines: list[str]) -> None:
"""Serialize a decision node body."""
question = node.get("question", "")
if question:
lines.append(f"# {question}")
lines.append("")
help_text = node.get("help_text", "")
if help_text:
for ht_line in help_text.split("\n"):
lines.append(f"> {ht_line}")
lines.append("")
options = node.get("options", [])
for i, opt in enumerate(options):
label = opt.get("label", "")
next_id = opt.get("next_node_id", "")
letter = chr(ord("A") + i) if i < 26 else str(i + 1)
if next_id:
lines.append(f"- [{letter}] {label} → @{next_id}")
else:
lines.append(f"- [{letter}] {label}")
def _serialize_action(node: dict[str, Any], lines: list[str]) -> None:
"""Serialize an action node body."""
title = node.get("title", "")
if title:
lines.append(f"## {title}")
lines.append("")
description = node.get("description", "")
if description:
lines.append(description)
lines.append("")
commands = node.get("commands", [])
if commands:
lines.append("```commands")
for cmd in commands:
lines.append(cmd)
lines.append("```")
lines.append("")
expected = node.get("expected_outcome", "")
if expected:
lines.append(f"**Expected:** {expected}")
lines.append("")
next_id = node.get("next_node_id", "")
if next_id:
lines.append(f"→ @{next_id}")
def _serialize_solution(node: dict[str, Any], lines: list[str]) -> None:
"""Serialize a solution node body."""
title = node.get("title", "")
if title:
lines.append(f"## {title}")
lines.append("")
description = node.get("description", "")
if description:
lines.append(description)
lines.append("")
steps = node.get("resolution_steps", [])
if steps:
for i, step in enumerate(steps, 1):
lines.append(f"{i}. {step}")
solution = node.get("solution", "")
if solution and not description and not steps:
lines.append(solution)

View File

@@ -0,0 +1,93 @@
"""
Validation for ResolutionFlow tree markdown.
Validates markdown without saving, returning detailed errors with line numbers.
"""
from app.services.tree_markdown_parser import parse_markdown_to_tree, ParseError
def validate_tree_markdown(markdown: str) -> list[ParseError]:
"""Validate tree markdown and return all errors/warnings.
This wraps the parser and adds additional semantic checks.
Args:
markdown: The markdown string to validate.
Returns:
List of ParseError objects with line, column, message, severity.
"""
result = parse_markdown_to_tree(markdown)
errors = list(result.errors)
# If parsing completely failed, return early
if result.tree_structure is None:
return errors
# Additional semantic validation on the parsed tree
_validate_tree_semantics(result.tree_structure, errors)
return errors
def _validate_tree_semantics(tree: dict, errors: list[ParseError]) -> None:
"""Run semantic checks on a parsed tree structure."""
all_ids: set[str] = set()
has_solution = False
def _collect_ids(node: dict) -> None:
nonlocal has_solution
all_ids.add(node.get("id", ""))
if node.get("type") == "solution":
has_solution = True
for child in node.get("children", []):
_collect_ids(child)
_collect_ids(tree)
if not has_solution:
errors.append(ParseError(
line=1, column=1,
message="Tree must have at least one solution node",
severity="warning"
))
# Check for empty required fields
def _validate_node(node: dict) -> None:
ntype = node.get("type", "")
nid = node.get("id", "")
if ntype == "decision":
if not node.get("question", "").strip():
errors.append(ParseError(
line=1, column=1,
message=f"Decision node '{nid}' has an empty question",
severity="warning"
))
if not node.get("options"):
errors.append(ParseError(
line=1, column=1,
message=f"Decision node '{nid}' has no options",
severity="warning"
))
elif ntype == "action":
if not node.get("title", "").strip():
errors.append(ParseError(
line=1, column=1,
message=f"Action node '{nid}' has an empty title",
severity="warning"
))
elif ntype == "solution":
if not node.get("title", "").strip():
errors.append(ParseError(
line=1, column=1,
message=f"Solution node '{nid}' has an empty title",
severity="warning"
))
for child in node.get("children", []):
_validate_node(child)
_validate_node(tree)

View File

@@ -0,0 +1,134 @@
"""
Variable extraction and resolution for ResolutionFlow tree structures.
Supports three variable tokens:
- [USER_INPUT:prompt] — prompts user for input during session navigation
- [VAR:name] — references a previously saved variable value
- [SAVE_AS:name] — saves the current context as a named variable
"""
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class VariableDefinition:
"""A variable found in a tree structure."""
name: str
kind: str # 'user_input', 'reference', 'save_as'
prompt: str # For user_input: the prompt text; for others: empty
node_id: str # The node where this variable appears
# Regex patterns for variable tokens
USER_INPUT_RE = re.compile(r'\[USER_INPUT:([^\]]+)\]')
VAR_REF_RE = re.compile(r'\[VAR:([^\]]+)\]')
SAVE_AS_RE = re.compile(r'\[SAVE_AS:([^\]]+)\]')
def extract_variables(tree_structure: dict[str, Any]) -> list[VariableDefinition]:
"""Extract all variable definitions from a tree structure.
Traverses the tree recursively and finds all [USER_INPUT:...],
[VAR:...], and [SAVE_AS:...] tokens.
Args:
tree_structure: The recursive tree_structure dict.
Returns:
List of VariableDefinition objects found in the tree.
"""
variables: list[VariableDefinition] = []
_scan_node(tree_structure, variables)
return variables
def _scan_node(node: dict[str, Any], variables: list[VariableDefinition]) -> None:
"""Scan a node and its children for variable tokens."""
node_id = node.get("id", "unknown")
# Collect all text fields to scan
text_fields = [
node.get("question", ""),
node.get("title", ""),
node.get("description", ""),
node.get("help_text", ""),
node.get("expected_outcome", ""),
node.get("solution", ""),
]
# Include option labels
for opt in node.get("options", []):
text_fields.append(opt.get("label", ""))
# Include resolution steps
for step in node.get("resolution_steps", []):
text_fields.append(step)
# Include commands
for cmd in node.get("commands", []):
text_fields.append(cmd)
# Scan all text fields
for text in text_fields:
if not text:
continue
for match in USER_INPUT_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="user_input",
prompt=match.group(1).strip(),
node_id=node_id,
))
for match in VAR_REF_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="reference",
prompt="",
node_id=node_id,
))
for match in SAVE_AS_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="save_as",
prompt="",
node_id=node_id,
))
# Recurse into children
for child in node.get("children", []):
_scan_node(child, variables)
def resolve_variables(text: str, variables: dict[str, str]) -> str:
"""Replace variable tokens in text with their resolved values.
- [VAR:name] → replaced with variables[name] if present
- [USER_INPUT:prompt] → replaced with variables[prompt] if present
- [SAVE_AS:name] → removed (save directives don't appear in output)
Args:
text: The text containing variable tokens.
variables: Dict mapping variable names to their values.
Returns:
Text with variable tokens replaced.
"""
def _replace_var(match: re.Match) -> str:
name = match.group(1).strip()
return variables.get(name, f"[VAR:{name}]")
def _replace_input(match: re.Match) -> str:
prompt = match.group(1).strip()
return variables.get(prompt, f"[USER_INPUT:{prompt}]")
def _replace_save(match: re.Match) -> str:
return "" # Save directives are removed from output
result = VAR_REF_RE.sub(_replace_var, text)
result = USER_INPUT_RE.sub(_replace_input, result)
result = SAVE_AS_RE.sub(_replace_save, result)
return result

View File

@@ -0,0 +1,595 @@
"""
Tests for tree markdown serialization, parsing, and round-trip fidelity.
"""
import pytest
from app.services.tree_markdown_service import serialize_tree_to_markdown
from app.services.tree_markdown_parser import parse_markdown_to_tree
from app.services.tree_markdown_validator import validate_tree_markdown
# --- Fixtures: Tree structures ---
SIMPLE_TREE = {
"id": "root",
"type": "decision",
"question": "Is this a test?",
"help_text": "Check if this is a test scenario",
"options": [
{"id": "yes", "label": "Yes", "next_node_id": "solution1"},
{"id": "no", "label": "No", "next_node_id": "solution2"},
],
"children": [
{
"id": "solution1",
"type": "solution",
"title": "Test Confirmed",
"description": "This is indeed a test.",
"resolution_steps": ["Step 1", "Step 2"],
"solution": "Test confirmed",
},
{
"id": "solution2",
"type": "solution",
"title": "Not a Test",
"description": "This is not a test.",
"solution": "Not a test",
},
],
}
ACTION_TREE = {
"id": "root",
"type": "decision",
"question": "What type of issue?",
"help_text": "Identify the primary issue",
"options": [
{"id": "opt_net", "label": "Network issue", "next_node_id": "check_net"},
],
"children": [
{
"id": "check_net",
"type": "action",
"title": "Run Network Diagnostics",
"description": "Check basic connectivity.",
"commands": ["ping 8.8.8.8", "tracert gateway.local"],
"expected_outcome": "Ping replies or timeout",
"next_node_id": "resolved",
"children": [
{
"id": "resolved",
"type": "solution",
"title": "Issue Resolved",
"description": "Network is working now.",
"solution": "Issue resolved",
}
],
}
],
}
DEEPLY_NESTED_TREE = {
"id": "root",
"type": "decision",
"question": "Level 1?",
"options": [
{"id": "o1", "label": "Go deeper", "next_node_id": "level2"},
],
"children": [
{
"id": "level2",
"type": "decision",
"question": "Level 2?",
"options": [
{"id": "o2", "label": "Go deeper", "next_node_id": "level3"},
],
"children": [
{
"id": "level3",
"type": "decision",
"question": "Level 3?",
"options": [
{"id": "o3", "label": "Done", "next_node_id": "final"},
],
"children": [
{
"id": "final",
"type": "solution",
"title": "Deep Solution",
"description": "Found it at level 3.",
"solution": "Deep solution found",
}
],
}
],
}
],
}
SINGLE_NODE_TREE = {
"id": "root",
"type": "solution",
"title": "Quick Fix",
"description": "Just restart the computer.",
"solution": "Restart the computer",
}
# --- Serialization Tests ---
class TestSerializeTreeToMarkdown:
def test_simple_tree(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
assert "id: root" in md
assert "type: decision" in md
assert "# Is this a test?" in md
assert "> Check if this is a test scenario" in md
assert "- [A] Yes → @solution1" in md
assert "- [B] No → @solution2" in md
assert "id: solution1" in md
assert "## Test Confirmed" in md
assert "1. Step 1" in md
assert "2. Step 2" in md
def test_action_tree(self):
md = serialize_tree_to_markdown(ACTION_TREE)
assert "## Run Network Diagnostics" in md
assert "```commands" in md
assert "ping 8.8.8.8" in md
assert "**Expected:** Ping replies or timeout" in md
assert "→ @resolved" in md
def test_deeply_nested(self):
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
assert "id: level2" in md
assert "parent: root" in md
assert "id: level3" in md
assert "parent: level2" in md
assert "id: final" in md
assert "parent: level3" in md
def test_single_node(self):
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
assert "id: root" in md
assert "type: solution" in md
assert "## Quick Fix" in md
def test_root_has_no_parent(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
lines = md.split("\n")
# Find the first frontmatter block
in_first_block = False
for line in lines:
if line.strip() == "---":
if not in_first_block:
in_first_block = True
continue
else:
break
if in_first_block:
assert not line.startswith("parent:"), "Root node should not have parent"
# --- Parsing Tests ---
class TestParseMarkdownToTree:
def test_simple_roundtrip(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
tree = result.tree_structure
assert tree["id"] == "root"
assert tree["type"] == "decision"
assert tree["question"] == "Is this a test?"
assert len(tree["options"]) == 2
assert tree["options"][0]["label"] == "Yes"
assert tree["options"][0]["next_node_id"] == "solution1"
assert len(tree["children"]) == 2
def test_action_roundtrip(self):
md = serialize_tree_to_markdown(ACTION_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
tree = result.tree_structure
# Find the action node
action = tree["children"][0]
assert action["type"] == "action"
assert action["title"] == "Run Network Diagnostics"
assert action["commands"] == ["ping 8.8.8.8", "tracert gateway.local"]
assert action["expected_outcome"] == "Ping replies or timeout"
assert action["next_node_id"] == "resolved"
# Action should have children
assert len(action["children"]) == 1
assert action["children"][0]["type"] == "solution"
def test_deeply_nested_roundtrip(self):
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
tree = result.tree_structure
assert tree["id"] == "root"
level2 = tree["children"][0]
assert level2["id"] == "level2"
level3 = level2["children"][0]
assert level3["id"] == "level3"
final = level3["children"][0]
assert final["id"] == "final"
assert final["type"] == "solution"
def test_single_node_roundtrip(self):
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
tree = result.tree_structure
assert tree["id"] == "root"
assert tree["type"] == "solution"
assert tree["title"] == "Quick Fix"
def test_empty_markdown(self):
result = parse_markdown_to_tree("")
assert result.tree_structure is None
assert len(result.errors) > 0
def test_malformed_frontmatter(self):
md = "---\nno_id_here: true\n---\n# Some question"
result = parse_markdown_to_tree(md)
assert any("missing 'id'" in e.message.lower() or "missing" in e.message.lower()
for e in result.errors)
def test_invalid_type(self):
md = "---\nid: root\ntype: invalid_type\n---\n# Question"
result = parse_markdown_to_tree(md)
assert any("invalid node type" in e.message.lower() for e in result.errors)
def test_duplicate_ids(self):
md = (
"---\nid: root\ntype: decision\n---\n# Q1\n- [A] Opt → @dup\n\n"
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol1\n\n"
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol2\n"
)
result = parse_markdown_to_tree(md)
assert any("duplicate" in e.message.lower() for e in result.errors)
def test_broken_reference(self):
md = (
"---\nid: root\ntype: decision\n---\n"
"# Which issue?\n"
"- [A] Network → @nonexistent\n"
)
result = parse_markdown_to_tree(md)
assert any("non-existent" in e.message.lower() for e in result.errors)
def test_orphaned_parent_reference(self):
md = (
"---\nid: root\ntype: decision\n---\n# Q\n- [A] Opt → @child\n\n"
"---\nid: child\ntype: solution\nparent: nonexistent_parent\n---\n## Sol\n"
)
result = parse_markdown_to_tree(md)
assert any("non-existent parent" in e.message.lower() for e in result.errors)
def test_resolution_steps_parsed(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
result = parse_markdown_to_tree(md)
tree = result.tree_structure
# solution1 should have resolution_steps
sol1 = tree["children"][0]
assert sol1["id"] == "solution1"
assert sol1["resolution_steps"] == ["Step 1", "Step 2"]
def test_help_text_preserved(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
result = parse_markdown_to_tree(md)
tree = result.tree_structure
assert tree["help_text"] == "Check if this is a test scenario"
def test_description_with_markdown(self):
"""Test that markdown content in descriptions is preserved."""
tree = {
"id": "root",
"type": "solution",
"title": "Complex Solution",
"description": "Use **bold** and *italic* and `code`.\n\nMultiple paragraphs too.",
"solution": "Complex solution",
}
md = serialize_tree_to_markdown(tree)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
assert "**bold**" in result.tree_structure["description"]
assert "`code`" in result.tree_structure["description"]
# --- Validation Tests ---
class TestValidateTreeMarkdown:
def test_valid_tree_no_errors(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
errors = validate_tree_markdown(md)
hard_errors = [e for e in errors if e.severity == "error"]
assert len(hard_errors) == 0
def test_empty_markdown_errors(self):
errors = validate_tree_markdown("")
assert len(errors) > 0
def test_missing_solution_warning(self):
tree = {
"id": "root",
"type": "decision",
"question": "Question?",
"options": [],
"children": [],
}
md = serialize_tree_to_markdown(tree)
errors = validate_tree_markdown(md)
warnings = [e for e in errors if e.severity == "warning"]
assert any("solution" in e.message.lower() for e in warnings)
def test_empty_question_warning(self):
tree = {
"id": "root",
"type": "decision",
"question": "",
"options": [],
"children": [],
}
md = serialize_tree_to_markdown(tree)
errors = validate_tree_markdown(md)
assert any("empty question" in e.message.lower() for e in errors)
# --- Metadata Tests ---
class TestMetadataBlocks:
def test_serialize_with_metadata(self):
metadata = {"name": "Test Tree", "description": "A test", "category": "Help Desk", "tags": ["dns", "network"]}
md = serialize_tree_to_markdown(SINGLE_NODE_TREE, metadata=metadata)
assert "name: Test Tree" in md
assert "description: A test" in md
assert "category: Help Desk" in md
assert "tags: [dns, network]" in md
# Node should still be present
assert "id: root" in md
def test_parse_with_metadata(self):
md = (
"---\nname: My Tree\ndescription: desc here\ncategory: Help Desk\ntags: [dns, vpn]\n---\n\n"
"---\nid: root\ntype: solution\n---\n## Quick Fix\n"
)
result = parse_markdown_to_tree(md)
assert result.metadata is not None
assert result.metadata["name"] == "My Tree"
assert result.metadata["description"] == "desc here"
assert result.metadata["category"] == "Help Desk"
assert result.metadata["tags"] == ["dns", "vpn"]
assert result.tree_structure is not None
assert result.tree_structure["id"] == "root"
def test_parse_without_metadata(self):
md = "---\nid: root\ntype: solution\n---\n## Quick Fix\n"
result = parse_markdown_to_tree(md)
assert result.metadata is None
assert result.tree_structure is not None
def test_metadata_roundtrip(self):
metadata = {"name": "Roundtrip Tree", "description": "Tests roundtrip", "tags": ["test"]}
md = serialize_tree_to_markdown(SIMPLE_TREE, metadata=metadata)
result = parse_markdown_to_tree(md)
assert result.metadata is not None
assert result.metadata["name"] == "Roundtrip Tree"
assert result.metadata["description"] == "Tests roundtrip"
assert result.metadata["tags"] == ["test"]
assert result.tree_structure is not None
assert result.tree_structure["id"] == "root"
def test_metadata_only_no_nodes(self):
md = "---\nname: Just Metadata\n---\n"
result = parse_markdown_to_tree(md)
assert result.metadata is not None
assert result.metadata["name"] == "Just Metadata"
assert result.tree_structure is None
assert any("no node blocks" in e.message.lower() for e in result.errors)
# --- Round-Trip Fidelity Tests ---
class TestRoundTripFidelity:
"""Ensure serialize → parse produces semantically identical trees."""
def _assert_node_equal(self, original: dict, parsed: dict, path: str = "root"):
"""Deep compare two node dicts, ignoring internal fields and option IDs."""
assert original["id"] == parsed["id"], f"{path}: id mismatch"
assert original["type"] == parsed["type"], f"{path}: type mismatch"
if original["type"] == "decision":
assert original.get("question", "") == parsed.get("question", ""), \
f"{path}: question mismatch"
# Compare option labels and next_node_ids (not auto-generated option IDs)
orig_opts = original.get("options", [])
parsed_opts = parsed.get("options", [])
assert len(orig_opts) == len(parsed_opts), \
f"{path}: options count mismatch ({len(orig_opts)} vs {len(parsed_opts)})"
for j, (oo, po) in enumerate(zip(orig_opts, parsed_opts)):
assert oo["label"] == po["label"], \
f"{path}.options[{j}]: label mismatch"
assert oo.get("next_node_id", "") == po.get("next_node_id", ""), \
f"{path}.options[{j}]: next_node_id mismatch"
elif original["type"] == "action":
assert original.get("title", "") == parsed.get("title", ""), \
f"{path}: title mismatch"
assert original.get("commands", []) == parsed.get("commands", []), \
f"{path}: commands mismatch"
assert original.get("expected_outcome", "") == parsed.get("expected_outcome", ""), \
f"{path}: expected_outcome mismatch"
assert original.get("next_node_id", "") == parsed.get("next_node_id", ""), \
f"{path}: next_node_id mismatch"
elif original["type"] == "solution":
assert original.get("title", "") == parsed.get("title", ""), \
f"{path}: title mismatch"
assert original.get("resolution_steps", []) == parsed.get("resolution_steps", []), \
f"{path}: resolution_steps mismatch"
# Compare children recursively
orig_children = original.get("children", [])
parsed_children = parsed.get("children", [])
assert len(orig_children) == len(parsed_children), \
f"{path}: children count mismatch ({len(orig_children)} vs {len(parsed_children)})"
for i, (oc, pc) in enumerate(zip(orig_children, parsed_children)):
self._assert_node_equal(oc, pc, f"{path}.children[{i}]")
def test_simple_tree_roundtrip(self):
md = serialize_tree_to_markdown(SIMPLE_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
self._assert_node_equal(SIMPLE_TREE, result.tree_structure)
def test_action_tree_roundtrip(self):
md = serialize_tree_to_markdown(ACTION_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
self._assert_node_equal(ACTION_TREE, result.tree_structure)
def test_deeply_nested_roundtrip(self):
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
result = parse_markdown_to_tree(md)
assert result.tree_structure is not None
self._assert_node_equal(DEEPLY_NESTED_TREE, result.tree_structure)
def test_double_roundtrip(self):
"""serialize → parse → serialize should produce same markdown."""
md1 = serialize_tree_to_markdown(SIMPLE_TREE)
result1 = parse_markdown_to_tree(md1)
assert result1.tree_structure is not None
md2 = serialize_tree_to_markdown(result1.tree_structure)
result2 = parse_markdown_to_tree(md2)
assert result2.tree_structure is not None
self._assert_node_equal(result1.tree_structure, result2.tree_structure)
# --- API Integration Tests ---
@pytest.mark.asyncio
class TestTreeMarkdownAPI:
async def test_export_markdown(self, client, auth_headers, test_tree):
tree_id = test_tree["id"]
response = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "markdown" in data
# Should include metadata block
assert "name:" in data["markdown"]
# Should include node content
assert "id: root" in data["markdown"]
assert "# Is this a test?" in data["markdown"]
async def test_export_not_found(self, client, auth_headers):
import uuid
fake_id = str(uuid.uuid4())
response = await client.get(
f"/api/v1/trees/{fake_id}/export-markdown",
headers=auth_headers,
)
assert response.status_code == 404
async def test_export_requires_auth(self, client, test_tree):
tree_id = test_tree["id"]
response = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
)
assert response.status_code == 401
async def test_import_markdown(self, client, auth_headers, test_tree):
tree_id = test_tree["id"]
# Export first
export_resp = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
headers=auth_headers,
)
markdown = export_resp.json()["markdown"]
# Import back
response = await client.put(
f"/api/v1/trees/{tree_id}/import-markdown",
json={"markdown": markdown},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert data["tree_structure"] is not None
async def test_import_invalid_markdown(self, client, auth_headers, test_tree):
tree_id = test_tree["id"]
response = await client.put(
f"/api/v1/trees/{tree_id}/import-markdown",
json={"markdown": "this is not valid tree markdown"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is False
assert len(data["errors"]) > 0
async def test_validate_markdown(self, client, auth_headers, test_tree):
tree_id = test_tree["id"]
# Export first
export_resp = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
headers=auth_headers,
)
markdown = export_resp.json()["markdown"]
# Validate
response = await client.post(
"/api/v1/trees/validate-markdown",
json={"markdown": markdown},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert data["tree_structure"] is not None
# Should return parsed metadata from export
assert data["metadata"] is not None
async def test_validate_requires_auth(self, client):
response = await client.post(
"/api/v1/trees/validate-markdown",
json={"markdown": "---\nid: root\ntype: solution\n---\n## Fix\n"},
)
assert response.status_code == 401
async def test_roundtrip_via_api(self, client, auth_headers, test_tree):
"""Export tree → import same markdown → verify tree unchanged."""
tree_id = test_tree["id"]
# Export
export_resp = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
headers=auth_headers,
)
markdown = export_resp.json()["markdown"]
# Import
import_resp = await client.put(
f"/api/v1/trees/{tree_id}/import-markdown",
json={"markdown": markdown},
headers=auth_headers,
)
assert import_resp.json()["valid"] is True
# Re-export and compare
export_resp2 = await client.get(
f"/api/v1/trees/{tree_id}/export-markdown",
headers=auth_headers,
)
markdown2 = export_resp2.json()["markdown"]
# Should be identical
assert markdown == markdown2

View File

@@ -0,0 +1,119 @@
"""Tests for variable extraction and resolution service."""
import pytest
from app.services.variable_service import extract_variables, resolve_variables
TREE_WITH_VARIABLES = {
"id": "root",
"type": "decision",
"question": "What is the hostname of [USER_INPUT:hostname]?",
"help_text": "Enter the server hostname",
"options": [
{"id": "opt1", "label": "Check [VAR:hostname]", "next_node_id": "action1"},
],
"children": [
{
"id": "action1",
"type": "action",
"title": "Ping [VAR:hostname]",
"description": "Run diagnostics on [VAR:hostname].\n\n[SAVE_AS:test_results]",
"commands": ["ping [VAR:hostname]"],
"expected_outcome": "Replies from [VAR:hostname]",
"next_node_id": "solution1",
"children": [
{
"id": "solution1",
"type": "solution",
"title": "Resolved",
"description": "Issue on [VAR:hostname] resolved.",
"resolution_steps": [
"Document results for [VAR:hostname]",
"Save as [SAVE_AS:final_result]"
],
"solution": "Resolved",
}
],
}
],
}
TREE_WITHOUT_VARIABLES = {
"id": "root",
"type": "solution",
"title": "Simple Fix",
"description": "Just restart it.",
"solution": "Restart",
}
class TestExtractVariables:
def test_extracts_user_input(self):
variables = extract_variables(TREE_WITH_VARIABLES)
user_inputs = [v for v in variables if v.kind == "user_input"]
assert len(user_inputs) == 1
assert user_inputs[0].name == "hostname"
assert user_inputs[0].node_id == "root"
def test_extracts_var_references(self):
variables = extract_variables(TREE_WITH_VARIABLES)
refs = [v for v in variables if v.kind == "reference"]
assert len(refs) >= 4 # hostname used in multiple places
def test_extracts_save_as(self):
variables = extract_variables(TREE_WITH_VARIABLES)
saves = [v for v in variables if v.kind == "save_as"]
assert len(saves) == 2
names = {v.name for v in saves}
assert "test_results" in names
assert "final_result" in names
def test_no_variables(self):
variables = extract_variables(TREE_WITHOUT_VARIABLES)
assert len(variables) == 0
def test_extracts_from_commands(self):
variables = extract_variables(TREE_WITH_VARIABLES)
cmd_vars = [v for v in variables if v.node_id == "action1" and v.kind == "reference"]
# hostname in title, description, commands, expected_outcome
assert len(cmd_vars) >= 3
class TestResolveVariables:
def test_resolves_var_reference(self):
text = "Ping [VAR:hostname] now"
result = resolve_variables(text, {"hostname": "server01"})
assert result == "Ping server01 now"
def test_resolves_user_input(self):
text = "Server: [USER_INPUT:hostname]"
result = resolve_variables(text, {"hostname": "server01"})
assert result == "Server: server01"
def test_removes_save_as(self):
text = "Done [SAVE_AS:result] here"
result = resolve_variables(text, {})
assert result == "Done here"
def test_unresolved_var_preserved(self):
text = "Server: [VAR:unknown_var]"
result = resolve_variables(text, {})
assert result == "Server: [VAR:unknown_var]"
def test_multiple_variables(self):
text = "[VAR:hostname] ([VAR:ip]) - [USER_INPUT:ticket]"
result = resolve_variables(text, {
"hostname": "server01",
"ip": "10.0.0.1",
"ticket": "INC001",
})
assert result == "server01 (10.0.0.1) - INC001"
def test_empty_variables_dict(self):
text = "No vars here"
result = resolve_variables(text, {})
assert result == "No vars here"
def test_empty_text(self):
result = resolve_variables("", {"hostname": "server01"})
assert result == ""

View File

@@ -11,6 +11,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"axios": "^1.13.4",
@@ -1338,6 +1339,29 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2000,6 +2024,14 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -3231,6 +3263,16 @@
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4567,6 +4609,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5259,6 +5314,17 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6276,6 +6342,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",

View File

@@ -16,6 +16,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"axios": "^1.13.4",

View File

@@ -10,3 +10,4 @@ export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'

View File

@@ -0,0 +1,22 @@
import api from '@/api/client'
import type { TreeMarkdownValidation } from '@/types'
export const treeMarkdownApi = {
/** Export a tree's JSONB structure as ResolutionFlow markdown */
exportMarkdown: async (treeId: string): Promise<{ markdown: string }> => {
const response = await api.get(`/trees/${treeId}/export-markdown`)
return response.data
},
/** Parse markdown and update a tree's JSONB structure */
importMarkdown: async (treeId: string, markdown: string): Promise<TreeMarkdownValidation> => {
const response = await api.put(`/trees/${treeId}/import-markdown`, { markdown })
return response.data
},
/** Validate markdown without saving */
validateMarkdown: async (markdown: string): Promise<TreeMarkdownValidation> => {
const response = await api.post('/trees/validate-markdown', { markdown })
return response.data
},
}

View File

@@ -0,0 +1,73 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface VariablePromptModalProps {
/** The prompt text from [USER_INPUT:prompt] */
prompt: string
/** Called with the user's input value */
onSubmit: (value: string) => void
/** Called when user cancels */
onCancel: () => void
}
export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariablePromptModalProps) {
const [value, setValue] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (value.trim()) {
onSubmit(value.trim())
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="glass-card w-full max-w-md rounded-2xl p-6 shadow-lg">
<h2 className="mb-1 text-lg font-semibold text-white">Input Required</h2>
<p className="mb-4 text-sm text-white/40">
This step requires you to provide a value.
</p>
<form onSubmit={handleSubmit}>
<label className="mb-2 block text-sm font-medium text-white/70">
{prompt}
</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter value..."
autoFocus
className={cn(
'w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
<div className="mt-4 flex gap-2">
<button
type="submit"
disabled={!value.trim()}
className={cn(
'flex-1 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Continue
</button>
<button
type="button"
onClick={onCancel}
className={cn(
'rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
'hover:bg-white/10 hover:text-white'
)}
>
Skip
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,13 +1,22 @@
import { lazy, Suspense } from 'react'
import { TreeMetadataForm } from './TreeMetadataForm'
import { NodeList } from './NodeList'
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
// Lazy load CodeModeEditor (Monaco is ~2MB)
const CodeModeEditor = lazy(() =>
import('./code-mode/CodeModeEditor').then(m => ({ default: m.CodeModeEditor }))
)
interface TreeEditorLayoutProps {
isMobile?: boolean
}
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
const editorMode = useTreeEditorStore(s => s.editorMode)
return (
<div
className={cn(
@@ -15,28 +24,52 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
isMobile ? 'flex-col' : 'flex-row'
)}
>
{/* Left Panel - Form Editor */}
<div
className={cn(
'flex flex-col overflow-y-auto border-white/[0.06]',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
)}
>
<div className="space-y-4 p-4">
<TreeMetadataForm />
<NodeList />
</div>
</div>
{editorMode === 'code' ? (
<>
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
<div className={cn(
'flex flex-col overflow-hidden border-white/[0.06]',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
)}>
<Suspense fallback={
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
}>
<CodeModeEditor />
</Suspense>
</div>
{/* Right Panel - Preview */}
<div
className={cn(
'flex-1 overflow-hidden bg-white/[0.02]',
isMobile ? 'hidden' : 'block'
)}
>
<TreePreviewPanel />
</div>
{/* Right Panel - Preview */}
<div className={cn(
'flex-1 overflow-hidden bg-white/[0.02]',
isMobile ? 'hidden' : 'block'
)}>
<TreePreviewPanel />
</div>
</>
) : (
<>
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
<div className={cn(
'flex flex-col overflow-y-auto border-white/[0.06]',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
)}>
<div className="space-y-4 p-4">
<TreeMetadataForm />
<NodeList />
</div>
</div>
{/* Right Panel - Preview */}
<div className={cn(
'flex-1 overflow-hidden bg-white/[0.02]',
isMobile ? 'hidden' : 'block'
)}>
<TreePreviewPanel />
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Editor, { type OnMount, type BeforeMount } from '@monaco-editor/react'
import type { editor as MonacoEditor } from 'monaco-editor'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { treeMarkdownApi } from '@/api/treeMarkdown'
import { resolutionFlowLanguage, LANGUAGE_ID } from './resolutionFlowLanguage'
import { resolutionFlowTheme, THEME_ID } from './resolutionFlowTheme'
import { createCompletionProvider } from './resolutionFlowCompletions'
import { CodeModeToolbar } from './CodeModeToolbar'
import { SyntaxHelpPanel } from './SyntaxHelpPanel'
// Module-level ref so TreeEditorPage can trigger Monaco undo/redo from toolbar buttons
let _monacoEditor: MonacoEditor.IStandaloneCodeEditor | null = null
export function getMonacoEditor() { return _monacoEditor }
export function CodeModeEditor() {
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
const validationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const {
markdownSource,
markdownValidationErrors,
isMarkdownValid,
isValidating,
setMarkdownSource,
setMarkdownValidationResult,
getAvailableTargetNodes,
} = useTreeEditorStore()
const [syntaxHelpOpen, setSyntaxHelpOpen] = useState(false)
// Register language and theme before editor mounts
const handleEditorWillMount: BeforeMount = useCallback((monaco) => {
// Register language if not already registered
const langs = monaco.languages.getLanguages()
if (!langs.some((l: { id: string }) => l.id === LANGUAGE_ID)) {
monaco.languages.register({ id: LANGUAGE_ID })
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, resolutionFlowLanguage)
}
// Register theme
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
// Register completion provider
monaco.languages.registerCompletionItemProvider(
LANGUAGE_ID,
createCompletionProvider(() =>
getAvailableTargetNodes().map(n => ({
id: n.id,
label: n.label,
type: n.type,
}))
)
)
}, [getAvailableTargetNodes])
// Editor mounted
const handleEditorDidMount: OnMount = useCallback((editor) => {
editorRef.current = editor
_monacoEditor = editor
editor.focus()
}, [])
// Debounced validation on change
const handleEditorChange = useCallback((value: string | undefined) => {
const md = value ?? ''
setMarkdownSource(md)
// Cancel pending validation
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Debounce 800ms
validationTimeoutRef.current = setTimeout(async () => {
abortControllerRef.current = new AbortController()
try {
const result = await treeMarkdownApi.validateMarkdown(md)
setMarkdownValidationResult(result)
// Set Monaco markers
if (editorRef.current) {
const monaco = (await import('monaco-editor')).default ?? await import('monaco-editor')
const model = editorRef.current.getModel()
if (model && monaco.editor?.setModelMarkers) {
const markers = result.errors.map((err) => ({
startLineNumber: err.line,
startColumn: err.column,
endLineNumber: err.line,
endColumn: err.column + 1,
message: err.message,
severity: err.severity === 'error' ? 8 : 4, // MarkerSeverity.Error : Warning
}))
monaco.editor.setModelMarkers(model, 'resolutionflow', markers)
}
}
} catch {
// Validation cancelled or failed — ignore
}
}, 800)
}, [setMarkdownSource, setMarkdownValidationResult])
// Insert template at cursor
const handleInsertTemplate = useCallback((template: string) => {
const editor = editorRef.current
if (!editor) return
const position = editor.getPosition()
if (!position) return
const model = editor.getModel()
if (!model) return
// Insert at end of document
const lastLine = model.getLineCount()
const lastColumn = model.getLineMaxColumn(lastLine)
editor.executeEdits('insert-template', [{
range: {
startLineNumber: lastLine,
startColumn: lastColumn,
endLineNumber: lastLine,
endColumn: lastColumn,
},
text: template,
}])
// Move cursor to the inserted template
const newLastLine = model.getLineCount()
editor.setPosition({ lineNumber: newLastLine, column: 1 })
editor.revealLine(newLastLine)
editor.focus()
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
_monacoEditor = null
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
return (
<div className="flex h-full flex-col">
<CodeModeToolbar
validationErrors={markdownValidationErrors}
isValidating={isValidating}
isValid={isMarkdownValid}
onInsertTemplate={handleInsertTemplate}
onToggleSyntaxHelp={() => setSyntaxHelpOpen(!syntaxHelpOpen)}
syntaxHelpOpen={syntaxHelpOpen}
/>
<div className="relative flex-1 min-h-0">
<Editor
height="100%"
language={LANGUAGE_ID}
theme={THEME_ID}
value={markdownSource ?? ''}
onChange={handleEditorChange}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
loading={
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
tabSize: 2,
insertSpaces: true,
automaticLayout: true,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
padding: { top: 12, bottom: 12 },
accessibilitySupport: 'on',
}}
/>
{/* Syntax help as absolute overlay on right side */}
{syntaxHelpOpen && (
<div className="absolute right-0 top-0 bottom-0 z-20 w-[280px]">
<SyntaxHelpPanel open={syntaxHelpOpen} onClose={() => setSyntaxHelpOpen(false)} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,186 @@
import { ChevronDown, AlertCircle, CheckCircle2, Loader2, Plus, HelpCircle } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import type { TreeMarkdownValidationError } from '@/types'
interface CodeModeToolbarProps {
validationErrors: TreeMarkdownValidationError[]
isValidating: boolean
isValid: boolean
onInsertTemplate: (template: string) => void
onToggleSyntaxHelp: () => void
syntaxHelpOpen: boolean
}
const NODE_TEMPLATES = {
decision: [
'',
'---',
'id: new_decision',
'type: decision',
'parent: root',
'---',
'# What is the question?',
'',
'> Help text for the engineer',
'',
'- [A] Option A → @target_id',
'- [B] Option B → @target_id',
'',
].join('\n'),
action: [
'',
'---',
'id: new_action',
'type: action',
'parent: root',
'---',
'## Action Title',
'',
'Description of what to do.',
'',
'```commands',
'command here',
'```',
'',
'**Expected:** Expected outcome',
'',
'→ @next_node_id',
'',
].join('\n'),
solution: [
'',
'---',
'id: new_solution',
'type: solution',
'parent: root',
'---',
'## Solution Title',
'',
'Description of the resolution.',
'',
'1. Step 1',
'2. Step 2',
'',
].join('\n'),
}
export function CodeModeToolbar({
validationErrors,
isValidating,
isValid,
onInsertTemplate,
onToggleSyntaxHelp,
syntaxHelpOpen,
}: CodeModeToolbarProps) {
const [insertOpen, setInsertOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const errorCount = validationErrors.filter(e => e.severity === 'error').length
const warningCount = validationErrors.filter(e => e.severity === 'warning').length
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setInsertOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div className="flex items-center justify-between border-b border-white/[0.06] bg-black/50 px-3 py-1.5">
<div className="flex items-center gap-2">
{/* Insert Node dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setInsertOpen(!insertOpen)}
className={cn(
'flex items-center gap-1 rounded-md border border-white/10 px-2.5 py-1 text-xs font-medium text-white/60',
'hover:bg-white/10 hover:text-white'
)}
>
<Plus className="h-3 w-3" />
Insert Node
<ChevronDown className="h-3 w-3" />
</button>
{insertOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-44 rounded-lg border border-white/10 bg-[#111] py-1 shadow-xl">
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.decision); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-blue-400" />
Decision
</button>
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.action); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-amber-400" />
Action
</button>
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.solution); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Solution
</button>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Validation status */}
<div className="flex items-center gap-1.5 text-xs">
{isValidating ? (
<>
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
<span className="text-white/40">Validating...</span>
</>
) : isValid ? (
<>
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
<span className="text-emerald-400/70">Synced</span>
{warningCount > 0 && (
<span className="text-yellow-400/70">
({warningCount} warning{warningCount !== 1 ? 's' : ''})
</span>
)}
</>
) : (
<>
<AlertCircle className="h-3 w-3 text-red-400" />
<span className="text-red-400/70">
{errorCount} error{errorCount !== 1 ? 's' : ''}
</span>
{warningCount > 0 && (
<span className="text-yellow-400/70">
, {warningCount} warning{warningCount !== 1 ? 's' : ''}
</span>
)}
</>
)}
</div>
{/* Syntax Help toggle */}
<button
onClick={onToggleSyntaxHelp}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-xs',
syntaxHelpOpen
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<HelpCircle className="h-3 w-3" />
Syntax
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SyntaxHelpPanelProps {
open: boolean
onClose: () => void
}
export function SyntaxHelpPanel({ open, onClose }: SyntaxHelpPanelProps) {
if (!open) return null
return (
<div className="flex h-full flex-col border-l border-white/[0.06] bg-[#0a0a0a]">
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-2">
<span className="text-xs font-medium text-white/60">Syntax Reference</span>
<button
onClick={onClose}
className="rounded p-0.5 text-white/30 hover:bg-white/10 hover:text-white/60"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 text-[11px] leading-relaxed text-white/50">
<Section title="Node Structure">
<Code>{`---
id: node_id
type: decision|action|solution
parent: parent_id
---`}</Code>
</Section>
<Section title="Decision Node">
<Code>{`# Question text here
> Help text (blockquote)
- [A] Option label → @target_id
- [B] Another option → @target_id`}</Code>
</Section>
<Section title="Action Node">
<Code>{`## Action Title
Description paragraph.
\`\`\`commands
ping 8.8.8.8
tracert gateway
\`\`\`
**Expected:** Expected outcome
→ @next_node_id`}</Code>
</Section>
<Section title="Solution Node">
<Code>{`## Solution Title
Description paragraph.
1. Resolution step one
2. Resolution step two`}</Code>
</Section>
<Section title="Variables">
<Row label="User input" code="[USER_INPUT:prompt]" />
<Row label="Reference" code="[VAR:name]" />
<Row label="Save value" code="[SAVE_AS:name]" />
</Section>
<Section title="Keyboard Shortcuts">
<Row label="Toggle mode" code="Ctrl+Shift+M" />
<Row label="Insert Decision" code="Ctrl+Shift+D" />
<Row label="Insert Action" code="Ctrl+Shift+A" />
<Row label="Insert Solution" code="Ctrl+Shift+S" />
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/30">{title}</h4>
{children}
</div>
)
}
function Code({ children }: { children: string }) {
return (
<pre className={cn(
'mb-2 overflow-x-auto rounded-md border border-white/[0.06] bg-black/50 px-2 py-1.5',
'text-[10px] leading-relaxed text-white/50 whitespace-pre'
)}>
{children}
</pre>
)
}
function Row({ label, code }: { label: string; code: string }) {
return (
<div className="flex items-center justify-between py-0.5">
<span className="text-white/40">{label}</span>
<code className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/50">{code}</code>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { CodeModeEditor, getMonacoEditor } from './CodeModeEditor'
export { CodeModeToolbar } from './CodeModeToolbar'
export { SyntaxHelpPanel } from './SyntaxHelpPanel'

View File

@@ -0,0 +1,158 @@
import type { languages, editor, Position, IRange } from 'monaco-editor'
interface NodeInfo {
id: string
label: string
type: string
}
/**
* Create a completion provider for ResolutionFlow markdown.
* Provides autocomplete for node references, types, and templates.
*/
export function createCompletionProvider(
getNodes: () => NodeInfo[]
): languages.CompletionItemProvider {
return {
triggerCharacters: ['@', ':', '['],
provideCompletionItems(
model: editor.ITextModel,
position: Position,
): languages.ProviderResult<languages.CompletionList> {
const lineContent = model.getLineContent(position.lineNumber)
const textUntilPosition = lineContent.substring(0, position.column - 1)
const word = model.getWordUntilPosition(position)
const range: IRange = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
}
const suggestions: languages.CompletionItem[] = []
// After @ — suggest node IDs
if (textUntilPosition.endsWith('@') || textUntilPosition.match(/@\w*$/)) {
const atPos = textUntilPosition.lastIndexOf('@')
const atRange: IRange = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: atPos + 2, // after the @
endColumn: position.column,
}
const nodes = getNodes()
for (const node of nodes) {
const typeIcon = node.type === 'decision' ? '?' : node.type === 'action' ? '!' : '='
suggestions.push({
label: `${node.id}${node.label}`,
kind: 18, // CompletionItemKind.Reference
insertText: node.id,
detail: `${typeIcon} ${node.type}`,
range: atRange,
})
}
return { suggestions }
}
// After "type:" — suggest node types
if (textUntilPosition.match(/^type:\s*$/)) {
for (const t of ['decision', 'action', 'solution']) {
suggestions.push({
label: t,
kind: 12, // CompletionItemKind.Value
insertText: t,
range,
})
}
return { suggestions }
}
// After "---\n" at beginning — suggest node template
if (lineContent.trim() === '---' && position.lineNumber <= 2) {
suggestions.push(
{
label: 'Decision Node',
kind: 14, // CompletionItemKind.Snippet
insertText: [
'',
'id: ${1:node_id}',
'type: decision',
'---',
'# ${2:What is the question?}',
'',
'> ${3:Help text for the engineer}',
'',
'- [A] ${4:Option A} → @${5:target_id}',
'- [B] ${6:Option B} → @${7:target_id}',
].join('\n'),
insertTextRules: 4, // InsertTextRule.InsertAsSnippet
detail: 'Decision node template',
range,
},
{
label: 'Action Node',
kind: 14,
insertText: [
'',
'id: ${1:node_id}',
'type: action',
'parent: ${2:parent_id}',
'---',
'## ${3:Action Title}',
'',
'${4:Description of what to do}',
'',
'```commands',
'${5:command here}',
'```',
'',
'**Expected:** ${6:Expected outcome}',
'',
'→ @${7:next_node_id}',
].join('\n'),
insertTextRules: 4,
detail: 'Action node template',
range,
},
{
label: 'Solution Node',
kind: 14,
insertText: [
'',
'id: ${1:node_id}',
'type: solution',
'parent: ${2:parent_id}',
'---',
'## ${3:Solution Title}',
'',
'${4:Description}',
'',
'1. ${5:Step 1}',
'2. ${6:Step 2}',
].join('\n'),
insertTextRules: 4,
detail: 'Solution node template',
range,
},
)
return { suggestions }
}
// After [VAR: — suggest variable names
if (textUntilPosition.match(/\[VAR:$/)) {
// Could be extended to track variable names from the document
suggestions.push(
{ label: 'hostname', kind: 5, insertText: 'hostname', range },
{ label: 'username', kind: 5, insertText: 'username', range },
{ label: 'ticket_number', kind: 5, insertText: 'ticket_number', range },
{ label: 'client_name', kind: 5, insertText: 'client_name', range },
)
return { suggestions }
}
return { suggestions }
},
}
}

View File

@@ -0,0 +1,72 @@
import type { languages } from 'monaco-editor'
/**
* Monarch tokenizer for ResolutionFlow tree markdown syntax.
* Provides syntax highlighting for frontmatter, headings, options, references, and variables.
*/
export const resolutionFlowLanguage: languages.IMonarchLanguage = {
tokenizer: {
root: [
// Frontmatter delimiter
[/^---\s*$/, 'delimiter.frontmatter', '@frontmatter'],
// Headings
[/^##\s+.*$/, 'heading.action'],
[/^#\s+.*$/, 'heading.decision'],
// Blockquote (help text)
[/^>\s.*$/, 'string.blockquote'],
// Option lines: - [X] Label → @target_id
[/^-\s*\[[A-Za-z0-9]+\]/, 'keyword.option', '@optionLine'],
// Next node reference: → @node_id
[/^→\s*@\S+/, 'variable.reference'],
// Expected outcome
[/^\*\*Expected:\*\*\s*.*$/, 'keyword.expected'],
// Command block
[/^```commands\s*$/, 'delimiter.commands', '@commandBlock'],
[/^```\s*$/, 'delimiter.commands'],
// Variable tokens
[/\[USER_INPUT:[^\]]+\]/, 'variable.input'],
[/\[VAR:[^\]]+\]/, 'variable.reference'],
[/\[SAVE_AS:[^\]]+\]/, 'variable.save'],
// Ordered list items
[/^\d+\.\s+/, 'keyword.step'],
// Bold
[/\*\*[^*]+\*\*/, 'strong'],
// Inline code
[/`[^`]+`/, 'string.code'],
// Regular text
[/./, 'text'],
],
frontmatter: [
[/^---\s*$/, 'delimiter.frontmatter', '@pop'],
[/^(id)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.id']],
[/^(type)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'type.value']],
[/^(parent)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.parent']],
[/./, 'variable.other'],
],
optionLine: [
[/→\s*@\S+/, 'variable.reference'],
[/$/, '', '@pop'],
[/./, 'string.option'],
],
commandBlock: [
[/^```\s*$/, 'delimiter.commands', '@pop'],
[/./, 'string.command'],
],
},
}
export const LANGUAGE_ID = 'resolutionflow'

View File

@@ -0,0 +1,71 @@
import type { editor } from 'monaco-editor'
/**
* Dark theme for ResolutionFlow markdown syntax.
* Matches the monochrome design system with functional color for node types.
*/
export const resolutionFlowTheme: editor.IStandaloneThemeData = {
base: 'vs-dark',
inherit: true,
rules: [
// Frontmatter
{ token: 'delimiter.frontmatter', foreground: '6b7280' },
{ token: 'keyword.frontmatter', foreground: '9ca3af' },
{ token: 'string.id', foreground: 'e5e7eb', fontStyle: 'bold' },
{ token: 'type.value', foreground: '60a5fa' },
{ token: 'string.parent', foreground: 'a78bfa' },
// Headings
{ token: 'heading.decision', foreground: '60a5fa', fontStyle: 'bold' },
{ token: 'heading.action', foreground: 'fbbf24', fontStyle: 'bold' },
// Options
{ token: 'keyword.option', foreground: '60a5fa' },
{ token: 'string.option', foreground: 'e5e7eb' },
// References
{ token: 'variable.reference', foreground: 'a78bfa' },
// Variables
{ token: 'variable.input', foreground: 'fb923c', fontStyle: 'bold' },
{ token: 'variable.save', foreground: 'fb923c' },
// Commands
{ token: 'delimiter.commands', foreground: '6b7280' },
{ token: 'string.command', foreground: '34d399' },
// Expected outcome
{ token: 'keyword.expected', foreground: '34d399' },
// Ordered list
{ token: 'keyword.step', foreground: '9ca3af' },
// Help text
{ token: 'string.blockquote', foreground: '9ca3af', fontStyle: 'italic' },
// Formatting
{ token: 'strong', fontStyle: 'bold' },
{ token: 'string.code', foreground: '34d399' },
// Default
{ token: 'text', foreground: 'd1d5db' },
],
colors: {
'editor.background': '#0a0a0a',
'editor.foreground': '#d1d5db',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ffffff20',
'editorCursor.foreground': '#ffffff',
'editor.inactiveSelectionBackground': '#ffffff10',
'editorLineNumber.foreground': '#4b5563',
'editorLineNumber.activeForeground': '#9ca3af',
'editorGutter.background': '#0a0a0a',
'editorWidget.background': '#111111',
'editorWidget.border': '#ffffff10',
'editorSuggestWidget.background': '#111111',
'editorSuggestWidget.border': '#ffffff10',
'editorSuggestWidget.selectedBackground': '#ffffff15',
},
}
export const THEME_ID = 'resolutionflow-dark'

View File

@@ -0,0 +1,90 @@
import type { TreeStructure } from '@/types'
export interface TreeMetadata {
name?: string
description?: string
category?: string
tags?: string[]
}
/**
* Lightweight frontend serializer for instant preview when switching Form→Code.
* The backend remains authoritative for actual saves.
*/
export function treeStructureToMarkdownPreview(
structure: TreeStructure,
metadata?: TreeMetadata,
): string {
const blocks: string[] = []
if (metadata) {
const fm = ['---']
if (metadata.name) fm.push(`name: ${metadata.name}`)
if (metadata.description) fm.push(`description: ${metadata.description}`)
if (metadata.category) fm.push(`category: ${metadata.category}`)
if (metadata.tags?.length) fm.push(`tags: [${metadata.tags.join(', ')}]`)
fm.push('---')
blocks.push(fm.join('\n'))
}
serializeNode(structure, null, blocks)
return blocks.join('\n\n') + '\n'
}
function serializeNode(
node: TreeStructure,
parentId: string | null,
blocks: string[],
): void {
const fm = ['---', `id: ${node.id}`, `type: ${node.type}`]
if (parentId !== null) fm.push(`parent: ${parentId}`)
fm.push('---')
const body: string[] = []
if (node.type === 'decision') {
if (node.question) {
body.push(`# ${node.question}`, '')
}
if (node.help_text) {
for (const line of node.help_text.split('\n')) {
body.push(`> ${line}`)
}
body.push('')
}
if (node.options) {
node.options.forEach((opt, i) => {
const letter = String.fromCharCode(65 + i)
if (opt.next_node_id) {
body.push(`- [${letter}] ${opt.label} → @${opt.next_node_id}`)
} else {
body.push(`- [${letter}] ${opt.label}`)
}
})
}
} else if (node.type === 'action') {
if (node.title) body.push(`## ${node.title}`, '')
if (node.description) body.push(node.description, '')
if (node.commands?.length) {
body.push('```commands')
node.commands.forEach(cmd => body.push(cmd))
body.push('```', '')
}
if (node.expected_outcome) body.push(`**Expected:** ${node.expected_outcome}`, '')
if (node.next_node_id) body.push(`→ @${node.next_node_id}`)
} else if (node.type === 'solution') {
if (node.title) body.push(`## ${node.title}`, '')
if (node.description) body.push(node.description, '')
if (node.resolution_steps?.length) {
node.resolution_steps.forEach((step, i) => body.push(`${i + 1}. ${step}`))
}
}
blocks.push(fm.join('\n') + '\n' + body.join('\n'))
if (node.children) {
for (const child of node.children) {
serializeNode(child, node.id, blocks)
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* Frontend-side variable substitution for display during navigation.
*
* - [VAR:name] → replaced with variables[name]
* - [USER_INPUT:prompt] → replaced with variables[prompt]
* - [SAVE_AS:name] → removed from display
*/
export function resolveVariables(text: string, variables: Record<string, string>): string {
// Replace [VAR:name]
let result = text.replace(/\[VAR:([^\]]+)\]/g, (_, name) => {
const key = name.trim()
return variables[key] ?? `[VAR:${key}]`
})
// Replace [USER_INPUT:prompt]
result = result.replace(/\[USER_INPUT:([^\]]+)\]/g, (_, prompt) => {
const key = prompt.trim()
return variables[key] ?? `[USER_INPUT:${key}]`
})
// Remove [SAVE_AS:name]
result = result.replace(/\[SAVE_AS:[^\]]+\]/g, '')
return result
}
/**
* Extract all [USER_INPUT:prompt] tokens from text.
* Returns array of prompt strings.
*/
export function extractUserInputPrompts(text: string): string[] {
const prompts: string[] = []
const re = /\[USER_INPUT:([^\]]+)\]/g
let match
while ((match = re.exec(text)) !== null) {
const prompt = match[1].trim()
if (!prompts.includes(prompt)) {
prompts.push(prompt)
}
}
return prompts
}
/**
* Check if text contains any variable tokens.
*/
export function hasVariableTokens(text: string): boolean {
return /\[(USER_INPUT|VAR|SAVE_AS):[^\]]+\]/.test(text)
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
import { treesApi } from '@/api'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList } from 'lucide-react'
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
import { treesApi, treeMarkdownApi } from '@/api'
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
@@ -24,6 +25,7 @@ export function TreeEditorPage() {
isLoading,
isSaving,
validationErrors,
editorMode,
initNewTree,
loadTree,
loadDraft,
@@ -34,7 +36,8 @@ export function TreeEditorPage() {
markSaved,
setLoading,
setSaving,
selectNode
selectNode,
setEditorMode,
} = useTreeEditorStore()
// Access undo/redo from temporal store
@@ -61,22 +64,50 @@ export function TreeEditorPage() {
isDirty && currentLocation.pathname !== nextLocation.pathname
)
const handleUndo = useCallback(() => {
if (editorMode === 'code') {
// In Code Mode, use Monaco's native undo (word-level, like VS Code)
const editor = getMonacoEditor()
if (editor) {
editor.trigger('toolbar', 'undo', null)
editor.focus()
return
}
}
if (pastStates.length > 0) {
undo()
toast.info('Undone')
}
}, [editorMode, pastStates.length, undo])
const handleRedo = useCallback(() => {
if (editorMode === 'code') {
// In Code Mode, use Monaco's native redo (word-level, like VS Code)
const editor = getMonacoEditor()
if (editor) {
editor.trigger('toolbar', 'redo', null)
editor.focus()
return
}
}
if (futureStates.length > 0) {
redo()
toast.info('Redone')
}
}, [editorMode, futureStates.length, redo])
// Keyboard shortcuts for undo/redo/save
useKeyboardShortcuts([
{
key: 'z',
ctrl: true,
handler: () => {
if (pastStates.length > 0) undo()
}
handler: handleUndo
},
{
key: 'z',
ctrl: true,
shift: true,
handler: () => {
if (futureStates.length > 0) redo()
}
handler: handleRedo
},
{
key: 's',
@@ -84,6 +115,14 @@ export function TreeEditorPage() {
handler: () => {
handleSave()
}
},
{
key: 'm',
ctrl: true,
shift: true,
handler: () => {
setEditorMode(editorMode === 'form' ? 'code' : 'form')
}
}
])
@@ -165,6 +204,29 @@ export function TreeEditorPage() {
const handleSaveDraft = useCallback(async () => {
setSaving(true)
try {
// In Code Mode, run fresh validation on current markdown before saving
if (editorMode === 'code') {
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
if (markdownSource) {
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
setMarkdownValidationResult(result) // applies tree_structure + metadata to store
if (!result.valid) {
const errorCount = result.errors.filter(e => e.severity === 'error').length
toast.error(`Cannot save: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
setSaving(false)
return
}
}
}
// Check tree name is set (metadata may come from Code Mode markdown)
const currentState = useTreeEditorStore.getState()
if (!currentState.name.trim()) {
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
setSaving(false)
return
}
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
@@ -174,31 +236,67 @@ export function TreeEditorPage() {
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('draft')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Draft created successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
} catch (err: unknown) {
console.error('Failed to save draft:', err)
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: string[] } } } }
if (axiosErr.response?.status === 422) {
const detail = axiosErr.response.data?.detail
if (typeof detail === 'object' && detail?.errors) {
toast.error(`Validation failed: ${detail.errors.join(', ')}`)
} else if (typeof detail === 'string') {
toast.error(`Validation failed: ${detail}`)
} else {
toast.error('Tree has validation errors. Fix them before saving.')
}
return
}
}
toast.error('Failed to save draft. Please try again.')
} finally {
setSaving(false)
}
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
}, [isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
const handlePublish = useCallback(async () => {
// Validate first
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
toast.error('Please fix validation errors before publishing')
return
}
setSaving(true)
try {
// In Code Mode, run fresh validation on current markdown before publishing
if (editorMode === 'code') {
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
if (markdownSource) {
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
setMarkdownValidationResult(result)
if (!result.valid) {
const errorCount = result.errors.filter(e => e.severity === 'error').length
toast.error(`Cannot publish: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
setSaving(false)
return
}
}
}
// Check tree name is set
const currentState = useTreeEditorStore.getState()
if (!currentState.name.trim()) {
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
setSaving(false)
return
}
// Validate tree structure
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
toast.error('Please fix validation errors before publishing')
setSaving(false)
return
}
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
@@ -208,10 +306,8 @@ export function TreeEditorPage() {
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('published')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Tree published successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
@@ -220,7 +316,7 @@ export function TreeEditorPage() {
} finally {
setSaving(false)
}
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
}, [isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => {
@@ -371,17 +467,52 @@ export function TreeEditorPage() {
</div>
<div className="flex items-center gap-2">
{/* Mode Toggle */}
<div className="flex items-center rounded-md border border-white/[0.06]">
<button
type="button"
onClick={() => setEditorMode('form')}
title="Flow Mode — form-based editing"
className={cn(
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
editorMode === 'form'
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<LayoutList className="h-3.5 w-3.5" />
Flow
</button>
<div className="h-5 w-px bg-white/[0.06]" />
<button
type="button"
onClick={() => setEditorMode('code')}
title="Code Mode — markdown editing (Ctrl+Shift+M)"
className={cn(
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
editorMode === 'code'
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<Code2 className="h-3.5 w-3.5" />
Code
</button>
</div>
<div className="mx-1 h-6 w-px bg-white/[0.06]" />
{/* Undo/Redo */}
<div className="flex items-center rounded-md border border-white/[0.06]">
<button
type="button"
onClick={() => undo()}
onClick={handleUndo}
disabled={pastStates.length === 0}
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
className={cn(
'rounded-l-md p-2 transition-colors',
pastStates.length > 0
? 'text-white hover:bg-white/[0.06]'
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
: 'text-white/20 cursor-not-allowed'
)}
>
@@ -390,13 +521,13 @@ export function TreeEditorPage() {
<div className="h-6 w-px bg-white/[0.06]" />
<button
type="button"
onClick={() => redo()}
onClick={handleRedo}
disabled={futureStates.length === 0}
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
className={cn(
'rounded-r-md p-2 transition-colors',
futureStates.length > 0
? 'text-white hover:bg-white/[0.06]'
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
: 'text-white/20 cursor-not-allowed'
)}
>

View File

@@ -1,7 +1,31 @@
import { create } from 'zustand'
import { temporal } from 'zundo'
import { shallow } from 'zustand/shallow'
import { immer } from 'zustand/middleware/immer'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType } from '@/types'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
// Throttle helper: captures first call immediately, then throttles subsequent calls
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let lastCall = 0
let timeout: ReturnType<typeof setTimeout> | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ((...args: any[]) => {
const now = Date.now()
if (now - lastCall >= ms) {
lastCall = now
fn(...args)
} else {
// Schedule a trailing call to capture the final state
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
lastCall = Date.now()
fn(...args)
}, ms - (now - lastCall))
}
}) as T
}
// Validation error interface
export interface ValidationError {
@@ -14,6 +38,71 @@ export interface ValidationError {
// Draft storage key
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
// Check if a tree is effectively empty (just initialized, no real content)
const isEmptyTree = (tree: TreeStructure | null): boolean => {
if (!tree) return true
if (tree.question && tree.question.trim()) return false
if (tree.children && tree.children.length > 0) return false
return true
}
// Starter template for new trees in Code Mode — all @refs are valid within the template
const CODE_MODE_STARTER_TEMPLATE = `---
name: My New Tree
description: A troubleshooting decision tree
---
---
id: root
type: decision
---
# What type of issue is the user experiencing?
> Select the category that best matches the reported problem
- [A] Option A \u2192 @option_a_action
- [B] Option B \u2192 @option_b_action
---
id: option_a_action
type: action
parent: root
---
## Investigate Option A
Describe the investigation steps here.
\`\`\`commands
example-command --flag
\`\`\`
**Expected:** Describe expected results here
\u2192 @resolution
---
id: option_b_action
type: action
parent: root
---
## Investigate Option B
Describe the investigation steps here.
\u2192 @resolution
---
id: resolution
type: solution
parent: root
---
## Resolution
1. Document findings
2. Apply the fix
3. Verify the issue is resolved
`
// Helper to generate unique IDs
const generateId = () => crypto.randomUUID()
@@ -114,6 +203,14 @@ interface TreeEditorState {
isSaving: boolean
validationErrors: ValidationError[]
// Code Mode state
editorMode: 'form' | 'code'
markdownSource: string | null
markdownValidationErrors: TreeMarkdownValidationError[]
isMarkdownValid: boolean
isValidating: boolean
lastValidTreeFromMarkdown: TreeStructure | null
// Auto-save state
lastSavedAt: Date | null
draftSavedAt: Date | null
@@ -159,6 +256,13 @@ interface TreeEditorState {
markSaved: () => void
getTreeForSave: () => TreeCreate | TreeUpdate
// Actions - Code Mode
setEditorMode: (mode: 'form' | 'code') => void
setMarkdownSource: (markdown: string) => void
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
syncMarkdownToTree: () => void
syncTreeToMarkdown: () => void
// Actions - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
@@ -188,6 +292,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
isLoading: false,
isSaving: false,
validationErrors: [],
editorMode: 'form',
markdownSource: null,
markdownValidationErrors: [],
isMarkdownValid: true,
isValidating: false,
lastValidTreeFromMarkdown: null,
lastSavedAt: null,
draftSavedAt: null,
hasDraft: false,
@@ -216,6 +326,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = hasDraft
@@ -292,6 +408,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = false
@@ -773,6 +895,96 @@ export const useTreeEditorStore = create<TreeEditorState>()(
}
},
// Code Mode actions
setEditorMode: (mode: 'form' | 'code') => {
const current = get()
if (mode === current.editorMode) return
if (mode === 'code') {
// Form → Code: generate markdown from tree structure (synchronous)
const { treeStructure, name, description, category, tags } = current
if (isEmptyTree(treeStructure) && !name.trim()) {
// New empty tree: use starter template
set((state) => {
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
state.markdownValidationErrors = []
state.isMarkdownValid = false // needs validation
state.isValidating = false
state.editorMode = mode
})
} else if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.editorMode = mode
})
} else {
set((state) => { state.editorMode = mode })
}
} else {
// Code → Form: apply last valid parse to tree structure
get().syncMarkdownToTree()
set((state) => { state.editorMode = mode })
}
},
setMarkdownSource: (markdown: string) => {
set((state) => {
state.markdownSource = markdown
state.isDirty = true
state.isValidating = true
})
},
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
set((state) => {
state.markdownValidationErrors = result.errors
state.isMarkdownValid = result.valid
state.isValidating = false
if (result.valid && result.tree_structure) {
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
// Live sync: apply valid parsed tree to treeStructure immediately
state.treeStructure = result.tree_structure as TreeStructure
}
// Apply metadata from parsed markdown (bidirectional sync)
if (result.metadata) {
if (result.metadata.name !== undefined) state.name = result.metadata.name
if (result.metadata.description !== undefined) state.description = result.metadata.description
if (result.metadata.category !== undefined) state.category = result.metadata.category
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
}
})
},
syncMarkdownToTree: () => {
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
if (isMarkdownValid && lastValidTreeFromMarkdown) {
set((state) => {
state.treeStructure = lastValidTreeFromMarkdown
state.markdownSource = null
state.isDirty = true
})
}
},
syncTreeToMarkdown: () => {
const { treeStructure, name, description, category, tags } = get()
if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
})
}
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},
@@ -839,7 +1051,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure
})
}),
// Skip no-op entries where partialized fields haven't changed
equality: (pastState, currentState) => shallow(pastState, currentState),
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
handleSet: (handleSet) =>
throttle<typeof handleSet>((state) => {
handleSet(state)
}, 3000),
}
)
)

View File

@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
type TreeLibraryView = 'grid' | 'list' | 'table'
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
type EditorMode = 'form' | 'code'
interface UserPreferencesState {
defaultExportFormat: ExportFormat
@@ -12,6 +13,8 @@ interface UserPreferencesState {
setTreeLibraryView: (view: TreeLibraryView) => void
treeLibrarySortBy: TreeSortBy
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
preferredEditorMode: EditorMode
setPreferredEditorMode: (mode: EditorMode) => void
}
export const useUserPreferencesStore = create<UserPreferencesState>()(
@@ -23,6 +26,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
treeLibrarySortBy: 'usage_count',
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
preferredEditorMode: 'form',
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
}),
{
name: 'user-preferences-storage',

View File

@@ -185,3 +185,25 @@ export interface TreeValidationResponse {
can_publish: boolean
errors: ValidationError[]
}
// Tree markdown types
export interface TreeMarkdownValidationError {
line: number
column: number
message: string
severity: 'error' | 'warning'
}
export interface TreeMarkdownMetadata {
name?: string
description?: string
category?: string
tags?: string[]
}
export interface TreeMarkdownValidation {
valid: boolean
errors: TreeMarkdownValidationError[]
tree_structure: TreeStructure | null
metadata?: TreeMarkdownMetadata | null
}