diff --git a/backend/alembic/versions/009_add_scratchpad_to_sessions.py b/backend/alembic/versions/009_add_scratchpad_to_sessions.py
new file mode 100644
index 00000000..7a12839a
--- /dev/null
+++ b/backend/alembic/versions/009_add_scratchpad_to_sessions.py
@@ -0,0 +1,30 @@
+"""add scratchpad to sessions
+
+Revision ID: 009
+Revises: 4cdb5cba1aff
+Create Date: 2026-02-04
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '009'
+down_revision: Union[str, None] = '4cdb5cba1aff'
+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('scratchpad', sa.Text(), nullable=True, server_default=sa.text("''"))
+ )
+ # Backfill existing rows to empty string (not NULL)
+ op.execute("UPDATE sessions SET scratchpad = '' WHERE scratchpad IS NULL")
+
+
+def downgrade() -> None:
+ op.drop_column('sessions', 'scratchpad')
diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py
index 2054d651..922563b3 100644
--- a/backend/app/api/endpoints/sessions.py
+++ b/backend/app/api/endpoints/sessions.py
@@ -10,7 +10,7 @@ from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
-from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport
+from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate
from app.api.deps import get_current_user
router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -183,6 +183,35 @@ async def complete_session(
return session
+@router.patch("/{session_id}/scratchpad", response_model=SessionResponse)
+async def update_scratchpad(
+ session_id: UUID,
+ data: ScratchpadUpdate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_user)]
+):
+ """Update session scratchpad. Accepts updates on both active and completed sessions."""
+ result = await db.execute(select(Session).where(Session.id == session_id))
+ session = result.scalar_one_or_none()
+
+ if not session:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Session not found"
+ )
+
+ if session.user_id != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You don't have access to this session"
+ )
+
+ session.scratchpad = data.scratchpad
+ await db.commit()
+ await db.refresh(session)
+ return session
+
+
@router.post("/{session_id}/export")
async def export_session(
session_id: UUID,
@@ -244,6 +273,16 @@ def _generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append("---")
lines.append("")
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ lines.append("## Evidence / Reference")
+ lines.append("")
+ lines.append(scratchpad)
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
lines.append("## Troubleshooting Steps")
lines.append("")
@@ -282,6 +321,14 @@ def _generate_text_export(session: Session, options: SessionExport) -> str:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ lines.append("EVIDENCE / REFERENCE")
+ lines.append("-" * 20)
+ lines.append(scratchpad)
+ lines.append("")
+
lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20)
@@ -331,6 +378,12 @@ def _generate_html_export(session: Session, options: SessionExport) -> str:
html.append(f'
Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}
')
html.append('')
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ html.append('Evidence / Reference
')
+ html.append(f'{scratchpad}
')
+
html.append('Troubleshooting Steps
')
for i, decision in enumerate(session.decisions, 1):
diff --git a/backend/app/models/session.py b/backend/app/models/session.py
index 4d222519..bec2cdd6 100644
--- a/backend/app/models/session.py
+++ b/backend/app/models/session.py
@@ -1,7 +1,8 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
-from sqlalchemy import String, DateTime, ForeignKey, Boolean
+from sqlalchemy import String, DateTime, ForeignKey, Boolean, Text
+import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
@@ -44,6 +45,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)
+ scratchpad: Mapped[Optional[str]] = mapped_column(
+ Text, nullable=True, server_default=sa.text("''")
+ )
# Relationships
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py
index ab0033cf..b4ab6377 100644
--- a/backend/app/schemas/session.py
+++ b/backend/app/schemas/session.py
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, validator
class DecisionRecord(BaseModel):
@@ -27,6 +27,7 @@ class SessionUpdate(BaseModel):
custom_steps: Optional[list[dict[str, Any]]] = None
ticket_number: Optional[str] = Field(None, max_length=100)
client_name: Optional[str] = Field(None, max_length=255)
+ scratchpad: Optional[str] = None
class SessionResponse(BaseModel):
@@ -42,6 +43,11 @@ class SessionResponse(BaseModel):
ticket_number: Optional[str] = None
client_name: Optional[str] = None
exported: bool
+ scratchpad: str = ""
+
+ @validator('scratchpad', pre=True, always=True)
+ def normalize_scratchpad(cls, v):
+ return v or ""
class Config:
from_attributes = True
@@ -51,3 +57,7 @@ class SessionExport(BaseModel):
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
include_timestamps: bool = True
include_tree_info: bool = True
+
+
+class ScratchpadUpdate(BaseModel):
+ scratchpad: str
diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py
index b8263caa..3ad858bc 100644
--- a/backend/tests/test_sessions.py
+++ b/backend/tests/test_sessions.py
@@ -315,3 +315,291 @@ class TestSessions:
data = response.json()
assert len(data) >= 1
assert all(s["completed_at"] is None for s in data)
+
+ @pytest.mark.asyncio
+ async def test_create_session_has_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that new sessions include scratchpad field."""
+ session_data = {
+ "tree_id": test_tree["id"],
+ }
+
+ response = await client.post(
+ "/api/v1/sessions",
+ json=session_data,
+ headers=auth_headers
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert "scratchpad" in data
+ assert data["scratchpad"] == ""
+
+ @pytest.mark.asyncio
+ async def test_update_scratchpad_via_put(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test updating scratchpad through the existing PUT endpoint."""
+ # Create session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Update scratchpad via PUT
+ update_data = {
+ "scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"
+ }
+
+ response = await client.put(
+ f"/api/v1/sessions/{session_id}",
+ json=update_data,
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scratchpad"] == update_data["scratchpad"]
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test the dedicated PATCH scratchpad endpoint."""
+ # Create session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Patch scratchpad
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "- IP: 10.0.0.1\n- User: jsmith"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scratchpad"] == "- IP: 10.0.0.1\n- User: jsmith"
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_not_found(
+ self, client: AsyncClient, auth_headers: dict
+ ):
+ """Test PATCH scratchpad with invalid session ID."""
+ import uuid
+ fake_id = str(uuid.uuid4())
+
+ response = await client.patch(
+ f"/api/v1/sessions/{fake_id}/scratchpad",
+ json={"scratchpad": "test"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_empty_string(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test PATCH scratchpad with empty string (clear scratchpad)."""
+ # Create session and set scratchpad
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Set scratchpad
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "some notes"},
+ headers=auth_headers
+ )
+
+ # Clear scratchpad
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": ""},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ assert response.json()["scratchpad"] == ""
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_completed_session(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that scratchpad can still be updated on completed sessions."""
+ # Create and complete session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.post(
+ f"/api/v1/sessions/{session_id}/complete",
+ headers=auth_headers
+ )
+
+ # Should still be able to update scratchpad on completed sessions
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "post-resolution notes"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ assert response.json()["scratchpad"] == "post-resolution notes"
+
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_markdown(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that markdown export includes scratchpad content."""
+ # Create session with scratchpad
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"], "ticket_number": "SP-001"},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Add scratchpad content
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"},
+ headers=auth_headers
+ )
+
+ # Export as markdown
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown", "include_tree_info": True},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "## Evidence / Reference" in content
+ assert "192.168.1.50" in content
+ assert "0x80070005" in content
+
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_text(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that text export includes scratchpad content."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "Error code: 12345"},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "text"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "EVIDENCE / REFERENCE" in content
+ assert "Error code: 12345" in content
+
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_html(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that HTML export includes scratchpad content."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "DNS server: 10.0.0.5"},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "html"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" in content
+ assert "DNS server: 10.0.0.5" in content
+
+ @pytest.mark.asyncio
+ async def test_export_excludes_empty_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that export omits scratchpad section when empty."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Export without setting scratchpad
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" not in content
+
+ @pytest.mark.asyncio
+ async def test_export_excludes_whitespace_only_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that export omits scratchpad section when only whitespace."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": " \n \n "},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" not in content
diff --git a/docs/plans/2026-02-04-session-scratchpad-implementation.md b/docs/plans/2026-02-04-session-scratchpad-implementation.md
new file mode 100644
index 00000000..cc7184c9
--- /dev/null
+++ b/docs/plans/2026-02-04-session-scratchpad-implementation.md
@@ -0,0 +1,1069 @@
+# Session Scratchpad Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a collapsible right sidebar to the tree navigation page that lets engineers capture freeform notes during troubleshooting, with auto-save and export integration.
+
+**Architecture:** New `scratchpad` Text column on the `sessions` table, a dedicated `PATCH /api/v1/sessions/{id}/scratchpad` endpoint for lightweight auto-saves, and a `ScratchpadSidebar` React component with 1000ms debounce. Scratchpad content is included in all three export formats (markdown, text, HTML) as an "Evidence / Reference" section.
+
+**Tech Stack:** Python FastAPI, SQLAlchemy 2.0, Alembic, React 19, TypeScript, Tailwind CSS, Lucide icons
+
+**Design Doc:** `docs/plans/2026-02-04-session-scratchpad-design.md`
+
+---
+
+## Task 1: Database Migration
+
+**Files:**
+- Create: `backend/alembic/versions/009_add_scratchpad_to_sessions.py`
+
+**Step 1: Create the migration file**
+
+Create `backend/alembic/versions/009_add_scratchpad_to_sessions.py`:
+
+```python
+"""add scratchpad to sessions
+
+Revision ID: 009
+Revises: 4cdb5cba1aff
+Create Date: 2026-02-04
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '009'
+down_revision: Union[str, None] = '4cdb5cba1aff'
+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('scratchpad', sa.Text(), nullable=True, server_default=sa.text("''"))
+ )
+ # Backfill existing rows to empty string (not NULL)
+ op.execute("UPDATE sessions SET scratchpad = '' WHERE scratchpad IS NULL")
+
+
+def downgrade() -> None:
+ op.drop_column('sessions', 'scratchpad')
+```
+
+**Step 2: Run the migration**
+
+Run: `cd backend && alembic upgrade head`
+Expected: Migration applies successfully. Verify with:
+```
+docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d sessions"
+```
+You should see `scratchpad | text` in the output.
+
+**Step 3: Commit**
+
+```bash
+git add backend/alembic/versions/009_add_scratchpad_to_sessions.py
+git commit -m "feat: add scratchpad column to sessions table
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 2: Backend Model & Schemas
+
+**Files:**
+- Modify: `backend/app/models/session.py:4,46` (add import + field)
+- Modify: `backend/app/schemas/session.py:4,24-29,32-47` (add imports + fields + validator)
+
+**Step 1: Write the failing tests**
+
+Add these tests to the end of `backend/tests/test_sessions.py` (inside the `TestSessions` class):
+
+```python
+ @pytest.mark.asyncio
+ async def test_create_session_has_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that new sessions include scratchpad field."""
+ session_data = {
+ "tree_id": test_tree["id"],
+ }
+
+ response = await client.post(
+ "/api/v1/sessions",
+ json=session_data,
+ headers=auth_headers
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert "scratchpad" in data
+ assert data["scratchpad"] == ""
+
+ @pytest.mark.asyncio
+ async def test_update_scratchpad_via_put(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test updating scratchpad through the existing PUT endpoint."""
+ # Create session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Update scratchpad via PUT
+ update_data = {
+ "scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"
+ }
+
+ response = await client.put(
+ f"/api/v1/sessions/{session_id}",
+ json=update_data,
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scratchpad"] == update_data["scratchpad"]
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_create_session_has_scratchpad tests/test_sessions.py::TestSessions::test_update_scratchpad_via_put -v`
+Expected: FAIL — `scratchpad` field not in response
+
+**Step 3: Update the model**
+
+In `backend/app/models/session.py`:
+
+Add `Text` to the sqlalchemy import on line 4:
+```python
+from sqlalchemy import String, DateTime, ForeignKey, Boolean, Text
+```
+
+Add `import sqlalchemy as sa` after existing imports (line 5):
+```python
+import sqlalchemy as sa
+```
+
+Add scratchpad field after `exported` (after line 46):
+```python
+ scratchpad: Mapped[Optional[str]] = mapped_column(
+ Text, nullable=True, server_default=sa.text("''")
+ )
+```
+
+**Step 4: Update the schemas**
+
+In `backend/app/schemas/session.py`:
+
+Add `validator` to the pydantic import on line 4:
+```python
+from pydantic import BaseModel, Field, validator
+```
+
+Add `scratchpad` to `SessionUpdate` (after line 29):
+```python
+ scratchpad: Optional[str] = None
+```
+
+Add `scratchpad` to `SessionResponse` (after line 44, before `class Config`):
+```python
+ scratchpad: str = ""
+
+ @validator('scratchpad', pre=True, always=True)
+ def normalize_scratchpad(cls, v):
+ return v or ""
+```
+
+Add new `ScratchpadUpdate` schema (after `SessionExport` class, at end of file):
+```python
+
+class ScratchpadUpdate(BaseModel):
+ scratchpad: str
+```
+
+**Step 5: Run tests to verify they pass**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_create_session_has_scratchpad tests/test_sessions.py::TestSessions::test_update_scratchpad_via_put -v`
+Expected: PASS
+
+**Step 6: Run full test suite**
+
+Run: `cd backend && pytest tests/test_sessions.py -v`
+Expected: All tests pass (existing + 2 new)
+
+**Step 7: Commit**
+
+```bash
+git add backend/app/models/session.py backend/app/schemas/session.py backend/tests/test_sessions.py
+git commit -m "feat: add scratchpad field to session model and schemas
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 3: PATCH Scratchpad Endpoint
+
+**Files:**
+- Modify: `backend/app/api/endpoints/sessions.py:13` (add import)
+- Modify: `backend/app/api/endpoints/sessions.py` (add endpoint after `complete_session`)
+- Test: `backend/tests/test_sessions.py`
+
+**Step 1: Write the failing tests**
+
+Add these tests to `backend/tests/test_sessions.py` (inside the `TestSessions` class):
+
+```python
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test the dedicated PATCH scratchpad endpoint."""
+ # Create session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Patch scratchpad
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "- IP: 10.0.0.1\n- User: jsmith"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scratchpad"] == "- IP: 10.0.0.1\n- User: jsmith"
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_not_found(
+ self, client: AsyncClient, auth_headers: dict
+ ):
+ """Test PATCH scratchpad with invalid session ID."""
+ import uuid
+ fake_id = str(uuid.uuid4())
+
+ response = await client.patch(
+ f"/api/v1/sessions/{fake_id}/scratchpad",
+ json={"scratchpad": "test"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_empty_string(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test PATCH scratchpad with empty string (clear scratchpad)."""
+ # Create session and set scratchpad
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Set scratchpad
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "some notes"},
+ headers=auth_headers
+ )
+
+ # Clear scratchpad
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": ""},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ assert response.json()["scratchpad"] == ""
+
+ @pytest.mark.asyncio
+ async def test_patch_scratchpad_completed_session(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that scratchpad can still be updated on completed sessions."""
+ # Create and complete session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.post(
+ f"/api/v1/sessions/{session_id}/complete",
+ headers=auth_headers
+ )
+
+ # Should still be able to update scratchpad on completed sessions
+ response = await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "post-resolution notes"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ assert response.json()["scratchpad"] == "post-resolution notes"
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_patch_scratchpad tests/test_sessions.py::TestSessions::test_patch_scratchpad_not_found tests/test_sessions.py::TestSessions::test_patch_scratchpad_empty_string tests/test_sessions.py::TestSessions::test_patch_scratchpad_completed_session -v`
+Expected: FAIL — 405 Method Not Allowed (route doesn't exist)
+
+**Step 3: Implement the endpoint**
+
+In `backend/app/api/endpoints/sessions.py`:
+
+Update the import on line 13 to include `ScratchpadUpdate`:
+```python
+from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate
+```
+
+Add this endpoint after the `complete_session` function (after line 183, before `export_session`):
+
+```python
+
+@router.patch("/{session_id}/scratchpad", response_model=SessionResponse)
+async def update_scratchpad(
+ session_id: UUID,
+ data: ScratchpadUpdate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_user)]
+):
+ """Update session scratchpad. Accepts updates on both active and completed sessions."""
+ result = await db.execute(select(Session).where(Session.id == session_id))
+ session = result.scalar_one_or_none()
+
+ if not session:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Session not found"
+ )
+
+ if session.user_id != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You don't have access to this session"
+ )
+
+ session.scratchpad = data.scratchpad
+ await db.commit()
+ await db.refresh(session)
+ return session
+
+```
+
+**Note:** This endpoint intentionally does NOT block updates on completed sessions. Engineers may want to add scratchpad notes after completing a session (e.g., adding follow-up details before exporting). This differs from the existing PUT endpoint which rejects updates on completed sessions.
+
+**Step 4: Run tests to verify they pass**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_patch_scratchpad tests/test_sessions.py::TestSessions::test_patch_scratchpad_not_found tests/test_sessions.py::TestSessions::test_patch_scratchpad_empty_string tests/test_sessions.py::TestSessions::test_patch_scratchpad_completed_session -v`
+Expected: PASS
+
+**Step 5: Run full test suite**
+
+Run: `cd backend && pytest -v`
+Expected: All tests pass
+
+**Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
+git commit -m "feat: add PATCH endpoint for session scratchpad
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 4: Export Integration
+
+**Files:**
+- Modify: `backend/app/api/endpoints/sessions.py:227-353` (all three export generators)
+- Test: `backend/tests/test_sessions.py`
+
+**Step 1: Write the failing tests**
+
+Add these tests to `backend/tests/test_sessions.py` (inside the `TestSessions` class):
+
+```python
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_markdown(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that markdown export includes scratchpad content."""
+ # Create session with scratchpad
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"], "ticket_number": "SP-001"},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Add scratchpad content
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "- Server IP: 192.168.1.50\n- Error: 0x80070005"},
+ headers=auth_headers
+ )
+
+ # Export as markdown
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown", "include_tree_info": True},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "## Evidence / Reference" in content
+ assert "192.168.1.50" in content
+ assert "0x80070005" in content
+
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_text(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that text export includes scratchpad content."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "Error code: 12345"},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "text"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "EVIDENCE / REFERENCE" in content
+ assert "Error code: 12345" in content
+
+ @pytest.mark.asyncio
+ async def test_export_includes_scratchpad_html(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that HTML export includes scratchpad content."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": "DNS server: 10.0.0.5"},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "html"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" in content
+ assert "DNS server: 10.0.0.5" in content
+
+ @pytest.mark.asyncio
+ async def test_export_excludes_empty_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that export omits scratchpad section when empty."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ # Export without setting scratchpad
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" not in content
+
+ @pytest.mark.asyncio
+ async def test_export_excludes_whitespace_only_scratchpad(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that export omits scratchpad section when only whitespace."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ await client.patch(
+ f"/api/v1/sessions/{session_id}/scratchpad",
+ json={"scratchpad": " \n \n "},
+ headers=auth_headers
+ )
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown"},
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ content = response.text
+ assert "Evidence / Reference" not in content
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_markdown tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_text tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_html tests/test_sessions.py::TestSessions::test_export_excludes_empty_scratchpad tests/test_sessions.py::TestSessions::test_export_excludes_whitespace_only_scratchpad -v`
+Expected: FAIL — `Evidence / Reference` not in export output (generators don't include scratchpad yet)
+
+**Step 3: Update the markdown export generator**
+
+In `_generate_markdown_export` (around line 245-247 in `sessions.py`), **insert before** the `lines.append("## Troubleshooting Steps")` line:
+
+```python
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ lines.append("## Evidence / Reference")
+ lines.append("")
+ lines.append(scratchpad)
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ lines.append("## Troubleshooting Steps")
+```
+
+This replaces the existing `lines.append("## Troubleshooting Steps")` line.
+
+**Step 4: Update the text export generator**
+
+In `_generate_text_export` (around line 285-286), **replace** the existing `TROUBLESHOOTING STEPS` block:
+
+```python
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ lines.append("EVIDENCE / REFERENCE")
+ lines.append("-" * 20)
+ lines.append(scratchpad)
+ lines.append("")
+
+ lines.append("TROUBLESHOOTING STEPS")
+ lines.append("-" * 20)
+```
+
+This replaces the existing two lines (`lines.append("TROUBLESHOOTING STEPS")` and `lines.append("-" * 20)`).
+
+**Step 5: Update the HTML export generator**
+
+In `_generate_html_export` (around line 334), **insert before** the `html.append('Troubleshooting Steps
')` line:
+
+```python
+ # Scratchpad / Evidence section
+ scratchpad = getattr(session, 'scratchpad', '') or ''
+ if scratchpad.strip():
+ html.append('Evidence / Reference
')
+ html.append(f'{scratchpad}
')
+
+ html.append('Troubleshooting Steps
')
+```
+
+This replaces the existing `html.append('Troubleshooting Steps
')` line.
+
+**Step 6: Run tests to verify they pass**
+
+Run: `cd backend && pytest tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_markdown tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_text tests/test_sessions.py::TestSessions::test_export_includes_scratchpad_html tests/test_sessions.py::TestSessions::test_export_excludes_empty_scratchpad tests/test_sessions.py::TestSessions::test_export_excludes_whitespace_only_scratchpad -v`
+Expected: PASS
+
+**Step 7: Run full test suite**
+
+Run: `cd backend && pytest -v`
+Expected: All tests pass
+
+**Step 8: Commit**
+
+```bash
+git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
+git commit -m "feat: include scratchpad in session export (markdown, text, HTML)
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 5: Frontend Types & API Client
+
+**Files:**
+- Modify: `frontend/src/types/session.ts:30-43,51-57` (add scratchpad to interfaces)
+- Modify: `frontend/src/api/sessions.ts:49` (add updateScratchpad method)
+
+**Step 1: Add `scratchpad` to the `Session` interface**
+
+In `frontend/src/types/session.ts`, add `scratchpad` to the `Session` interface after `exported` (line 42):
+
+```typescript
+ scratchpad: string
+```
+
+**Step 2: Add `scratchpad` to the `SessionUpdate` interface**
+
+In `frontend/src/types/session.ts`, add to `SessionUpdate` (after line 56):
+
+```typescript
+ scratchpad?: string
+```
+
+**Step 3: Add `updateScratchpad` to the API client**
+
+In `frontend/src/api/sessions.ts`, add this method inside `sessionsApi` (after `export` method, before the closing `}`):
+
+```typescript
+
+ async updateScratchpad(id: string, content: string): Promise {
+ const response = await apiClient.patch(`/sessions/${id}/scratchpad`, { scratchpad: content })
+ return response.data
+ },
+```
+
+**Step 4: Verify build compiles**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds with no type errors
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/types/session.ts frontend/src/api/sessions.ts
+git commit -m "feat: add scratchpad to frontend types and API client
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 6: ScratchpadSidebar Component
+
+**Files:**
+- Create: `frontend/src/components/session/ScratchpadSidebar.tsx`
+- Modify: `frontend/src/components/session/index.ts` (add export)
+
+**Step 1: Create the ScratchpadSidebar component**
+
+Create `frontend/src/components/session/ScratchpadSidebar.tsx`:
+
+```tsx
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { cn } from '@/lib/utils'
+import { MarkdownContent } from '@/components/ui/MarkdownContent'
+import { StickyNote, ChevronRight, ChevronLeft, Eye, Pencil, Loader2 } from 'lucide-react'
+
+interface ScratchpadSidebarProps {
+ sessionId: string
+ initialContent: string
+ onSave: (content: string) => Promise
+}
+
+export function ScratchpadSidebar({ sessionId, initialContent, onSave }: ScratchpadSidebarProps) {
+ const [content, setContent] = useState(initialContent)
+ const [lastSaved, setLastSaved] = useState(initialContent)
+ const [isCollapsed, setIsCollapsed] = useState(() => {
+ return localStorage.getItem('scratchpad-collapsed') === 'true'
+ })
+ const [isSaving, setIsSaving] = useState(false)
+ const [showPreview, setShowPreview] = useState(false)
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved' | 'error'>('idle')
+
+ const debounceRef = useRef | null>(null)
+ const fadeTimerRef = useRef | null>(null)
+
+ const hasUnsavedChanges = content !== lastSaved
+
+ // Reset content when session changes
+ useEffect(() => {
+ setContent(initialContent)
+ setLastSaved(initialContent)
+ setSaveStatus('idle')
+ }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Update save status based on state
+ useEffect(() => {
+ if (isSaving) {
+ setSaveStatus('saving')
+ } else if (hasUnsavedChanges) {
+ setSaveStatus('unsaved')
+ }
+ }, [isSaving, hasUnsavedChanges])
+
+ // Persist collapse state
+ useEffect(() => {
+ localStorage.setItem('scratchpad-collapsed', String(isCollapsed))
+ }, [isCollapsed])
+
+ // beforeunload warning
+ useEffect(() => {
+ if (!hasUnsavedChanges) return
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault()
+ }
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [hasUnsavedChanges])
+
+ const doSave = useCallback(async (text: string) => {
+ setIsSaving(true)
+ try {
+ await onSave(text)
+ setLastSaved(text)
+ setSaveStatus('saved')
+
+ // Clear "Saved" indicator after 2 seconds
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
+ fadeTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000)
+ } catch {
+ setSaveStatus('error')
+ } finally {
+ setIsSaving(false)
+ }
+ }, [onSave])
+
+ const handleChange = (value: string) => {
+ setContent(value)
+
+ // Cancel any pending debounce
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+
+ // Schedule save after 1000ms of inactivity
+ debounceRef.current = setTimeout(() => {
+ if (value !== lastSaved) {
+ doSave(value)
+ }
+ }, 1000)
+ }
+
+ const handleBlur = () => {
+ // Cancel pending debounce and save immediately
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (content !== lastSaved) {
+ doSave(content)
+ }
+ }
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
+ }
+ }, [])
+
+ if (isCollapsed) {
+ return (
+
+
+ {hasUnsavedChanges && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Scratchpad
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {showPreview ? (
+
+ {content.trim() ? (
+
+ ) : (
+
Nothing to preview
+ )}
+
+ ) : (
+
+
+ {/* Save Indicator */}
+
+
+ {saveStatus === 'unsaved' && (
+ Unsaved changes
+ )}
+ {saveStatus === 'saving' && (
+ <>
+
+ Saving...
+ >
+ )}
+ {saveStatus === 'saved' && (
+ Saved
+ )}
+ {saveStatus === 'error' && (
+ Save failed
+ )}
+ {saveStatus === 'idle' && (
+ Markdown supported
+ )}
+
+
+
+ )
+}
+```
+
+**Step 2: Export from session components index**
+
+In `frontend/src/components/session/index.ts`, add:
+
+```typescript
+export { ScratchpadSidebar } from './ScratchpadSidebar'
+```
+
+**Step 3: Verify build compiles**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds (component isn't rendered yet, just compiled)
+
+**Step 4: Commit**
+
+```bash
+git add frontend/src/components/session/ScratchpadSidebar.tsx frontend/src/components/session/index.ts
+git commit -m "feat: add ScratchpadSidebar component with auto-save and markdown preview
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 7: Integrate Sidebar into TreeNavigationPage
+
+**Files:**
+- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
+
+**Step 1: Add import**
+
+At the top of `TreeNavigationPage.tsx`, add `ScratchpadSidebar` to the session import (line 10):
+
+```typescript
+import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session'
+```
+
+Also add `sessionsApi` usage — it's already imported on line 3, so no change needed there.
+
+**Step 2: Add the scratchpad save handler**
+
+Inside the `TreeNavigationPage` component, add this handler (after the existing state declarations, around line 58):
+
+```typescript
+ // Scratchpad save handler
+ const handleScratchpadSave = async (content: string) => {
+ if (!session) return
+ await sessionsApi.updateScratchpad(session.id, content)
+ }
+```
+
+**Step 3: Restructure the main return JSX to flex layout**
+
+The main return block (line 632) currently starts with:
+```tsx
+ return (
+
+```
+
+And ends (line 1009) with:
+```tsx
+
+ )
+```
+
+Replace the outer container to add a flex wrapper. The new structure should be:
+
+```tsx
+ return (
+
+ {/* Main Content */}
+
+
+ {/* ... ALL existing content (header, breadcrumb, node card, modals) stays here unchanged ... */}
+
+
+
+ {/* Scratchpad Sidebar */}
+ {session && (
+
+ )}
+
+ )
+```
+
+**Important details:**
+- The outer `div` changes from `container mx-auto px-4 py-8` to `flex h-[calc(100vh-4rem)]` (4rem = navbar height)
+- The existing content gets wrapped in ``
+- The `ScratchpadSidebar` is rendered after the main content div, as a sibling
+- All existing JSX inside (header, breadcrumb, node card, notes, back button, keyboard hints, modals) stays exactly the same
+- The error returns earlier in the component (lines 622-630) should also be updated to use the same flex wrapper for consistency, but this is optional — they can stay as-is since the sidebar isn't useful in error states
+
+**Step 4: Verify build compiles**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds with no type errors
+
+**Step 5: Manual test**
+
+Start the dev servers (`docker start patherly_postgres`, backend with `uvicorn`, frontend with `npm run dev`). Navigate to a tree, start a session, and verify:
+
+1. Scratchpad sidebar appears on the right
+2. Typing in the scratchpad auto-saves after 1 second of inactivity
+3. "Saving..." / "Saved" indicators appear correctly
+4. Collapse button hides the sidebar to a thin strip with icon
+5. Expanding restores the full sidebar with content preserved
+6. Refreshing the page preserves collapse state and scratchpad content
+7. Preview toggle renders markdown correctly
+8. Exporting the session includes scratchpad content under "Evidence / Reference"
+
+**Step 6: Commit**
+
+```bash
+git add frontend/src/pages/TreeNavigationPage.tsx
+git commit -m "feat: integrate scratchpad sidebar into tree navigation page
+
+Co-Authored-By: Claude Opus 4.5 "
+```
+
+---
+
+## Task 8: Final Validation & Build
+
+**Step 1: Run backend tests**
+
+Run: `cd backend && pytest -v`
+Expected: All tests pass (existing + 9 new scratchpad tests)
+
+**Step 2: Run frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds, no warnings
+
+**Step 3: Run frontend lint**
+
+Run: `cd frontend && npm run lint`
+Expected: No errors (warnings are acceptable)
+
+**Step 4: Verify git status is clean**
+
+Run: `git status`
+Expected: Clean working tree, all changes committed
+
+**Step 5: Push to remote**
+
+Run: `git push`
+
+---
+
+## Summary of All Files Changed
+
+| File | Action | Task |
+|------|--------|------|
+| `backend/alembic/versions/009_add_scratchpad_to_sessions.py` | Create | 1 |
+| `backend/app/models/session.py` | Modify | 2 |
+| `backend/app/schemas/session.py` | Modify | 2 |
+| `backend/tests/test_sessions.py` | Modify | 2, 3, 4 |
+| `backend/app/api/endpoints/sessions.py` | Modify | 3, 4 |
+| `frontend/src/types/session.ts` | Modify | 5 |
+| `frontend/src/api/sessions.ts` | Modify | 5 |
+| `frontend/src/components/session/ScratchpadSidebar.tsx` | Create | 6 |
+| `frontend/src/components/session/index.ts` | Modify | 6 |
+| `frontend/src/pages/TreeNavigationPage.tsx` | Modify | 7 |
+
+## Commit Sequence
+
+1. `feat: add scratchpad column to sessions table` (migration)
+2. `feat: add scratchpad field to session model and schemas` (model + tests)
+3. `feat: add PATCH endpoint for session scratchpad` (endpoint + tests)
+4. `feat: include scratchpad in session export (markdown, text, HTML)` (export + tests)
+5. `feat: add scratchpad to frontend types and API client` (types + api)
+6. `feat: add ScratchpadSidebar component with auto-save and markdown preview` (component)
+7. `feat: integrate scratchpad sidebar into tree navigation page` (integration)
diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts
index fa6d55a2..7be667cf 100644
--- a/frontend/src/api/sessions.ts
+++ b/frontend/src/api/sessions.ts
@@ -46,6 +46,11 @@ export const sessionsApi = {
const response = await apiClient.post(`/sessions/${id}/export`, options)
return response.data
},
+
+ async updateScratchpad(id: string, content: string): Promise {
+ const response = await apiClient.patch(`/sessions/${id}/scratchpad`, { scratchpad: content })
+ return response.data
+ },
}
export default sessionsApi
diff --git a/frontend/src/components/session/ScratchpadSidebar.tsx b/frontend/src/components/session/ScratchpadSidebar.tsx
new file mode 100644
index 00000000..951453af
--- /dev/null
+++ b/frontend/src/components/session/ScratchpadSidebar.tsx
@@ -0,0 +1,199 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { cn } from '@/lib/utils'
+import { MarkdownContent } from '@/components/ui/MarkdownContent'
+import { StickyNote, ChevronRight, Eye, Pencil, Loader2 } from 'lucide-react'
+
+interface ScratchpadSidebarProps {
+ sessionId: string
+ initialContent: string
+ onSave: (content: string) => Promise
+}
+
+export function ScratchpadSidebar({ sessionId, initialContent, onSave }: ScratchpadSidebarProps) {
+ const [content, setContent] = useState(initialContent)
+ const [lastSaved, setLastSaved] = useState(initialContent)
+ const [isCollapsed, setIsCollapsed] = useState(() => {
+ return localStorage.getItem('scratchpad-collapsed') === 'true'
+ })
+ const [isSaving, setIsSaving] = useState(false)
+ const [showPreview, setShowPreview] = useState(false)
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved' | 'error'>('idle')
+
+ const debounceRef = useRef | null>(null)
+ const fadeTimerRef = useRef | null>(null)
+
+ const hasUnsavedChanges = content !== lastSaved
+
+ // Reset content when session changes
+ useEffect(() => {
+ setContent(initialContent)
+ setLastSaved(initialContent)
+ setSaveStatus('idle')
+ }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Update save status based on state
+ useEffect(() => {
+ if (isSaving) {
+ setSaveStatus('saving')
+ } else if (hasUnsavedChanges) {
+ setSaveStatus('unsaved')
+ }
+ }, [isSaving, hasUnsavedChanges])
+
+ // Persist collapse state
+ useEffect(() => {
+ localStorage.setItem('scratchpad-collapsed', String(isCollapsed))
+ }, [isCollapsed])
+
+ // beforeunload warning
+ useEffect(() => {
+ if (!hasUnsavedChanges) return
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault()
+ }
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [hasUnsavedChanges])
+
+ const doSave = useCallback(async (text: string) => {
+ setIsSaving(true)
+ try {
+ await onSave(text)
+ setLastSaved(text)
+ setSaveStatus('saved')
+
+ // Clear "Saved" indicator after 2 seconds
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
+ fadeTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000)
+ } catch {
+ setSaveStatus('error')
+ } finally {
+ setIsSaving(false)
+ }
+ }, [onSave])
+
+ const handleChange = (value: string) => {
+ setContent(value)
+
+ // Cancel any pending debounce
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+
+ // Schedule save after 1000ms of inactivity
+ debounceRef.current = setTimeout(() => {
+ if (value !== lastSaved) {
+ doSave(value)
+ }
+ }, 1000)
+ }
+
+ const handleBlur = () => {
+ // Cancel pending debounce and save immediately
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (content !== lastSaved) {
+ doSave(content)
+ }
+ }
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current)
+ }
+ }, [])
+
+ if (isCollapsed) {
+ return (
+
+
+ {hasUnsavedChanges && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Scratchpad
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {showPreview ? (
+
+ {content.trim() ? (
+
+ ) : (
+
Nothing to preview
+ )}
+
+ ) : (
+
+
+ {/* Save Indicator */}
+
+
+ {saveStatus === 'unsaved' && (
+ Unsaved changes
+ )}
+ {saveStatus === 'saving' && (
+ <>
+
+ Saving...
+ >
+ )}
+ {saveStatus === 'saved' && (
+ Saved
+ )}
+ {saveStatus === 'error' && (
+ Save failed
+ )}
+ {saveStatus === 'idle' && (
+ Markdown supported
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/session/index.ts b/frontend/src/components/session/index.ts
index f22d8a04..83a67464 100644
--- a/frontend/src/components/session/index.ts
+++ b/frontend/src/components/session/index.ts
@@ -1,3 +1,4 @@
export { PostStepActionModal } from './PostStepActionModal'
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal'
+export { ScratchpadSidebar } from './ScratchpadSidebar'
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx
index aacd3820..c6e3199a 100644
--- a/frontend/src/pages/TreeNavigationPage.tsx
+++ b/frontend/src/pages/TreeNavigationPage.tsx
@@ -7,7 +7,7 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
-import { PostStepActionModal, ContinuationModal, ForkTreeModal, type DescendantNode } from '@/components/session'
+import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session'
import { Plus, CheckCircle, ArrowRight } from 'lucide-react'
interface LocationState {
@@ -56,6 +56,12 @@ export function TreeNavigationPage() {
// Fork flow
const [showForkModal, setShowForkModal] = useState(false)
+ // Scratchpad save handler
+ const handleScratchpadSave = async (content: string) => {
+ if (!session) return
+ await sessionsApi.updateScratchpad(session.id, content)
+ }
+
useEffect(() => {
if (treeId) {
loadTreeAndSession()
@@ -630,7 +636,10 @@ export function TreeNavigationPage() {
}
return (
-
+
+ {/* Main Content */}
+
+
{/* Header */}
@@ -1006,6 +1015,17 @@ export function TreeNavigationPage() {
onFork={handleForkTree}
onSkip={handleSkipFork}
/>
+
+
+
+ {/* Scratchpad Sidebar */}
+ {session && (
+
+ )}
)
}
diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts
index cf48e629..097e5c30 100644
--- a/frontend/src/types/session.ts
+++ b/frontend/src/types/session.ts
@@ -40,6 +40,7 @@ export interface Session {
ticket_number: string | null
client_name: string | null
exported: boolean
+ scratchpad: string
}
export interface SessionCreate {
@@ -54,6 +55,7 @@ export interface SessionUpdate {
custom_steps?: CustomStep[]
ticket_number?: string
client_name?: string
+ scratchpad?: string
}
export interface SessionExport {