From d488d2acc8c218e7faf08c125a98750d84f6e5f6 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:47:18 -0500 Subject: [PATCH 1/8] feat: add scratchpad column to sessions table Co-Authored-By: Claude Opus 4.5 --- .../009_add_scratchpad_to_sessions.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/alembic/versions/009_add_scratchpad_to_sessions.py 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') From 7d0000827bee0c428a40b3a95f69b7e3b98d3846 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:47:22 -0500 Subject: [PATCH 2/8] feat: add scratchpad field to session model and schemas Co-Authored-By: Claude Opus 4.5 --- backend/app/models/session.py | 6 ++++- backend/app/schemas/session.py | 12 ++++++++- backend/tests/test_sessions.py | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) 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..2e50a119 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -315,3 +315,51 @@ 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"] From 6da2044b209d71398b52844d727ea9454be02d10 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:48:10 -0500 Subject: [PATCH 3/8] feat: add PATCH endpoint for session scratchpad Co-Authored-By: Claude Opus 4.5 --- backend/app/api/endpoints/sessions.py | 31 ++++++++- backend/tests/test_sessions.py | 98 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 2054d651..86286b6c 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, diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index 2e50a119..d7994de4 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -363,3 +363,101 @@ class TestSessions: 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" From 7824cddd71ec5252991708b05daf7e235fe90b58 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:49:11 -0500 Subject: [PATCH 4/8] feat: include scratchpad in session export (markdown, text, HTML) Co-Authored-By: Claude Opus 4.5 --- backend/app/api/endpoints/sessions.py | 24 +++++ backend/tests/test_sessions.py | 142 ++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 86286b6c..922563b3 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -273,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("") @@ -311,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) @@ -360,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/tests/test_sessions.py b/backend/tests/test_sessions.py index d7994de4..3ad858bc 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -461,3 +461,145 @@ class TestSessions: 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 From a92671157f7fef49c3b1dd2ee3e3defdeddd5d01 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:49:50 -0500 Subject: [PATCH 5/8] feat: add scratchpad to frontend types and API client Co-Authored-By: Claude Opus 4.5 --- frontend/src/api/sessions.ts | 5 +++++ frontend/src/types/session.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index fa6d55a2..7be667cf 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -46,6 +46,11 @@ export const sessionsApi = { const response = await apiClient.post(`/sessions/${id}/export`, options) return response.data }, + + async updateScratchpad(id: string, content: string): Promise { + const response = await apiClient.patch(`/sessions/${id}/scratchpad`, { scratchpad: content }) + return response.data + }, } export default sessionsApi diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index cf48e629..097e5c30 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -40,6 +40,7 @@ export interface Session { ticket_number: string | null client_name: string | null exported: boolean + scratchpad: string } export interface SessionCreate { @@ -54,6 +55,7 @@ export interface SessionUpdate { custom_steps?: CustomStep[] ticket_number?: string client_name?: string + scratchpad?: string } export interface SessionExport { From 26cf66e239d80ada7a2b1dac71bce290fd4609bc Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Feb 2026 02:50:37 -0500 Subject: [PATCH 6/8] feat: add ScratchpadSidebar component with auto-save and markdown preview Co-Authored-By: Claude Opus 4.5 --- .../components/session/ScratchpadSidebar.tsx | 199 ++++++++++++++++++ frontend/src/components/session/index.ts | 1 + 2 files changed, 200 insertions(+) create mode 100644 frontend/src/components/session/ScratchpadSidebar.tsx diff --git a/frontend/src/components/session/ScratchpadSidebar.tsx b/frontend/src/components/session/ScratchpadSidebar.tsx new file mode 100644 index 00000000..951453af --- /dev/null +++ b/frontend/src/components/session/ScratchpadSidebar.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { StickyNote, ChevronRight, 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

+ )} +
+ ) : ( +