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

View File

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

View File

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

View 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
}

View File

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

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

View File

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