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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #72.
This commit is contained in:
chihlasm
2026-02-10 19:40:45 -05:00
committed by GitHub
parent 84fa554a7a
commit 402cdea063
7 changed files with 271 additions and 52 deletions

View File

@@ -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">