diff --git a/backend/alembic/versions/009_add_scratchpad_to_sessions.py b/backend/alembic/versions/009_add_scratchpad_to_sessions.py new file mode 100644 index 00000000..7a12839a --- /dev/null +++ b/backend/alembic/versions/009_add_scratchpad_to_sessions.py @@ -0,0 +1,30 @@ +"""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') diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 2054d651..922563b3 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -10,7 +10,7 @@ from app.core.database import get_db from app.models.tree import Tree from app.models.session import Session from app.models.user import User -from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport +from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate from app.api.deps import get_current_user router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -183,6 +183,35 @@ async def complete_session( return 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 + + @router.post("/{session_id}/export") async def export_session( session_id: UUID, @@ -244,6 +273,16 @@ def _generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append("---") lines.append("") + # 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") lines.append("") @@ -282,6 +321,14 @@ def _generate_text_export(session: Session, options: SessionExport) -> str: lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}") lines.append("") + # 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) @@ -331,6 +378,12 @@ def _generate_html_export(session: Session, options: SessionExport) -> str: html.append(f'

Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}

') html.append('') + # Scratchpad / Evidence section + scratchpad = getattr(session, 'scratchpad', '') or '' + if scratchpad.strip(): + html.append('

Evidence / Reference

') + html.append(f'
{scratchpad}
') + html.append('

Troubleshooting Steps

') for i, decision in enumerate(session.decisions, 1): diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 4d222519..bec2cdd6 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -1,7 +1,8 @@ import uuid from datetime import datetime, timezone from typing import Optional, Any -from sqlalchemy import String, DateTime, ForeignKey, Boolean +from sqlalchemy import String, DateTime, ForeignKey, Boolean, Text +import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base @@ -44,6 +45,9 @@ class Session(Base): ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) exported: Mapped[bool] = mapped_column(Boolean, default=False) + scratchpad: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, server_default=sa.text("''") + ) # Relationships tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions") diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index ab0033cf..b4ab6377 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional, Any from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator class DecisionRecord(BaseModel): @@ -27,6 +27,7 @@ class SessionUpdate(BaseModel): custom_steps: Optional[list[dict[str, Any]]] = None ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) + scratchpad: Optional[str] = None class SessionResponse(BaseModel): @@ -42,6 +43,11 @@ class SessionResponse(BaseModel): ticket_number: Optional[str] = None client_name: Optional[str] = None exported: bool + scratchpad: str = "" + + @validator('scratchpad', pre=True, always=True) + def normalize_scratchpad(cls, v): + return v or "" class Config: from_attributes = True @@ -51,3 +57,7 @@ class SessionExport(BaseModel): format: str = Field(default="markdown", pattern="^(text|markdown|html)$") include_timestamps: bool = True include_tree_info: bool = True + + +class ScratchpadUpdate(BaseModel): + scratchpad: str diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index b8263caa..3ad858bc 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -315,3 +315,291 @@ class TestSessions: data = response.json() assert len(data) >= 1 assert all(s["completed_at"] is None for s in data) + + @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"] + + @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" + + @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 diff --git a/docs/plans/2026-02-04-session-scratchpad-implementation.md b/docs/plans/2026-02-04-session-scratchpad-implementation.md new file mode 100644 index 00000000..cc7184c9 --- /dev/null +++ b/docs/plans/2026-02-04-session-scratchpad-implementation.md @@ -0,0 +1,1069 @@ +# 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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('

Troubleshooting Steps

')` line: + +```python + # Scratchpad / Evidence section + scratchpad = getattr(session, 'scratchpad', '') or '' + if scratchpad.strip(): + html.append('

Evidence / Reference

') + html.append(f'
{scratchpad}
') + + html.append('

Troubleshooting Steps

') +``` + +This replaces the existing `html.append('

Troubleshooting Steps

')` 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 " +``` + +--- + +## 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 { + const response = await apiClient.patch(`/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 " +``` + +--- + +## 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 +} + +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 | null>(null) + const fadeTimerRef = useRef | 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 ( +
+ + {hasUnsavedChanges && ( +
+ )} +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + Scratchpad +
+
+ + +
+
+ + {/* Content */} +
+ {showPreview ? ( +
+ {content.trim() ? ( + + ) : ( +

Nothing to preview

+ )} +
+ ) : ( +