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

1070 lines
35 KiB
Markdown

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