feat: UX improvements — copy buttons, shortcuts modal, breadcrumb rewind, create tree CTA
- Add copy-to-clipboard buttons on command blocks (action + custom step nodes) - Add keyboard shortcuts modal (?) with option loading spinners and [Esc] hint - Restyle session timer as a pill badge for better visibility - Add prominent "Create Tree" CTA to MyTreesPage header and empty state - Make breadcrumb items clickable to rewind navigation to any previous step Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ export interface TreeNavigationShortcutsConfig {
|
||||
onSelectOption: (index: number) => void
|
||||
onGoBack: () => void
|
||||
onContinue: () => void
|
||||
onShowShortcuts?: () => void
|
||||
optionCount: number
|
||||
canGoBack: boolean
|
||||
canContinue: boolean
|
||||
@@ -60,6 +61,7 @@ export function useTreeNavigationShortcuts({
|
||||
onSelectOption,
|
||||
onGoBack,
|
||||
onContinue,
|
||||
onShowShortcuts,
|
||||
optionCount,
|
||||
canGoBack,
|
||||
canContinue,
|
||||
@@ -89,6 +91,13 @@ export function useTreeNavigationShortcuts({
|
||||
document.getElementById('session-notes')?.focus()
|
||||
},
|
||||
},
|
||||
// ? to show shortcuts modal
|
||||
{
|
||||
key: '?',
|
||||
shift: true,
|
||||
handler: () => onShowShortcuts?.(),
|
||||
enabled: !!onShowShortcuts,
|
||||
},
|
||||
]
|
||||
|
||||
useKeyboardShortcuts(shortcuts)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree } from 'lucide-react'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
@@ -22,7 +22,7 @@ interface TreeWithStats extends TreeListItem {
|
||||
export function MyTreesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const { canEditTree } = usePermissions()
|
||||
const { canEditTree, canCreateTrees } = usePermissions()
|
||||
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [treeToDelete, setTreeToDelete] = useState<TreeWithStats | null>(null)
|
||||
@@ -101,11 +101,22 @@ export function MyTreesPage() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">My Trees</h1>
|
||||
<p className="mt-2 text-white/40">
|
||||
Your forked and custom decision trees
|
||||
</p>
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">My Trees</h1>
|
||||
<p className="mt-2 text-white/40">
|
||||
Your forked and custom decision trees
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Tree
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
@@ -120,15 +131,29 @@ export function MyTreesPage() {
|
||||
<p className="mb-4 text-sm text-white/40">
|
||||
Fork a tree from the library to customize it for your workflow
|
||||
</p>
|
||||
<Link
|
||||
to="/trees"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
)}
|
||||
>
|
||||
Browse Library
|
||||
</Link>
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create from Scratch
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
Browse Trees
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -10,7 +10,8 @@ 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, SessionOutcomeModal } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal } from 'lucide-react'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, HelpCircle } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
@@ -41,6 +42,15 @@ export function TreeNavigationPage() {
|
||||
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
|
||||
const [pendingCompletionDecision, setPendingCompletionDecision] = useState<DecisionRecord | null>(null)
|
||||
const [completionSource, setCompletionSource] = useState<CompletionSource>('standard')
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null)
|
||||
const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false)
|
||||
const [selectingOption, setSelectingOption] = useState<string | null>(null)
|
||||
|
||||
const handleCopyCommand = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedCommand(text)
|
||||
setTimeout(() => setCopiedCommand(null), 2000)
|
||||
}
|
||||
|
||||
// Session metadata (prefill from Repeat Last Session)
|
||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||
@@ -220,10 +230,12 @@ export function TreeNavigationPage() {
|
||||
}
|
||||
|
||||
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
|
||||
if (!session || !tree) return
|
||||
if (!session || !tree || selectingOption) return
|
||||
|
||||
setSelectingOption(_optionId)
|
||||
|
||||
const node = findNode(currentNodeId, tree.tree_structure)
|
||||
if (!node) return
|
||||
if (!node) { setSelectingOption(null); return }
|
||||
|
||||
const exitedAt = new Date().toISOString()
|
||||
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||
@@ -259,6 +271,8 @@ export function TreeNavigationPage() {
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update session:', err)
|
||||
} finally {
|
||||
setSelectingOption(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +384,16 @@ export function TreeNavigationPage() {
|
||||
setCommandOutputOpen(!!prevOutput)
|
||||
}
|
||||
|
||||
const handleBreadcrumbJump = (nodeId: string, index: number) => {
|
||||
setPathTaken(prev => prev.slice(0, index + 1))
|
||||
setDecisions(prev => prev.slice(0, index))
|
||||
setCurrentNodeId(nodeId)
|
||||
setCurrentStepEnteredAt(new Date().toISOString())
|
||||
setNotes('')
|
||||
setCommandOutput('')
|
||||
setCommandOutputOpen(false)
|
||||
}
|
||||
|
||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||
const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null
|
||||
const currentCustomStep = customStepFlow.findCustomStep(currentNodeId)
|
||||
@@ -379,11 +403,12 @@ export function TreeNavigationPage() {
|
||||
useTreeNavigationShortcuts({
|
||||
onSelectOption: (index) => {
|
||||
const option = currentOptions[index]
|
||||
if (option && session && tree) {
|
||||
if (option && session && tree && !selectingOption) {
|
||||
handleSelectOption(option.id, option.label, option.next_node_id)
|
||||
}
|
||||
},
|
||||
onGoBack: handleGoBack,
|
||||
onShowShortcuts: () => setShortcutsModalOpen(true),
|
||||
onContinue: () => {
|
||||
if (currentNode?.type === 'action' && currentNode.next_node_id) {
|
||||
handleContinue()
|
||||
@@ -392,8 +417,8 @@ export function TreeNavigationPage() {
|
||||
}
|
||||
},
|
||||
optionCount: currentOptions.length,
|
||||
canGoBack: pathTaken.length > 1 && !showMetadataForm && !isLoading,
|
||||
canContinue: !showMetadataForm && !isLoading && (currentNode?.type === 'action' || currentNode?.type === 'solution'),
|
||||
canGoBack: pathTaken.length > 1 && !showMetadataForm && !isLoading && !selectingOption,
|
||||
canContinue: !showMetadataForm && !isLoading && !selectingOption && (currentNode?.type === 'action' || currentNode?.type === 'solution'),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
@@ -502,11 +527,19 @@ export function TreeNavigationPage() {
|
||||
<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" />
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-0.5 text-sm text-white/60">
|
||||
<Clock className="h-4 w-4" />
|
||||
{timerDisplay}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShortcutsModalOpen(true)}
|
||||
className="flex items-center justify-center rounded-full p-1 text-white/30 hover:bg-white/10 hover:text-white/60"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{(ticketNumber || clientName) && (
|
||||
<p className="text-sm text-white/40">
|
||||
@@ -530,18 +563,23 @@ export function TreeNavigationPage() {
|
||||
const node = findNode(nodeId, tree?.tree_structure)
|
||||
const customStep = customStepFlow.findCustomStep(nodeId)
|
||||
const label = node?.question || node?.title || customStep?.step_data.title || nodeId
|
||||
const truncatedLabel = label.length > 30 ? `${label.slice(0, 30)}...` : label
|
||||
return (
|
||||
<span key={nodeId} className="flex items-center gap-2 whitespace-nowrap">
|
||||
{index > 0 && <span className="text-white/40">→</span>}
|
||||
<span
|
||||
className={cn(
|
||||
index === pathTaken.length - 1
|
||||
? 'font-medium text-white'
|
||||
: 'text-white/40'
|
||||
)}
|
||||
>
|
||||
{label.length > 30 ? `${label.slice(0, 30)}...` : label}
|
||||
</span>
|
||||
{index < pathTaken.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBreadcrumbJump(nodeId, index)}
|
||||
className="text-white/40 hover:text-white/70 hover:underline"
|
||||
>
|
||||
{truncatedLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium text-white">
|
||||
{truncatedLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
@@ -565,16 +603,24 @@ export function TreeNavigationPage() {
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleSelectOption(option.id, option.label, option.next_node_id)}
|
||||
disabled={!!selectingOption}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 p-3 text-left text-white transition-colors',
|
||||
'hover:border-white/30 hover:bg-white/[0.06]',
|
||||
'flex items-center gap-3'
|
||||
'flex items-center gap-3',
|
||||
selectingOption && selectingOption !== option.id && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{index < 9 && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 text-xs font-medium text-white/50">
|
||||
{index + 1}
|
||||
</span>
|
||||
selectingOption === option.id ? (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 text-xs font-medium text-white/50">
|
||||
{index + 1}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
@@ -650,9 +696,23 @@ export function TreeNavigationPage() {
|
||||
{currentCustomStep.step_data.content.commands.map((cmd, index) => (
|
||||
<div key={index}>
|
||||
<p className="mb-1 text-xs text-white/40">{cmd.label}</p>
|
||||
<code className="block rounded bg-white/10 p-2 text-sm font-mono">
|
||||
{cmd.command}
|
||||
</code>
|
||||
<div className="group relative">
|
||||
<code className="block rounded bg-white/10 p-2 pr-8 text-sm font-mono">
|
||||
{cmd.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyCommand(cmd.command)}
|
||||
className="absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title="Copy command"
|
||||
>
|
||||
{copiedCommand === cmd.command ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400" />
|
||||
) : (
|
||||
<Clipboard className="h-3.5 w-3.5 text-white/40 hover:text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -761,12 +821,23 @@ export function TreeNavigationPage() {
|
||||
<p className="mb-2 text-sm font-medium text-white">Commands:</p>
|
||||
<div className="space-y-1">
|
||||
{currentNode.commands.map((cmd, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="block rounded bg-white/10 p-2 text-sm font-mono"
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
<div key={index} className="group relative">
|
||||
<code className="block rounded bg-white/10 p-2 pr-8 text-sm font-mono">
|
||||
{cmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyCommand(cmd)}
|
||||
className="absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title="Copy command"
|
||||
>
|
||||
{copiedCommand === cmd ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400" />
|
||||
) : (
|
||||
<Clipboard className="h-3.5 w-3.5 text-white/40 hover:text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Command Output Capture */}
|
||||
@@ -885,7 +956,7 @@ export function TreeNavigationPage() {
|
||||
onClick={handleGoBack}
|
||||
className="mt-4 text-sm text-white/50 hover:text-white"
|
||||
>
|
||||
← Go back
|
||||
← Go back <span className="text-xs text-white/30">[Esc]</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -950,6 +1021,37 @@ export function TreeNavigationPage() {
|
||||
onSubmit={handleSubmitOutcome}
|
||||
isSubmitting={isCompleting}
|
||||
/>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<Modal
|
||||
isOpen={shortcutsModalOpen}
|
||||
onClose={() => setShortcutsModalOpen(false)}
|
||||
title="Keyboard Shortcuts"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Select option</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-xs text-white/80">1-9</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Go back</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-xs text-white/80">Esc</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Continue / Complete</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-xs text-white/80">Enter</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Focus notes</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-xs text-white/80">Tab</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">Show shortcuts</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-xs text-white/80">?</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user