feat: add PSA ticket export format and Quick-Start landing page

PSA Export:
- New "PSA / Ticket Note" export format optimized for ConnectWise
- Structured output: Problem, Steps Taken, Resolution, Time Spent, Notes
- Prominent "Copy for Ticket" button on session detail page
- 24 unit tests for PSA export generator

Quick-Start Landing:
- New default landing page with search-first UX
- Auto-focused search bar with debounced tree search
- "Continue Session" cards for active sessions
- "Recent Trees" section from session history
- Home nav item and logo links updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-08 19:36:51 -05:00
parent f2ae3a51fa
commit 4f8b7dd7ca
11 changed files with 621 additions and 13 deletions

View File

@@ -13,7 +13,7 @@ from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -288,6 +288,9 @@ async def export_session(
elif export_options.format == "html":
content = generate_html_export(session, export_options)
media_type = "text/html"
elif export_options.format == "psa":
content = generate_psa_export(session, export_options)
media_type = "text/plain"
else: # text
content = generate_text_export(session, export_options)
media_type = "text/plain"

View File

@@ -70,7 +70,7 @@ class SessionResponse(BaseModel):
class SessionExport(BaseModel):
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
include_timestamps: bool = True
include_tree_info: bool = True

View File

@@ -1,15 +1,31 @@
"""
Session export generators for ResolutionFlow.
Provides markdown, plain text, and HTML export formatters
Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
from datetime import datetime, timezone
from app.models.session import Session
from app.schemas.session import SessionExport
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
"""Format duration between two datetimes as human-readable string."""
if not completed_at:
return "In progress"
delta = completed_at - started_at
total_seconds = int(delta.total_seconds())
if total_seconds < 0:
return "0 minutes"
hours, remainder = divmod(total_seconds, 3600)
minutes = remainder // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes} minutes"
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
@@ -160,3 +176,65 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)
def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
lines = []
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_description = session.tree_snapshot.get("description", "")
ticket_number = session.ticket_number or "N/A"
client_name = session.client_name or "N/A"
date_str = session.started_at.strftime("%Y-%m-%d %H:%M")
lines.append("=== TROUBLESHOOTING NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Tree: {tree_name} | Date: {date_str}")
lines.append("")
# Problem section
lines.append("--- PROBLEM ---")
lines.append(tree_description if tree_description else "No description provided.")
lines.append("")
# Steps taken
lines.append("--- STEPS TAKEN ---")
if session.decisions:
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
line = f"{i}. {question}"
if answer:
line += f" -> {answer}"
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
else:
lines.append("No steps recorded.")
lines.append("")
# Resolution - last decision answer
lines.append("--- RESOLUTION ---")
if session.decisions:
last_decision = session.decisions[-1]
resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded")
lines.append(resolution)
else:
lines.append("No resolution recorded.")
lines.append("")
# Time spent
lines.append("--- TIME SPENT ---")
duration = _format_duration(session.started_at, session.completed_at)
lines.append(f"Duration: {duration}")
lines.append("")
# Engineer notes (scratchpad)
lines.append("--- ENGINEER NOTES ---")
scratchpad = getattr(session, 'scratchpad', '') or ''
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
return "\n".join(lines)

View File

@@ -0,0 +1,233 @@
"""Tests for PSA/ticket note export format.
Covers: all fields present, missing optional fields, duration calculation,
incomplete sessions, and edge cases.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from app.schemas.session import SessionExport
from app.services.export_service import generate_psa_export, _format_duration
def _make_session(
tree_name="Test Tree",
tree_description="A test problem description",
ticket_number=None,
client_name=None,
decisions=None,
scratchpad="",
started_at=None,
completed_at=None,
):
"""Create a mock session object for PSA export testing."""
session = MagicMock()
session.tree_snapshot = {"name": tree_name, "description": tree_description}
session.ticket_number = ticket_number
session.client_name = client_name
session.decisions = decisions or []
session.scratchpad = scratchpad
session.started_at = started_at or datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
session.completed_at = completed_at or datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc)
return session
def _default_options():
return SessionExport(format="psa", include_timestamps=True, include_tree_info=True)
class TestPsaExportAllFields:
"""Test PSA export with all fields populated."""
def test_includes_header(self):
session = _make_session(
ticket_number="TK-5001",
client_name="Contoso Corp",
)
result = generate_psa_export(session, _default_options())
assert "=== TROUBLESHOOTING NOTES ===" in result
assert "Ticket: TK-5001" in result
assert "Client: Contoso Corp" in result
def test_includes_tree_name_and_date(self):
session = _make_session(tree_name="DNS Troubleshooting")
result = generate_psa_export(session, _default_options())
assert "Tree: DNS Troubleshooting" in result
assert "Date: 2026-01-15 10:00" in result
def test_includes_problem_section(self):
session = _make_session(tree_description="Client cannot resolve DNS names")
result = generate_psa_export(session, _default_options())
assert "--- PROBLEM ---" in result
assert "Client cannot resolve DNS names" in result
def test_includes_steps_taken(self):
session = _make_session(decisions=[
{"question": "Is the DNS service running?", "answer": "Yes", "notes": "Checked services"},
{"question": "Can you ping the DNS server?", "answer": "No"},
])
result = generate_psa_export(session, _default_options())
assert "--- STEPS TAKEN ---" in result
assert "1. Is the DNS service running? -> Yes" in result
assert " Notes: Checked services" in result
assert "2. Can you ping the DNS server? -> No" in result
def test_includes_resolution(self):
session = _make_session(decisions=[
{"question": "Check cable", "answer": "Connected"},
{"question": "Restart service", "answer": "Service restarted successfully"},
])
result = generate_psa_export(session, _default_options())
assert "--- RESOLUTION ---" in result
assert "Service restarted successfully" in result
def test_includes_duration(self):
session = _make_session(
started_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc),
)
result = generate_psa_export(session, _default_options())
assert "--- TIME SPENT ---" in result
assert "Duration: 1h 30m" in result
def test_includes_engineer_notes(self):
session = _make_session(scratchpad="Checked firewall rules, port 53 was blocked")
result = generate_psa_export(session, _default_options())
assert "--- ENGINEER NOTES ---" in result
assert "Checked firewall rules, port 53 was blocked" in result
class TestPsaExportMissingFields:
"""Test PSA export gracefully handles missing optional fields."""
def test_missing_ticket_number(self):
session = _make_session(ticket_number=None)
result = generate_psa_export(session, _default_options())
assert "Ticket: N/A" in result
def test_missing_client_name(self):
session = _make_session(client_name=None)
result = generate_psa_export(session, _default_options())
assert "Client: N/A" in result
def test_missing_description(self):
session = _make_session(tree_description="")
result = generate_psa_export(session, _default_options())
assert "No description provided." in result
def test_empty_scratchpad(self):
session = _make_session(scratchpad="")
result = generate_psa_export(session, _default_options())
assert "--- ENGINEER NOTES ---" in result
lines = result.split("\n")
notes_idx = lines.index("--- ENGINEER NOTES ---")
assert lines[notes_idx + 1] == "None"
def test_whitespace_only_scratchpad(self):
session = _make_session(scratchpad=" \n \n ")
result = generate_psa_export(session, _default_options())
lines = result.split("\n")
notes_idx = lines.index("--- ENGINEER NOTES ---")
assert lines[notes_idx + 1] == "None"
def test_no_decisions(self):
session = _make_session(decisions=[])
result = generate_psa_export(session, _default_options())
assert "No steps recorded." in result
assert "No resolution recorded." in result
def test_decision_with_action_performed_fallback(self):
session = _make_session(decisions=[
{"action_performed": "Restarted the server", "answer": "Done"},
])
result = generate_psa_export(session, _default_options())
assert "1. Restarted the server -> Done" in result
def test_decision_without_answer(self):
session = _make_session(decisions=[
{"question": "Check logs"},
])
result = generate_psa_export(session, _default_options())
assert "1. Check logs" in result
# No arrow when answer is missing
assert "->" not in result
class TestDurationCalculation:
"""Test the _format_duration helper."""
def test_hours_and_minutes(self):
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 1, 15, 12, 45, tzinfo=timezone.utc)
assert _format_duration(start, end) == "2h 45m"
def test_minutes_only(self):
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 1, 15, 10, 25, tzinfo=timezone.utc)
assert _format_duration(start, end) == "25 minutes"
def test_zero_minutes(self):
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 1, 15, 10, 0, 30, tzinfo=timezone.utc)
assert _format_duration(start, end) == "0 minutes"
def test_incomplete_session(self):
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
assert _format_duration(start, None) == "In progress"
def test_exact_hour(self):
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc)
assert _format_duration(start, end) == "1h 0m"
class TestPsaExportIncompleteSession:
"""Test PSA export for sessions that are not yet completed."""
def test_incomplete_session_shows_in_progress_duration(self):
session = _make_session(completed_at=None)
# Override completed_at to None explicitly
session.completed_at = None
result = generate_psa_export(session, _default_options())
assert "Duration: In progress" in result
def test_incomplete_session_with_steps(self):
session = _make_session(
decisions=[{"question": "First step", "answer": "Done"}],
completed_at=None,
)
session.completed_at = None
result = generate_psa_export(session, _default_options())
assert "1. First step -> Done" in result
assert "Duration: In progress" in result
class TestPsaExportFormat:
"""Test the overall format structure of PSA export."""
def test_section_order(self):
session = _make_session(
ticket_number="TK-100",
client_name="Acme",
decisions=[{"question": "Step 1", "answer": "Yes"}],
scratchpad="Some notes",
)
result = generate_psa_export(session, _default_options())
sections = [
"=== TROUBLESHOOTING NOTES ===",
"--- PROBLEM ---",
"--- STEPS TAKEN ---",
"--- RESOLUTION ---",
"--- TIME SPENT ---",
"--- ENGINEER NOTES ---",
]
positions = [result.index(s) for s in sections]
assert positions == sorted(positions), "Sections should appear in the expected order"
def test_format_validation_accepts_psa(self):
"""Verify the schema accepts 'psa' as a valid format."""
export = SessionExport(format="psa")
assert export.format == "psa"

View File

@@ -47,6 +47,7 @@ export function AppLayout() {
}, [mobileMenuOpen, handleKeyDown])
const navItems = [
{ path: '/', label: 'Home' },
{ path: '/trees', label: 'Trees' },
{ path: '/my-trees', label: 'My Trees' },
{ path: '/sessions', label: 'Sessions' },
@@ -70,7 +71,7 @@ export function AppLayout() {
<Menu className="h-5 w-5" />
</button>
<Link to="/trees" className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2">
<BrandLogo size="sm" />
<BrandWordmark size="sm" />
</Link>
@@ -81,7 +82,7 @@ export function AppLayout() {
to={item.path}
className={cn(
'relative rounded-md px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith(item.path)
(item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
@@ -137,7 +138,7 @@ export function AppLayout() {
{/* Drawer */}
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-card shadow-xl animate-slide-in-left">
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<Link to="/trees" className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2">
<BrandLogo size="sm" />
<BrandWordmark size="sm" />
</Link>
@@ -180,7 +181,7 @@ export function AppLayout() {
to={item.path}
className={cn(
'block rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
location.pathname.startsWith(item.path)
(item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}

View File

@@ -8,7 +8,7 @@ interface ExportPreviewModalProps {
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html'
format: 'markdown' | 'text' | 'html' | 'psa'
onDownload: () => void
}

View File

@@ -0,0 +1,253 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Search, Clock, ArrowRight, Play, Loader2 } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
import type { Session } from '@/types/session'
import { cn } from '@/lib/utils'
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function QuickStartPage() {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
const [isSearching, setIsSearching] = useState(false)
const [showResults, setShowResults] = useState(false)
const [activeSessions, setActiveSessions] = useState<Session[]>([])
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
const [isLoading, setIsLoading] = useState(true)
const searchRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Load sessions on mount
useEffect(() => {
async function loadData() {
try {
const [active, recent] = await Promise.all([
sessionsApi.list({ completed: false, size: 5 }),
sessionsApi.list({ size: 10 }),
])
setActiveSessions(active.slice(0, 3))
// Deduplicate recent sessions by tree_id, max 5
const seen = new Set<string>()
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
for (const s of recent) {
if (!seen.has(s.tree_id) && deduped.length < 5) {
seen.add(s.tree_id)
deduped.push({
tree_id: s.tree_id,
name: s.tree_snapshot?.name || 'Unnamed Tree',
lastUsed: s.started_at,
})
}
}
setRecentTrees(deduped)
} catch (err) {
console.error('Failed to load sessions:', err)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Debounced search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 2) {
setSearchResults([])
setShowResults(false)
setIsSearching(false)
return
}
setIsSearching(true)
setShowResults(true)
debounceRef.current = setTimeout(async () => {
try {
const results = await treesApi.search(query, 8)
setSearchResults(results)
} catch (err) {
console.error('Search failed:', err)
setSearchResults([])
} finally {
setIsSearching(false)
}
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query])
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowResults(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
return (
<div className="container mx-auto px-4 py-8">
{/* Hero Section */}
<div className="mx-auto max-w-2xl text-center">
<h1 className="font-heading text-3xl font-bold text-foreground">
What are you troubleshooting?
</h1>
<div ref={searchRef} className="relative mt-6">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.length >= 2 && setShowResults(true)}
placeholder="Paste ticket subject or search for a tree..."
className={cn(
'w-full rounded-lg border border-border bg-card py-3 pl-12 pr-4 text-lg',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-primary/50'
)}
/>
</div>
{/* Search Results Dropdown */}
{showResults && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg">
{isSearching ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : searchResults.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No results found
</div>
) : (
<ul className="max-h-80 overflow-y-auto py-1">
{searchResults.map((tree) => (
<li key={tree.id}>
<button
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
>
<div className="text-sm font-medium text-foreground">
{tree.name}
</div>
{tree.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{tree.description}
</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
{/* Continue Session Section */}
{activeSessions.length > 0 && (
<div className="mx-auto mt-12 max-w-4xl">
<h2 className="font-heading text-lg font-semibold text-foreground">
Continue Session
</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{activeSessions.map((session) => (
<button
key={session.id}
onClick={() =>
navigate(`/trees/${session.tree_id}/navigate`, {
state: { sessionId: session.id },
})
}
className="rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{session.tree_snapshot?.name || 'Unnamed Tree'}
</div>
{(session.ticket_number || session.client_name) && (
<div className="mt-1 truncate text-xs text-muted-foreground">
{[session.ticket_number, session.client_name]
.filter(Boolean)
.join(' - ')}
</div>
)}
</div>
<Play className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{timeAgo(session.started_at)}</span>
</div>
</button>
))}
</div>
</div>
)}
{/* Recent Trees Section */}
{!isLoading && recentTrees.length > 0 && (
<div className="mx-auto mt-10 max-w-4xl">
<h2 className="font-heading text-lg font-semibold text-foreground">
Recent Trees
</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{recentTrees.map((tree) => (
<button
key={tree.tree_id}
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
className="rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
<div className="truncate text-sm font-medium text-foreground">
{tree.name}
</div>
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{timeAgo(tree.lastUsed)}</span>
</div>
</button>
))}
</div>
</div>
)}
{/* Footer */}
<div className="mx-auto mt-12 max-w-4xl text-center">
<Link
to="/trees"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Browse All Trees
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
)
}
export default QuickStartPage

View File

@@ -19,10 +19,11 @@ export function SessionDetailPage() {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>(defaultExportFormat)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa'>(defaultExportFormat)
const [exportContent, setExportContent] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [copied, setCopied] = useState(false)
const [copiedPsa, setCopiedPsa] = useState(false)
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
const [isSavingTree, setIsSavingTree] = useState(false)
const [showRatingModal, setShowRatingModal] = useState(false)
@@ -81,7 +82,7 @@ export function SessionDetailPage() {
const getFilename = () => {
if (!session) return 'export.txt'
const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'
const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt' // psa and text both use .txt
return `session-${session.ticket_number || session.id}.${ext}`
}
@@ -129,6 +130,27 @@ export function SessionDetailPage() {
}
}
const handleCopyForTicket = async () => {
if (!session) return
try {
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
}
const content = await sessionsApi.export(session.id, options)
if (content) {
await navigator.clipboard.writeText(content)
setCopiedPsa(true)
setTimeout(() => setCopiedPsa(false), 2000)
toast.success('Copied ticket notes to clipboard')
}
} catch (err) {
console.error('Copy for ticket failed:', err)
toast.error('Failed to copy ticket notes')
}
}
const handleDownload = () => {
if (!exportContent || !session) return
const blob = new Blob([exportContent], { type: 'text/plain' })
@@ -273,6 +295,18 @@ export function SessionDetailPage() {
</button>
)}
{/* Copy for Ticket */}
<button
onClick={handleCopyForTicket}
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
{/* Export Controls */}
<div className="flex items-center gap-2">
<select
@@ -287,6 +321,7 @@ export function SessionDetailPage() {
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
<button
onClick={handleCopy}

View File

@@ -9,6 +9,7 @@ import {
} from '@/pages'
// Lazy load heavy pages for code splitting
const QuickStartPage = lazy(() => import('@/pages/QuickStartPage'))
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
const MyTreesPage = lazy(() => import('@/pages/MyTreesPage'))
const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
@@ -54,7 +55,11 @@ export const router = createBrowserRouter([
children: [
{
index: true,
element: <Navigate to="/trees" replace />,
element: (
<Suspense fallback={<PageLoader />}>
<QuickStartPage />
</Suspense>
),
},
{
path: 'trees',

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type ExportFormat = 'markdown' | 'text' | 'html'
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
type TreeLibraryView = 'grid' | 'list' | 'table'
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'

View File

@@ -67,7 +67,7 @@ export interface SessionUpdate {
}
export interface SessionExport {
format: 'text' | 'markdown' | 'html'
format: 'text' | 'markdown' | 'html' | 'psa'
include_timestamps?: boolean
include_tree_info?: boolean
}