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