From 402cdea0630e746b2bf5ddbbb585a3e8a588ea1f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 10 Feb 2026 19:40:45 -0500 Subject: [PATCH] feat: session quick wins (#51-#55) (#72) * feat: add session quick wins (#51-#55) - Session timer showing elapsed time in header (#51) - Tab keyboard shortcut to focus notes textarea (#52) - Repeat Last Session button on tree library page (#53) - Auto-recovery banner for incomplete sessions (#54) - Copy individual step to clipboard on session detail (#55) Co-Authored-By: Claude Opus 4.6 * fix: add missing delete button to table and list tree views The onDeleteTree prop was accepted but never used in TreeTableView and TreeListView. Now both views show a trash icon (permission-gated) matching the existing grid view behavior. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../src/components/library/TreeListView.tsx | 39 ++++--- .../src/components/library/TreeTableView.tsx | 39 ++++--- frontend/src/hooks/useKeyboardShortcuts.ts | 7 ++ frontend/src/hooks/useSessionTimer.ts | 33 ++++++ frontend/src/pages/SessionDetailPage.tsx | 69 ++++++++---- frontend/src/pages/TreeLibraryPage.tsx | 101 +++++++++++++++++- frontend/src/pages/TreeNavigationPage.tsx | 35 ++++-- 7 files changed, 271 insertions(+), 52 deletions(-) create mode 100644 frontend/src/hooks/useSessionTimer.ts diff --git a/frontend/src/components/library/TreeListView.tsx b/frontend/src/components/library/TreeListView.tsx index 0916843f..5ecc154a 100644 --- a/frontend/src/components/library/TreeListView.tsx +++ b/frontend/src/components/library/TreeListView.tsx @@ -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({ )} {canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && ( - - - + <> + + + + + )} + )} + 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)