# 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 <noreply@anthropic.com>"
```

---

## 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 <noreply@anthropic.com>"
```

---

## 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 <noreply@anthropic.com>"
```

---

## 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('<h2>Troubleshooting Steps</h2>')` line:

```python
    # Scratchpad / Evidence section
    scratchpad = getattr(session, 'scratchpad', '') or ''
    if scratchpad.strip():
        html.append('<h2>Evidence / Reference</h2>')
        html.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{scratchpad}</div>')

    html.append('<h2>Troubleshooting Steps</h2>')
```

This replaces the existing `html.append('<h2>Troubleshooting Steps</h2>')` 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 <noreply@anthropic.com>"
```

---

## 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<Session> {
    const response = await apiClient.patch<Session>(`/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 <noreply@anthropic.com>"
```

---

## 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<void>
}

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<ReturnType<typeof setTimeout> | null>(null)
  const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
      <div className="flex w-12 flex-shrink-0 flex-col items-center border-l border-border bg-card pt-4">
        <button
          onClick={() => setIsCollapsed(false)}
          className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
          title="Open scratchpad"
        >
          <StickyNote className="h-5 w-5" />
        </button>
        {hasUnsavedChanges && (
          <div className="mt-2 h-2 w-2 rounded-full bg-amber-500" title="Unsaved changes" />
        )}
      </div>
    )
  }

  return (
    <div className="flex w-[300px] flex-shrink-0 flex-col border-l border-border bg-card">
      {/* Header */}
      <div className="flex items-center justify-between border-b border-border px-3 py-2">
        <div className="flex items-center gap-2">
          <StickyNote className="h-4 w-4 text-muted-foreground" />
          <span className="text-sm font-medium text-foreground">Scratchpad</span>
        </div>
        <div className="flex items-center gap-1">
          <button
            onClick={() => setShowPreview(!showPreview)}
            className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
            title={showPreview ? 'Edit' : 'Preview'}
          >
            {showPreview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
          </button>
          <button
            onClick={() => setIsCollapsed(true)}
            className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
            title="Collapse scratchpad"
          >
            <ChevronRight className="h-4 w-4" />
          </button>
        </div>
      </div>

      {/* Content */}
      <div className="flex-1 overflow-y-auto p-3">
        {showPreview ? (
          <div className="min-h-[100px]">
            {content.trim() ? (
              <MarkdownContent content={content} className="text-sm" />
            ) : (
              <p className="text-sm italic text-muted-foreground">Nothing to preview</p>
            )}
          </div>
        ) : (
          <textarea
            value={content}
            onChange={(e) => handleChange(e.target.value)}
            onBlur={handleBlur}
            placeholder="Capture IPs, error codes, server names, user info...&#10;&#10;Supports markdown formatting."
            className={cn(
              'h-full min-h-[200px] w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm',
              'text-foreground placeholder:text-muted-foreground',
              'focus:outline-none focus:ring-0'
            )}
          />
        )}
      </div>

      {/* Save Indicator */}
      <div className="border-t border-border px-3 py-1.5">
        <div className="flex items-center gap-1.5 text-xs">
          {saveStatus === 'unsaved' && (
            <span className="text-muted-foreground">Unsaved changes</span>
          )}
          {saveStatus === 'saving' && (
            <>
              <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
              <span className="text-muted-foreground">Saving...</span>
            </>
          )}
          {saveStatus === 'saved' && (
            <span className="text-green-600 dark:text-green-400">Saved</span>
          )}
          {saveStatus === 'error' && (
            <span className="text-destructive">Save failed</span>
          )}
          {saveStatus === 'idle' && (
            <span className="text-muted-foreground/50">Markdown supported</span>
          )}
        </div>
      </div>
    </div>
  )
}
```

**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 <noreply@anthropic.com>"
```

---

## 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 (
    <div className="container mx-auto px-4 py-8">
```

And ends (line 1009) with:
```tsx
    </div>
  )
```

Replace the outer container to add a flex wrapper. The new structure should be:

```tsx
  return (
    <div className="flex h-[calc(100vh-4rem)]">
      {/* Main Content */}
      <div className="flex-1 min-w-0 overflow-y-auto px-4 py-8">
        <div className="mx-auto max-w-4xl">
          {/* ... ALL existing content (header, breadcrumb, node card, modals) stays here unchanged ... */}
        </div>
      </div>

      {/* Scratchpad Sidebar */}
      {session && (
        <ScratchpadSidebar
          sessionId={session.id}
          initialContent={session.scratchpad ?? ''}
          onSave={handleScratchpadSave}
        />
      )}
    </div>
  )
```

**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 `<div className="flex-1 min-w-0 overflow-y-auto px-4 py-8"><div className="mx-auto max-w-4xl">...</div></div>`
- 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 <noreply@anthropic.com>"
```

---

## 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)
