feat: session quick wins (#51-#55) #72
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2 } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
@@ -19,6 +19,7 @@ export function TreeListView({
|
||||
trees,
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onFolderCreated,
|
||||
onForkTree,
|
||||
}: TreeListViewProps) {
|
||||
@@ -94,17 +95,31 @@ export function TreeListView({
|
||||
</button>
|
||||
)}
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
title="Delete tree"
|
||||
aria-label="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2 } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
@@ -24,6 +24,7 @@ export function TreeTableView({
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onDeleteTree,
|
||||
onSortChange,
|
||||
onForkTree,
|
||||
}: TreeTableViewProps) {
|
||||
@@ -198,17 +199,31 @@ export function TreeTableView({
|
||||
</button>
|
||||
)}
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
title="Edit tree"
|
||||
aria-label="Edit tree"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTree(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
||||
'hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
title="Delete tree"
|
||||
aria-label="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -82,6 +82,13 @@ export function useTreeNavigationShortcuts({
|
||||
handler: onContinue,
|
||||
enabled: canContinue,
|
||||
},
|
||||
// Tab to focus notes
|
||||
{
|
||||
key: 'Tab',
|
||||
handler: () => {
|
||||
document.getElementById('session-notes')?.focus()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
useKeyboardShortcuts(shortcuts)
|
||||
|
||||
33
frontend/src/hooks/useSessionTimer.ts
Normal file
33
frontend/src/hooks/useSessionTimer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
export function useSessionTimer(startedAt: string | undefined | null): string | null {
|
||||
const [elapsed, setElapsed] = useState<string | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) {
|
||||
setElapsed(null)
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = new Date(startedAt).getTime()
|
||||
|
||||
const tick = () => {
|
||||
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))
|
||||
const hours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
const seconds = diff % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
setElapsed(hours > 0 ? `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`)
|
||||
}
|
||||
|
||||
tick()
|
||||
intervalRef.current = setInterval(tick, 1000)
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [startedAt])
|
||||
|
||||
return elapsed
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function SessionDetailPage() {
|
||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -217,6 +218,21 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyStep = async (decision: Session['decisions'][number], index: number) => {
|
||||
const lines: string[] = []
|
||||
if (decision.question) lines.push(`Question: ${decision.question}`)
|
||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'))
|
||||
setCopiedStepIndex(index)
|
||||
setTimeout(() => setCopiedStepIndex(null), 2000)
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -367,25 +383,40 @@ export function SessionDetailPage() {
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Plus, X, FolderOpen } from 'lucide-react'
|
||||
import { Plus, X, FolderOpen, RotateCcw, Play } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem } from '@/types'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
||||
import { FolderSidebar } from '@/components/library/FolderSidebar'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
@@ -13,7 +14,7 @@ import { TreeListView } from '@/components/library/TreeListView'
|
||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -51,6 +52,23 @@ export function TreeLibraryPage() {
|
||||
// Fork state
|
||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
||||
|
||||
// Repeat Last Session
|
||||
const lastSessionData = (() => {
|
||||
const raw = safeGetItem('last-session')
|
||||
if (!raw) return null
|
||||
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string } }
|
||||
catch { return null }
|
||||
})()
|
||||
|
||||
// Incomplete sessions for auto-recovery
|
||||
const [incompleteSessions, setIncompleteSessions] = useState<Session[]>([])
|
||||
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('dismissed-sessions')
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set()
|
||||
} catch { return new Set() }
|
||||
})
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
try {
|
||||
const foldersData = await foldersApi.list()
|
||||
@@ -60,6 +78,30 @@ export function TreeLibraryPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load incomplete sessions on mount
|
||||
useEffect(() => {
|
||||
sessionsApi.list({ completed: false, size: 5 })
|
||||
.then(setIncompleteSessions)
|
||||
.catch((err) => console.error('Failed to load incomplete sessions:', err))
|
||||
}, [])
|
||||
|
||||
const dismissSession = (sessionId: string) => {
|
||||
const next = new Set(dismissedSessionIds)
|
||||
next.add(sessionId)
|
||||
setDismissedSessionIds(next)
|
||||
try { sessionStorage.setItem('dismissed-sessions', JSON.stringify([...next])) } catch { /* */ }
|
||||
}
|
||||
|
||||
const visibleIncompleteSessions = incompleteSessions.filter(s => !dismissedSessionIds.has(s.id))
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const diff = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`
|
||||
return `${Math.floor(diff / 86400)} days ago`
|
||||
}
|
||||
|
||||
// Load categories once on mount (they rarely change)
|
||||
useEffect(() => {
|
||||
categoriesApi.list()
|
||||
@@ -348,6 +390,59 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incomplete Session Recovery */}
|
||||
{visibleIncompleteSessions.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{visibleIncompleteSessions.map(s => (
|
||||
<div key={s.id} className="glass-card flex items-center justify-between rounded-xl p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-white">
|
||||
{s.tree_snapshot?.name || 'Unknown tree'}
|
||||
</p>
|
||||
<p className="text-sm text-white/40">
|
||||
{s.client_name && `${s.client_name} · `}
|
||||
Started {formatTimeAgo(s.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dismissSession(s.id)}
|
||||
className="rounded-md p-1.5 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repeat Last Session */}
|
||||
{lastSessionData && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${lastSessionData.tree_id}/navigate`, {
|
||||
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
|
||||
})}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2.5 text-sm text-white/60',
|
||||
'hover:border-white/20 hover:bg-white/[0.04] hover:text-white'
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Repeat: {lastSessionData.tree_name}
|
||||
{lastSessionData.client_name && ` (${lastSessionData.client_name})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
|
||||
@@ -4,15 +4,18 @@ import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
||||
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight } from 'lucide-react'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
prefillClientName?: string
|
||||
prefillTicketNumber?: string
|
||||
}
|
||||
|
||||
export function TreeNavigationPage() {
|
||||
@@ -31,11 +34,14 @@ export function TreeNavigationPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
|
||||
// Session metadata
|
||||
const [ticketNumber, setTicketNumber] = useState<string>('')
|
||||
const [clientName, setClientName] = useState<string>('')
|
||||
// Session metadata (prefill from Repeat Last Session)
|
||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
||||
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
||||
|
||||
// Session timer
|
||||
const timerDisplay = useSessionTimer(session?.started_at)
|
||||
|
||||
// Scratchpad state
|
||||
const [scratchpadOpen, setScratchpadOpen] = useState(() => {
|
||||
return safeGetItem('scratchpad-collapsed') === 'false'
|
||||
@@ -120,6 +126,13 @@ export function TreeNavigationPage() {
|
||||
})
|
||||
setSession(newSession)
|
||||
setShowMetadataForm(false)
|
||||
// Save for "Repeat Last Session"
|
||||
safeSetItem('last-session', JSON.stringify({
|
||||
tree_id: tree.id,
|
||||
tree_name: tree.name,
|
||||
client_name: clientName || '',
|
||||
ticket_number: ticketNumber || '',
|
||||
}))
|
||||
} catch (err) {
|
||||
setError('Failed to start session')
|
||||
console.error(err)
|
||||
@@ -368,7 +381,15 @@ export function TreeNavigationPage() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{tree.name}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">{tree.name}</h1>
|
||||
{timerDisplay && (
|
||||
<span className="flex items-center gap-1 text-sm text-white/40">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{timerDisplay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(ticketNumber || clientName) && (
|
||||
<p className="text-sm text-white/40">
|
||||
{ticketNumber && `Ticket: ${ticketNumber}`}
|
||||
@@ -665,6 +686,7 @@ export function TreeNavigationPage() {
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="session-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any notes for this step..."
|
||||
@@ -698,6 +720,7 @@ export function TreeNavigationPage() {
|
||||
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
||||
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
||||
)}
|
||||
<span>, Tab notes</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user