Merge branch 'feat/session-scratchpad'

This commit was merged in pull request #27.
This commit is contained in:
Michael Chihlas
2026-02-04 03:13:42 -05:00
11 changed files with 1686 additions and 5 deletions

View File

@@ -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')

View File

@@ -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'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html.append('</div>')
# 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>')
for i, decision in enumerate(session.decisions, 1):

View File

@@ -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")

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,11 @@ export const sessionsApi = {
const response = await apiClient.post<string>(`/sessions/${id}/export`, options)
return response.data
},
async updateScratchpad(id: string, content: string): Promise<Session> {
const response = await apiClient.patch<Session>(`/sessions/${id}/scratchpad`, { scratchpad: content })
return response.data
},
}
export default sessionsApi

View File

@@ -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<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...\n\nSupports 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>
)
}

View File

@@ -1,3 +1,4 @@
export { PostStepActionModal } from './PostStepActionModal'
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal'
export { ScratchpadSidebar } from './ScratchpadSidebar'

View File

@@ -7,7 +7,7 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, type DescendantNode } from '@/components/session'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session'
import { Plus, CheckCircle, ArrowRight } from 'lucide-react'
interface LocationState {
@@ -56,6 +56,12 @@ export function TreeNavigationPage() {
// Fork flow
const [showForkModal, setShowForkModal] = useState(false)
// Scratchpad save handler
const handleScratchpadSave = async (content: string) => {
if (!session) return
await sessionsApi.updateScratchpad(session.id, content)
}
useEffect(() => {
if (treeId) {
loadTreeAndSession()
@@ -630,7 +636,10 @@ export function TreeNavigationPage() {
}
return (
<div className="container mx-auto px-4 py-8">
<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">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
@@ -1006,6 +1015,17 @@ export function TreeNavigationPage() {
onFork={handleForkTree}
onSkip={handleSkipFork}
/>
</div>
</div>
{/* Scratchpad Sidebar */}
{session && (
<ScratchpadSidebar
sessionId={session.id}
initialContent={session.scratchpad ?? ''}
onSave={handleScratchpadSave}
/>
)}
</div>
)
}

View File

@@ -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 {