# 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

)}
) : (