diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 0590998a..c25fd59f 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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) diff --git a/frontend/src/hooks/useSessionTimer.ts b/frontend/src/hooks/useSessionTimer.ts new file mode 100644 index 00000000..d8e216b4 --- /dev/null +++ b/frontend/src/hooks/useSessionTimer.ts @@ -0,0 +1,33 @@ +import { useState, useEffect, useRef } from 'react' + +export function useSessionTimer(startedAt: string | undefined | null): string | null { + const [elapsed, setElapsed] = useState(null) + const intervalRef = useRef | 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 +} diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index 3827b60f..34bc83f2 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -30,6 +30,7 @@ export function SessionDetailPage() { const [showRatingModal, setShowRatingModal] = useState(false) const [isSavingRatings, setIsSavingRatings] = useState(false) const [librarySteps, setLibrarySteps] = useState([]) + const [copiedStepIndex, setCopiedStepIndex] = useState(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() {
- {decision.question && ( -

{decision.question}

- )} - {decision.answer && ( -

Answer: {decision.answer}

- )} - {decision.action_performed && ( -

- Action: {decision.action_performed} -

- )} - {decision.notes && ( -

- Notes: {decision.notes} -

- )} -

- {formatDate(decision.timestamp)} -

+
+
+ {decision.question && ( +

{decision.question}

+ )} + {decision.answer && ( +

Answer: {decision.answer}

+ )} + {decision.action_performed && ( +

+ Action: {decision.action_performed} +

+ )} + {decision.notes && ( +

+ Notes: {decision.notes} +

+ )} +

+ {formatDate(decision.timestamp)} +

+
+ +
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 29267359..e330d0bf 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -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([]) + const [dismissedSessionIds, setDismissedSessionIds] = useState>(() => { + 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() { )} + {/* Incomplete Session Recovery */} + {visibleIncompleteSessions.length > 0 && ( +
+ {visibleIncompleteSessions.map(s => ( +
+
+

+ {s.tree_snapshot?.name || 'Unknown tree'} +

+

+ {s.client_name && `${s.client_name} ยท `} + Started {formatTimeAgo(s.started_at)} +

+
+
+ + +
+
+ ))} +
+ )} + + {/* Repeat Last Session */} + {lastSessionData && ( +
+ +
+ )} + {/* Loading State */} {isLoading ? (
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index d0065a78..edb48c38 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -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(null) const [isCompleting, setIsCompleting] = useState(false) - // Session metadata - const [ticketNumber, setTicketNumber] = useState('') - const [clientName, setClientName] = useState('') + // Session metadata (prefill from Repeat Last Session) + const [ticketNumber, setTicketNumber] = useState(locationState?.prefillTicketNumber || '') + const [clientName, setClientName] = useState(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 */}
-

{tree.name}

+
+

{tree.name}

+ {timerDisplay && ( + + + {timerDisplay} + + )} +
{(ticketNumber || clientName) && (

{ticketNumber && `Ticket: ${ticketNumber}`} @@ -665,6 +686,7 @@ export function TreeNavigationPage() { Notes (optional)