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>
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 aftercomplete_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... 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
divchanges fromcontainer mx-auto px-4 py-8toflex 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
ScratchpadSidebaris 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:
- Scratchpad sidebar appears on the right
- Typing in the scratchpad auto-saves after 1 second of inactivity
- "Saving..." / "Saved" indicators appear correctly
- Collapse button hides the sidebar to a thin strip with icon
- Expanding restores the full sidebar with content preserved
- Refreshing the page preserves collapse state and scratchpad content
- Preview toggle renders markdown correctly
- 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
feat: add scratchpad column to sessions table(migration)feat: add scratchpad field to session model and schemas(model + tests)feat: add PATCH endpoint for session scratchpad(endpoint + tests)feat: include scratchpad in session export (markdown, text, HTML)(export + tests)feat: add scratchpad to frontend types and API client(types + api)feat: add ScratchpadSidebar component with auto-save and markdown preview(component)feat: integrate scratchpad sidebar into tree navigation page(integration)