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

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