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>
1070 lines
35 KiB
Markdown
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... 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)
|