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:
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
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 type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||||
@@ -19,6 +19,7 @@ export function TreeListView({
|
|||||||
trees,
|
trees,
|
||||||
onStartSession,
|
onStartSession,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
|
onDeleteTree,
|
||||||
onFolderCreated,
|
onFolderCreated,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
}: TreeListViewProps) {
|
}: TreeListViewProps) {
|
||||||
@@ -94,17 +95,31 @@ export function TreeListView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||||
<Link
|
<>
|
||||||
to={`/trees/${tree.id}/edit`}
|
<Link
|
||||||
className={cn(
|
to={`/trees/${tree.id}/edit`}
|
||||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
className={cn(
|
||||||
'hover:bg-white/10 hover:text-white'
|
'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"
|
title="Edit tree"
|
||||||
>
|
aria-label="Edit tree"
|
||||||
<Pencil className="h-4 w-4" />
|
>
|
||||||
</Link>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||||
@@ -24,6 +24,7 @@ export function TreeTableView({
|
|||||||
onStartSession,
|
onStartSession,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onFolderCreated,
|
onFolderCreated,
|
||||||
|
onDeleteTree,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
}: TreeTableViewProps) {
|
}: TreeTableViewProps) {
|
||||||
@@ -198,17 +199,31 @@ export function TreeTableView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||||
<Link
|
<>
|
||||||
to={`/trees/${tree.id}/edit`}
|
<Link
|
||||||
className={cn(
|
to={`/trees/${tree.id}/edit`}
|
||||||
'rounded-md border border-white/10 p-1.5 text-white/60',
|
className={cn(
|
||||||
'hover:bg-white/10 hover:text-white'
|
'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"
|
title="Edit tree"
|
||||||
>
|
aria-label="Edit tree"
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
>
|
||||||
</Link>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ export function useTreeNavigationShortcuts({
|
|||||||
handler: onContinue,
|
handler: onContinue,
|
||||||
enabled: canContinue,
|
enabled: canContinue,
|
||||||
},
|
},
|
||||||
|
// Tab to focus notes
|
||||||
|
{
|
||||||
|
key: 'Tab',
|
||||||
|
handler: () => {
|
||||||
|
document.getElementById('session-notes')?.focus()
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
useKeyboardShortcuts(shortcuts)
|
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 [showRatingModal, setShowRatingModal] = useState(false)
|
||||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||||
|
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString()
|
return new Date(dateString).toLocaleString()
|
||||||
}
|
}
|
||||||
@@ -367,25 +383,40 @@ export function SessionDetailPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
<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">
|
<div className="glass-card rounded-xl p-4">
|
||||||
{decision.question && (
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="font-medium text-white">{decision.question}</p>
|
<div className="flex-1">
|
||||||
)}
|
{decision.question && (
|
||||||
{decision.answer && (
|
<p className="font-medium text-white">{decision.question}</p>
|
||||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
)}
|
||||||
)}
|
{decision.answer && (
|
||||||
{decision.action_performed && (
|
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||||
<p className="mt-1 text-sm text-white/40">
|
)}
|
||||||
Action: {decision.action_performed}
|
{decision.action_performed && (
|
||||||
</p>
|
<p className="mt-1 text-sm text-white/40">
|
||||||
)}
|
Action: {decision.action_performed}
|
||||||
{decision.notes && (
|
</p>
|
||||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
)}
|
||||||
Notes: {decision.notes}
|
{decision.notes && (
|
||||||
</p>
|
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||||
)}
|
Notes: {decision.notes}
|
||||||
<p className="mt-2 text-xs text-white/40">
|
</p>
|
||||||
{formatDate(decision.timestamp)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
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 { treesApi } from '@/api/trees'
|
||||||
import { categoriesApi } from '@/api/categories'
|
import { categoriesApi } from '@/api/categories'
|
||||||
import { foldersApi } from '@/api/folders'
|
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 { FolderSidebar } from '@/components/library/FolderSidebar'
|
||||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
@@ -13,7 +14,7 @@ import { TreeListView } from '@/components/library/TreeListView'
|
|||||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -51,6 +52,23 @@ export function TreeLibraryPage() {
|
|||||||
// Fork state
|
// Fork state
|
||||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
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 () => {
|
const loadFolders = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const foldersData = await foldersApi.list()
|
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)
|
// Load categories once on mount (they rarely change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesApi.list()
|
categoriesApi.list()
|
||||||
@@ -348,6 +390,59 @@ export function TreeLibraryPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Loading State */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import { treesApi } from '@/api/trees'
|
|||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
||||||
|
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
||||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
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 { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
|
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 {
|
interface LocationState {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
prefillClientName?: string
|
||||||
|
prefillTicketNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeNavigationPage() {
|
export function TreeNavigationPage() {
|
||||||
@@ -31,11 +34,14 @@ export function TreeNavigationPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
|
||||||
// Session metadata
|
// Session metadata (prefill from Repeat Last Session)
|
||||||
const [ticketNumber, setTicketNumber] = useState<string>('')
|
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||||
const [clientName, setClientName] = useState<string>('')
|
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
||||||
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
||||||
|
|
||||||
|
// Session timer
|
||||||
|
const timerDisplay = useSessionTimer(session?.started_at)
|
||||||
|
|
||||||
// Scratchpad state
|
// Scratchpad state
|
||||||
const [scratchpadOpen, setScratchpadOpen] = useState(() => {
|
const [scratchpadOpen, setScratchpadOpen] = useState(() => {
|
||||||
return safeGetItem('scratchpad-collapsed') === 'false'
|
return safeGetItem('scratchpad-collapsed') === 'false'
|
||||||
@@ -120,6 +126,13 @@ export function TreeNavigationPage() {
|
|||||||
})
|
})
|
||||||
setSession(newSession)
|
setSession(newSession)
|
||||||
setShowMetadataForm(false)
|
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) {
|
} catch (err) {
|
||||||
setError('Failed to start session')
|
setError('Failed to start session')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -368,7 +381,15 @@ export function TreeNavigationPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<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) && (
|
{(ticketNumber || clientName) && (
|
||||||
<p className="text-sm text-white/40">
|
<p className="text-sm text-white/40">
|
||||||
{ticketNumber && `Ticket: ${ticketNumber}`}
|
{ticketNumber && `Ticket: ${ticketNumber}`}
|
||||||
@@ -665,6 +686,7 @@ export function TreeNavigationPage() {
|
|||||||
Notes (optional)
|
Notes (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="session-notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="Add any notes for this step..."
|
placeholder="Add any notes for this step..."
|
||||||
@@ -698,6 +720,7 @@ export function TreeNavigationPage() {
|
|||||||
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
||||||
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
||||||
)}
|
)}
|
||||||
|
<span>, Tab notes</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user