Files
resolutionflow/docs/archive/2026-02-04-session-scratchpad-implementation.md
Michael Chihlas 89d343d49a chore: archive 11 completed plan documents
Move completed design/implementation docs from docs/plans/ to docs/archive/
to keep the plans folder focused on active and future work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:51:21 -05:00

35 KiB

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:

"""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

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

    @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:

from sqlalchemy import String, DateTime, ForeignKey, Boolean, Text

Add import sqlalchemy as sa after existing imports (line 5):

import sqlalchemy as sa

Add scratchpad field after exported (after line 46):

    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:

from pydantic import BaseModel, Field, validator

Add scratchpad to SessionUpdate (after line 29):

    scratchpad: Optional[str] = None

Add scratchpad to SessionResponse (after line 44, before class Config):

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


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

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

    @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:

from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate

Add this endpoint after the complete_session function (after line 183, before export_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

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

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

    @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:

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

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

    # 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

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

  scratchpad: string

Step 2: Add scratchpad to the SessionUpdate interface

In frontend/src/types/session.ts, add to SessionUpdate (after line 56):

  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 }):


  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

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:

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:

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

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

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

  // 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:

  return (
    <div className="container mx-auto px-4 py-8">

And ends (line 1009) with:

    </div>
  )

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

  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

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)