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:
chihlasm
2026-02-13 02:19:24 -05:00
parent ad59446332
commit 2fc4e69c38
3 changed files with 182 additions and 46 deletions

View File

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

View File

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

View File

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