Merge branch 'feat/session-scratchpad'
This commit was merged in pull request #27.
This commit is contained in:
30
backend/alembic/versions/009_add_scratchpad_to_sessions.py
Normal file
30
backend/alembic/versions/009_add_scratchpad_to_sessions.py
Normal 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')
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1069
docs/plans/2026-02-04-session-scratchpad-implementation.md
Normal file
1069
docs/plans/2026-02-04-session-scratchpad-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
199
frontend/src/components/session/ScratchpadSidebar.tsx
Normal file
199
frontend/src/components/session/ScratchpadSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { PostStepActionModal } from './PostStepActionModal'
|
||||
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
||||
export { ForkTreeModal } from './ForkTreeModal'
|
||||
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user