* fix: tree editor authoring blockers - scroll trap, form density, branching hint - Replace fixed viewport height with flex layout in NodeEditorPanel - Make footer sticky so Save/Cancel always reachable - Compact root node banner to single-line with InfoTip tooltip - Reduce resolution note from callout box to inline text - Add answer-first branching hint below options label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: broken functionality - auth errors, toast logic, role update, routing, step library - Extract backend error detail in auth store login/register - Fix inverted 4xx toast logic and add 429 rate limit handling - Send account_role field to match backend schema in role update - Use type-aware routing for Repeat Last Session button - Add step library placeholder page and route, remove dot badge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: navigation correctness - back buttons, exit dialog, dedup nav, redirects - Standardize all procedural back/exit paths to /trees (not /my-trees) - Add exit button with ConfirmDialog to procedural session top bar - Consolidate duplicate account links in sidebar and topbar - Auto-redirect non-owners to personal analytics - Add toast feedback before silent permission redirects in tree editor - Delete orphaned AdminCategoriesPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: shared components, ConfirmDialog migration, pinned flow fixes - Create shared Spinner component with sm/md/lg sizes - Migrate 13 page-level spinners to shared Spinner - Promote EmptyState to shared component, adopt in MyShares and SessionHistory - Replace window.confirm with ConfirmDialog in 3 files - Fix PinnedFlow.tree_type to include maintenance, update emoji display - Verify sidebar unpin handler already correct (no-op) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: visual consistency - toasts, typography, focus rings, container padding - Remove richColors from Sonner toasts, limit stacking to 3 - Add font-heading to all page H1s (7 files) - Add font-label (Outfit) to TagBadges component - Fix focus ring tokens on analytics pages - Replace deprecated glass-stat with design system tokens - Standardize container padding on analytics pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: backend alignment - remove drafts toggle, clean dead code, truncation indicator - Remove non-functional drafts toggle and clean TreeFilters type - Fix AccountInvite type to match backend schema - Remove dead API methods: pinnedFlows.pin/reorder, trees.getSharedTree - Remove unused types: SessionListResponse, RatingCreate.is_verified_use - Add session list truncation indicator with size=51 lookahead Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove bg-black from PageLoader and RouteError, fix PageLoader height PageLoader used h-screen inside a grid cell, causing it to overflow. Changed to h-full so it fits within the main-content area. Removed bg-black from both PageLoader and RouteError in favor of theme-aware bg-background to prevent black flash during lazy loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard against Pydantic validation error objects in toast/error messages FastAPI returns `detail` as an array of objects for 422 validation errors, not a string. Passing these objects to toast.error() or rendering them in JSX crashes React with Error #31 ("Objects are not valid as a React child"). Now checks typeof detail === 'string' before using it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: toast styling, node editor first-click, action node placeholder pattern 1. Toast fixes: Add theme="dark" to Sonner, use !important CSS overrides instead of zero-specificity :where() selectors, suppress noisy 4xx global toasts (pages handle their own errors) 2. Node editor first-click: Add node.type to draft initialization useEffect deps so draft resets when answer stub converts to real type 3. Action node redesign: Remove NodePicker dropdown, auto-create answer placeholder on save (matching decision node pattern). Users click the placeholder on canvas to choose type and fill in details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-seed test users when release command fails on PR envs The background seeder now creates users directly via DB if login fails, instead of silently aborting. This handles Railway PR environments where the releaseCommand may not execute properly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove categories/tags from sidebar to prevent footer clipping Categories and Tags sections were pushing Feedback, Account, and Collapse off-screen when All Flows expanded its children. These filters already exist on the TreeLibraryPage, so the sidebar duplicates were removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1277 lines
50 KiB
TypeScript
1277 lines
50 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
|
import { treesApi } from '@/api/trees'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
|
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
|
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
|
import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
|
|
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, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { toast } from '@/lib/toast'
|
|
import { Modal } from '@/components/common/Modal'
|
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
|
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
|
|
|
interface LocationState {
|
|
sessionId?: string
|
|
prefillClientName?: string
|
|
prefillTicketNumber?: string
|
|
}
|
|
|
|
type CompletionSource = 'standard' | 'custom'
|
|
|
|
export function TreeNavigationPage() {
|
|
const { id: treeId } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const locationState = location.state as LocationState | undefined
|
|
|
|
const [tree, setTree] = useState<Tree | null>(null)
|
|
const [session, setSession] = useState<Session | null>(null)
|
|
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
|
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
|
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
|
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
|
const [notes, setNotes] = useState<string>('')
|
|
const [commandOutput, setCommandOutput] = useState<string>('')
|
|
const [commandOutputOpen, setCommandOutputOpen] = useState(false)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [isCompleting, setIsCompleting] = useState(false)
|
|
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 [copiedForTicket, setCopiedForTicket] = useState(false)
|
|
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
|
const [showSharePopover, setShowSharePopover] = useState(false)
|
|
const [showShareModal, setShowShareModal] = useState(false)
|
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
|
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
|
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
|
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
|
|
|
const handleCopyCommand = (text: string) => {
|
|
navigator.clipboard.writeText(text)
|
|
setCopiedCommand(text)
|
|
setTimeout(() => setCopiedCommand(null), 2000)
|
|
}
|
|
|
|
const handleCopyForTicket = async () => {
|
|
if (!session || isCopyingForTicket) return
|
|
setIsCopyingForTicket(true)
|
|
try {
|
|
const content = await sessionsApi.export(session.id, {
|
|
format: 'psa',
|
|
include_timestamps: true,
|
|
include_tree_info: true,
|
|
})
|
|
if (content) {
|
|
await navigator.clipboard.writeText(content)
|
|
setCopiedForTicket(true)
|
|
setTimeout(() => setCopiedForTicket(false), 2000)
|
|
toast.success('Copied progress notes to clipboard')
|
|
}
|
|
} catch (err) {
|
|
console.error('Copy for ticket failed:', err)
|
|
toast.error('Failed to copy notes')
|
|
} finally {
|
|
setIsCopyingForTicket(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyShareLink = async () => {
|
|
if (!session || isCopyingShareLink) return
|
|
setIsCopyingShareLink(true)
|
|
try {
|
|
const allShares = await sessionsApi.listMyShares()
|
|
const existingShare = getLatestActiveShareForSession(allShares, session.id)
|
|
let shareUrl: string
|
|
if (existingShare) {
|
|
shareUrl = buildSessionShareUrl(existingShare)
|
|
} else {
|
|
const newShare = await sessionsApi.createShare(session.id, { visibility: 'account' })
|
|
shareUrl = buildSessionShareUrl(newShare)
|
|
}
|
|
await navigator.clipboard.writeText(shareUrl)
|
|
setCopiedShareLink(true)
|
|
toast.success('Share link copied to clipboard')
|
|
setTimeout(() => setCopiedShareLink(false), 2000)
|
|
} catch (err) {
|
|
console.error('Copy share link failed:', err)
|
|
toast.error('Failed to copy share link')
|
|
} finally {
|
|
setIsCopyingShareLink(false)
|
|
}
|
|
}
|
|
|
|
// Close share popover on outside click
|
|
useEffect(() => {
|
|
if (!showSharePopover) return
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) {
|
|
setShowSharePopover(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleMouseDown)
|
|
return () => document.removeEventListener('mousedown', handleMouseDown)
|
|
}, [showSharePopover])
|
|
|
|
// Close share popover on Escape key
|
|
useEffect(() => {
|
|
if (!showSharePopover) return
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setShowSharePopover(false)
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [showSharePopover])
|
|
|
|
// Session metadata (prefill from Repeat Last Session)
|
|
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
|
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
|
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
|
|
|
// Session timer
|
|
const timerDisplay = useSessionTimer(session?.started_at)
|
|
|
|
// Scratchpad state
|
|
const [scratchpadOpen, setScratchpadOpen] = useState(() => {
|
|
return safeGetItem('scratchpad-collapsed') === 'false'
|
|
})
|
|
|
|
const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => {
|
|
if (!structure) return null
|
|
if (structure.id === nodeId) return structure
|
|
if (structure.children) {
|
|
for (const child of structure.children) {
|
|
const found = findNode(nodeId, child)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
|
|
const enteredAtMs = new Date(enteredAtIso).getTime()
|
|
const exitedAtMs = new Date(exitedAtIso).getTime()
|
|
if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
|
|
return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
|
|
}
|
|
|
|
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
|
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
|
return sessionData.started_at
|
|
}
|
|
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
|
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
|
|
}
|
|
|
|
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
|
const exitedAt = new Date().toISOString()
|
|
const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
|
|
setPendingCompletionDecision({
|
|
...completionDecision,
|
|
timestamp: exitedAt,
|
|
entered_at: enteredAt,
|
|
exited_at: exitedAt,
|
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
|
})
|
|
setCompletionSource(source)
|
|
setShowOutcomeModal(true)
|
|
}
|
|
|
|
const closeOutcomeModal = () => {
|
|
if (isCompleting) return
|
|
setShowOutcomeModal(false)
|
|
setPendingCompletionDecision(null)
|
|
setCompletionSource('standard')
|
|
}
|
|
|
|
const handleCsatClose = () => {
|
|
setShowCsatModal(false)
|
|
if (session) {
|
|
navigate(`/sessions/${session.id}`)
|
|
}
|
|
}
|
|
|
|
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
|
const customStepFlow = useCustomStepFlow({
|
|
tree,
|
|
session,
|
|
currentNodeId,
|
|
pathTaken,
|
|
decisions,
|
|
notes,
|
|
findNode,
|
|
setCurrentNodeId,
|
|
setPathTaken,
|
|
setDecisions,
|
|
setNotes,
|
|
setError,
|
|
onEnterNode: setCurrentStepEnteredAt,
|
|
isCompleting,
|
|
onRequestCompletion: (completionDecision) => {
|
|
openCompletionModal(completionDecision, 'custom')
|
|
},
|
|
})
|
|
|
|
// Inject command_output into the last decision (for custom steps) before continuing
|
|
const updateLastDecisionWithCommandOutput = async () => {
|
|
const output = commandOutput.trim() || null
|
|
if (!output || !session || decisions.length === 0) return
|
|
const updatedDecisions = [...decisions]
|
|
updatedDecisions[updatedDecisions.length - 1] = {
|
|
...updatedDecisions[updatedDecisions.length - 1],
|
|
command_output: output,
|
|
}
|
|
setDecisions(updatedDecisions)
|
|
try {
|
|
await sessionsApi.update(session.id, { decisions: updatedDecisions })
|
|
} catch (err) {
|
|
console.error('Failed to update decision with command output:', err)
|
|
}
|
|
}
|
|
|
|
const handleCustomContinueToDescendant = async () => {
|
|
await updateLastDecisionWithCommandOutput()
|
|
setCommandOutput('')
|
|
setCommandOutputOpen(false)
|
|
customStepFlow.handleContinueToDescendant()
|
|
}
|
|
|
|
const handleCustomBranchCompleteWithOutput = async () => {
|
|
await updateLastDecisionWithCommandOutput()
|
|
customStepFlow.handleCustomBranchComplete()
|
|
}
|
|
|
|
const handleScratchpadSave = async (content: string) => {
|
|
if (!session) return
|
|
await sessionsApi.updateScratchpad(session.id, content)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (treeId) {
|
|
loadTreeAndSession()
|
|
}
|
|
}, [treeId])
|
|
|
|
const loadTreeAndSession = async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const treeData = await treesApi.get(treeId!)
|
|
|
|
// Safety redirect: procedural trees should use the procedural navigator
|
|
if (treeData.tree_type === 'procedural') {
|
|
navigate(`/flows/${treeId}/navigate`, {
|
|
replace: true,
|
|
state: locationState,
|
|
})
|
|
return
|
|
}
|
|
|
|
setTree(treeData)
|
|
|
|
// If resuming a session
|
|
if (locationState?.sessionId) {
|
|
const sessionData = await sessionsApi.get(locationState.sessionId)
|
|
setSession(sessionData)
|
|
setPathTaken(sessionData.path_taken)
|
|
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
|
setDecisions(sessionData.decisions as DecisionRecord[])
|
|
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
|
|
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
|
setTicketNumber(sessionData.ticket_number || '')
|
|
setClientName(sessionData.client_name || '')
|
|
setShowMetadataForm(false)
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to load tree')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const startSession = async () => {
|
|
if (!tree) return
|
|
setIsLoading(true)
|
|
try {
|
|
const newSession = await sessionsApi.create({
|
|
tree_id: tree.id,
|
|
ticket_number: ticketNumber || undefined,
|
|
client_name: clientName || undefined,
|
|
})
|
|
setSession(newSession)
|
|
setCurrentStepEnteredAt(newSession.started_at)
|
|
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) {
|
|
setError('Failed to start session')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
|
|
if (!session || !tree || selectingOption) return
|
|
|
|
setSelectingOption(_optionId)
|
|
|
|
const node = findNode(currentNodeId, tree.tree_structure)
|
|
if (!node) { setSelectingOption(null); return }
|
|
|
|
const exitedAt = new Date().toISOString()
|
|
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
|
const newDecision: DecisionRecord = {
|
|
node_id: currentNodeId,
|
|
question: node.question || null,
|
|
answer: optionLabel,
|
|
action_performed: null,
|
|
notes: notes || null,
|
|
automation_used: false,
|
|
timestamp: exitedAt,
|
|
entered_at: enteredAt,
|
|
exited_at: exitedAt,
|
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
|
attachments: [],
|
|
}
|
|
|
|
const newPath = [...pathTaken, nextNodeId]
|
|
const newDecisions = [...decisions, newDecision]
|
|
|
|
setPathTaken(newPath)
|
|
setDecisions(newDecisions)
|
|
setCurrentNodeId(nextNodeId)
|
|
setCurrentStepEnteredAt(exitedAt)
|
|
setNotes('')
|
|
setCommandOutput('')
|
|
setCommandOutputOpen(false)
|
|
|
|
try {
|
|
await sessionsApi.update(session.id, {
|
|
path_taken: newPath,
|
|
decisions: newDecisions,
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to update session:', err)
|
|
} finally {
|
|
setSelectingOption(null)
|
|
}
|
|
}
|
|
|
|
const handleContinue = async (actionPerformed?: string) => {
|
|
if (!session || !tree) return
|
|
|
|
const node = findNode(currentNodeId, tree.tree_structure)
|
|
if (!node || !node.next_node_id) return
|
|
|
|
const exitedAt = new Date().toISOString()
|
|
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
|
const newDecision: DecisionRecord = {
|
|
node_id: currentNodeId,
|
|
question: null,
|
|
answer: null,
|
|
action_performed: actionPerformed || node.title || 'Action completed',
|
|
notes: notes || null,
|
|
command_output: commandOutput.trim() || null,
|
|
automation_used: false,
|
|
timestamp: exitedAt,
|
|
entered_at: enteredAt,
|
|
exited_at: exitedAt,
|
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
|
attachments: [],
|
|
}
|
|
|
|
const newPath = [...pathTaken, node.next_node_id]
|
|
const newDecisions = [...decisions, newDecision]
|
|
|
|
setPathTaken(newPath)
|
|
setDecisions(newDecisions)
|
|
setCurrentNodeId(node.next_node_id)
|
|
setCurrentStepEnteredAt(exitedAt)
|
|
setNotes('')
|
|
setCommandOutput('')
|
|
setCommandOutputOpen(false)
|
|
|
|
try {
|
|
await sessionsApi.update(session.id, {
|
|
path_taken: newPath,
|
|
decisions: newDecisions,
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to update session:', err)
|
|
}
|
|
}
|
|
|
|
const handleComplete = async () => {
|
|
if (!session || !tree) return
|
|
const node = findNode(currentNodeId, tree.tree_structure)
|
|
if (!node) return
|
|
const completionDecision: DecisionRecord = {
|
|
node_id: currentNodeId,
|
|
question: null,
|
|
answer: null,
|
|
action_performed: node.title || 'Session completed',
|
|
notes: notes || null,
|
|
command_output: commandOutput.trim() || null,
|
|
automation_used: false,
|
|
timestamp: new Date().toISOString(),
|
|
attachments: [],
|
|
}
|
|
openCompletionModal(completionDecision, 'standard')
|
|
}
|
|
|
|
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => {
|
|
if (!session) return
|
|
setIsCompleting(true)
|
|
setError(null)
|
|
try {
|
|
let finalDecisions = decisions
|
|
if (pendingCompletionDecision) {
|
|
finalDecisions = [...decisions, pendingCompletionDecision]
|
|
setDecisions(finalDecisions)
|
|
await sessionsApi.update(session.id, {
|
|
decisions: finalDecisions,
|
|
})
|
|
}
|
|
|
|
await sessionsApi.complete(session.id, data)
|
|
|
|
setShowOutcomeModal(false)
|
|
setPendingCompletionDecision(null)
|
|
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
|
customStepFlow.setShowForkModal(true)
|
|
} else if (!hasBeenRated(session.id)) {
|
|
setShowCsatModal(true)
|
|
} else {
|
|
navigate(`/sessions/${session.id}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to complete session:', err)
|
|
setError('Failed to complete session. Check console for details.')
|
|
} finally {
|
|
setIsCompleting(false)
|
|
}
|
|
}
|
|
|
|
const handleGoBack = () => {
|
|
if (pathTaken.length <= 1) return
|
|
const newPath = pathTaken.slice(0, -1)
|
|
const removedDecision = decisions[decisions.length - 1]
|
|
const newDecisions = decisions.slice(0, -1)
|
|
setPathTaken(newPath)
|
|
setDecisions(newDecisions)
|
|
setCurrentNodeId(newPath[newPath.length - 1])
|
|
setCurrentStepEnteredAt(new Date().toISOString())
|
|
// Preload fields from the removed decision when revisiting
|
|
const prevOutput = removedDecision?.command_output || ''
|
|
setCommandOutput(prevOutput)
|
|
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)
|
|
const currentOptions = currentNode?.options || []
|
|
|
|
// Keyboard shortcuts - must be called unconditionally (React hooks rules)
|
|
useTreeNavigationShortcuts({
|
|
onSelectOption: (index) => {
|
|
const option = currentOptions[index]
|
|
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()
|
|
} else if (currentNode?.type === 'solution') {
|
|
handleComplete()
|
|
}
|
|
},
|
|
optionCount: currentOptions.length,
|
|
canGoBack: pathTaken.length > 1 && !showMetadataForm && !isLoading && !selectingOption,
|
|
canContinue: !showMetadataForm && !isLoading && !selectingOption && (currentNode?.type === 'action' || currentNode?.type === 'solution'),
|
|
})
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !tree) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="rounded-md bg-red-400/10 p-4 text-red-400">
|
|
{error || 'Tree not found'}
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/trees')}
|
|
className="mt-4 text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
Back to trees
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Session metadata form
|
|
if (showMetadataForm) {
|
|
return (
|
|
<div className="container mx-auto max-w-lg px-4 py-8">
|
|
<h1 className="mb-2 text-2xl font-bold font-heading text-foreground">{tree.name}</h1>
|
|
<p className="mb-6 text-muted-foreground">{tree.description}</p>
|
|
|
|
<div className="bg-card border border-border rounded-xl space-y-4 p-6">
|
|
<h2 className="font-semibold text-foreground">Session Details</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Optional: Add ticket and client info for easier tracking
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground">
|
|
Ticket Number
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={ticketNumber}
|
|
onChange={(e) => setTicketNumber(e.target.value)}
|
|
placeholder="e.g., INC0012345"
|
|
className={cn(
|
|
'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground">
|
|
Client Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={clientName}
|
|
onChange={(e) => setClientName(e.target.value)}
|
|
placeholder="e.g., Acme Corp"
|
|
className={cn(
|
|
'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={startSession}
|
|
className={cn(
|
|
'w-full rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Start Troubleshooting
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!currentNode && !currentCustomStep) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="rounded-md bg-red-400/10 p-4 text-red-400">
|
|
Invalid tree structure
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full">
|
|
{/* Main Content */}
|
|
<div className={cn('h-full overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
|
|
<div className="mx-auto max-w-4xl">
|
|
{/* Header */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-xl font-bold font-heading text-foreground">{tree.name}</h1>
|
|
{timerDisplay && (
|
|
<span className="flex items-center gap-1.5 rounded-full bg-accent px-2 py-0.5 text-[0.6875rem] font-label text-muted-foreground">
|
|
<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-muted-foreground hover:bg-accent hover:text-foreground"
|
|
title="Keyboard shortcuts"
|
|
>
|
|
<HelpCircle className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
{(ticketNumber || clientName) && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{ticketNumber && `Ticket: ${ticketNumber}`}
|
|
{ticketNumber && clientName && ' · '}
|
|
{clientName && `Client: ${clientName}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Share Progress Popover */}
|
|
<div className="relative" ref={sharePopoverRef}>
|
|
<button
|
|
onClick={() => setShowSharePopover(!showSharePopover)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground transition-colors'
|
|
)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Share Progress
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
{showSharePopover && (
|
|
<div className="absolute right-0 mt-1 z-50 bg-card border border-border rounded-xl p-1 min-w-[220px]">
|
|
{/* Copy Progress Summary */}
|
|
<button
|
|
onClick={() => {
|
|
handleCopyForTicket()
|
|
setShowSharePopover(false)
|
|
}}
|
|
disabled={isCopyingForTicket}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground w-full text-left cursor-pointer transition-colors',
|
|
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
|
)}
|
|
>
|
|
{copiedForTicket ? <Check className="h-4 w-4 text-emerald-400" /> : <Clipboard className="h-4 w-4" />}
|
|
{copiedForTicket ? 'Copied!' : 'Copy Progress Summary'}
|
|
</button>
|
|
{/* Copy Share Link */}
|
|
<button
|
|
onClick={() => {
|
|
handleCopyShareLink()
|
|
setShowSharePopover(false)
|
|
}}
|
|
disabled={isCopyingShareLink}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground w-full text-left cursor-pointer transition-colors',
|
|
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
|
)}
|
|
>
|
|
{copiedShareLink ? <Check className="h-4 w-4 text-emerald-400" /> : <Link2 className="h-4 w-4" />}
|
|
{isCopyingShareLink ? 'Loading...' : copiedShareLink ? 'Copied!' : 'Copy Share Link'}
|
|
</button>
|
|
{/* Divider */}
|
|
<div className="border-t border-border my-1" />
|
|
{/* Manage Share Links */}
|
|
<button
|
|
onClick={() => {
|
|
setShowSharePopover(false)
|
|
setShowShareModal(true)
|
|
}}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground w-full text-left cursor-pointer transition-colors',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
Manage Share Links...
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/sessions')}
|
|
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
Exit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breadcrumb */}
|
|
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
|
|
{pathTaken.map((nodeId, index) => {
|
|
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-muted-foreground">→</span>}
|
|
{index < pathTaken.length - 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleBreadcrumbJump(nodeId, index)}
|
|
className="text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
{truncatedLabel}
|
|
</button>
|
|
) : (
|
|
<span className="font-medium text-foreground">
|
|
{truncatedLabel}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Current Node */}
|
|
<div className="bg-card border border-border rounded-xl p-6 shadow-sm">
|
|
{/* Answer placeholder guard */}
|
|
{currentNode && currentNode.type === 'answer' && (
|
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
|
|
<p className="text-sm font-medium text-yellow-400">
|
|
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Decision Node */}
|
|
{currentNode && currentNode.type === 'decision' && (
|
|
<>
|
|
<h2 className="mb-2 text-xl font-semibold font-heading text-foreground">
|
|
{currentNode.question}
|
|
</h2>
|
|
{currentNode.help_text && (
|
|
<div className="mb-4 text-sm text-muted-foreground">
|
|
<MarkdownContent content={currentNode.help_text} />
|
|
</div>
|
|
)}
|
|
<div className="mb-4 space-y-2">
|
|
{currentNode.options?.map((option, index) => (
|
|
<button
|
|
key={option.id}
|
|
onClick={() => handleSelectOption(option.id, option.label, option.next_node_id)}
|
|
disabled={!!selectingOption}
|
|
className={cn(
|
|
'w-full rounded-md border border-border p-3 text-left text-foreground transition-colors',
|
|
'hover:border-primary/30 hover:bg-accent',
|
|
'flex items-center gap-3',
|
|
selectingOption && selectingOption !== option.id && 'opacity-50 pointer-events-none'
|
|
)}
|
|
>
|
|
{index < 9 && (
|
|
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-border border-t-foreground" />
|
|
</span>
|
|
) : (
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-muted-foreground">
|
|
{index + 1}
|
|
</span>
|
|
)
|
|
)}
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Previously-created custom steps at this node */}
|
|
{customStepFlow.customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && (
|
|
<div className="mt-2 space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Your Custom Steps
|
|
</p>
|
|
{customStepFlow.customSteps
|
|
.filter(cs => cs.inserted_after_node_id === currentNodeId)
|
|
.map(cs => (
|
|
<button
|
|
type="button"
|
|
key={cs.id}
|
|
onClick={() => customStepFlow.handleNavigateToCustomStep(cs)}
|
|
className={cn(
|
|
'w-full rounded-md border border-primary/30 bg-primary/10 p-3 text-left text-foreground transition-colors',
|
|
'hover:border-primary/50 hover:bg-primary/20',
|
|
'flex items-center gap-3'
|
|
)}
|
|
>
|
|
<span className="flex-shrink-0 rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
|
Custom
|
|
</span>
|
|
<span>{cs.step_data.title}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Custom Step Button */}
|
|
<button
|
|
onClick={() => customStepFlow.setShowCustomStepModal(true)}
|
|
className="mt-2 inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Custom Step
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Custom Step Node */}
|
|
{currentCustomStep && (
|
|
<div className="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
|
{/* Custom Step Badge */}
|
|
<span className="mb-2 inline-block rounded-full bg-primary/20 px-2 py-1 text-xs font-medium text-primary">
|
|
Custom Step
|
|
</span>
|
|
|
|
<h2 className="mb-2 text-xl font-semibold font-heading text-foreground">
|
|
{currentCustomStep.step_data.title}
|
|
</h2>
|
|
|
|
{currentCustomStep.step_data.content.instructions && (
|
|
<div className="mb-4 text-muted-foreground">
|
|
<MarkdownContent content={currentCustomStep.step_data.content.instructions} />
|
|
</div>
|
|
)}
|
|
|
|
{currentCustomStep.step_data.content.help_text && (
|
|
<div className="mb-4 rounded bg-blue-500/10 p-3 text-sm">
|
|
<MarkdownContent content={currentCustomStep.step_data.content.help_text} />
|
|
</div>
|
|
)}
|
|
|
|
{currentCustomStep.step_data.content.commands && currentCustomStep.step_data.content.commands.length > 0 && (
|
|
<div className="mb-4">
|
|
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
|
|
<div className="space-y-2">
|
|
{currentCustomStep.step_data.content.commands.map((cmd, index) => (
|
|
<div key={index}>
|
|
<p className="mb-1 text-xs text-muted-foreground">{cmd.label}</p>
|
|
<div className="group relative">
|
|
<code className="block rounded bg-accent 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-muted-foreground hover:text-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Command Output Capture */}
|
|
<div className="mt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
|
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Terminal className="h-3.5 w-3.5" />
|
|
<span>Paste Output (Optional)</span>
|
|
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
|
|
</button>
|
|
{commandOutputOpen && (
|
|
<div className="mt-2">
|
|
<textarea
|
|
value={commandOutput}
|
|
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
|
|
placeholder="Paste command output here..."
|
|
rows={4}
|
|
maxLength={10000}
|
|
className={cn(
|
|
'block w-full rounded-md border border-border bg-accent px-3 py-2',
|
|
'font-mono text-sm text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
<p className="mt-1 text-right text-xs text-muted-foreground">
|
|
{commandOutput.length.toLocaleString()} / 10,000
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Continue to selected descendant */}
|
|
{customStepFlow.pendingContinuationNodeId && !customStepFlow.customBranchMode && (() => {
|
|
const targetNode = findNode(customStepFlow.pendingContinuationNodeId, tree?.tree_structure)
|
|
const targetLabel = targetNode?.question || targetNode?.title || 'next step'
|
|
return (
|
|
<div className="mt-6 border-t border-primary/30 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleCustomContinueToDescendant}
|
|
className={cn(
|
|
'flex w-full items-center justify-between rounded-md bg-gradient-brand px-4 py-3 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
<span>Continue to: {targetLabel.length > 50 ? `${targetLabel.slice(0, 50)}...` : targetLabel}</span>
|
|
<ArrowRight className="h-4 w-4 flex-shrink-0" />
|
|
</button>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Custom Branch Controls */}
|
|
{customStepFlow.customBranchMode && (
|
|
<div className="mt-6 border-t border-primary/30 pt-4">
|
|
<p className="mb-3 text-sm text-amber-400">
|
|
Building custom branch - add steps until the issue is resolved
|
|
</p>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={() => customStepFlow.setShowCustomStepModal(true)}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Another Step
|
|
</button>
|
|
<button
|
|
onClick={handleCustomBranchCompleteWithOutput}
|
|
disabled={isCompleting}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
|
|
'hover:bg-green-700 disabled:opacity-50'
|
|
)}
|
|
>
|
|
<CheckCircle className="h-4 w-4" />
|
|
{isCompleting ? 'Completing...' : 'This Solves My Issue'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Node */}
|
|
{currentNode && currentNode.type === 'action' && (
|
|
<>
|
|
<h2 className="mb-2 text-xl font-semibold font-heading text-foreground">
|
|
{currentNode.title}
|
|
</h2>
|
|
{currentNode.description && (
|
|
<div className="mb-4 text-muted-foreground">
|
|
<MarkdownContent content={currentNode.description} />
|
|
</div>
|
|
)}
|
|
{currentNode.commands && currentNode.commands.length > 0 && (
|
|
<div className="mb-4">
|
|
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
|
|
<div className="space-y-1">
|
|
{currentNode.commands.map((cmd, index) => (
|
|
<div key={index} className="group relative">
|
|
<code className="block rounded bg-accent 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-muted-foreground hover:text-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Command Output Capture */}
|
|
<div className="mt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
|
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Terminal className="h-3.5 w-3.5" />
|
|
<span>Paste Output (Optional)</span>
|
|
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
|
|
</button>
|
|
{commandOutputOpen && (
|
|
<div className="mt-2">
|
|
<textarea
|
|
value={commandOutput}
|
|
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
|
|
placeholder="Paste command output here..."
|
|
rows={4}
|
|
maxLength={10000}
|
|
className={cn(
|
|
'block w-full rounded-md border border-border bg-accent px-3 py-2',
|
|
'font-mono text-sm text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
<p className="mt-1 text-right text-xs text-muted-foreground">
|
|
{commandOutput.length.toLocaleString()} / 10,000
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{currentNode.expected_outcome && (
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
<strong>Expected outcome:</strong> {currentNode.expected_outcome}
|
|
</p>
|
|
)}
|
|
{currentNode.next_node_id && (
|
|
<button
|
|
onClick={() => handleContinue()}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Continue
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Solution Node */}
|
|
{currentNode && currentNode.type === 'solution' && (
|
|
<>
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<span className="rounded-full bg-green-900/30 px-2 py-1 text-xs font-medium text-green-400">
|
|
Solution
|
|
</span>
|
|
</div>
|
|
<h2 className="mb-2 text-xl font-semibold font-heading text-foreground">
|
|
{currentNode.title}
|
|
</h2>
|
|
{currentNode.description && (
|
|
<div className="mb-4 text-muted-foreground">
|
|
<MarkdownContent content={currentNode.description} />
|
|
</div>
|
|
)}
|
|
{currentNode.resolution_steps && currentNode.resolution_steps.length > 0 && (
|
|
<div className="mb-4">
|
|
<p className="mb-2 text-sm font-medium text-foreground">Resolution steps:</p>
|
|
<ol className="list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
|
{currentNode.resolution_steps.map((step, index) => (
|
|
<li key={index}>{step}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={handleComplete}
|
|
disabled={isCompleting}
|
|
className={cn(
|
|
'rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
|
|
'hover:bg-green-700 disabled:opacity-50'
|
|
)}
|
|
>
|
|
{isCompleting ? 'Completing...' : 'Complete Session'}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Step Feedback */}
|
|
{session && (currentNode || currentCustomStep) && (
|
|
<div className="mt-3 flex justify-end border-t border-border pt-3">
|
|
<StepFeedback stepId={currentCustomStep?.id || currentNodeId} sessionId={session.id} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
<div className="mt-6 border-t border-border pt-4">
|
|
<label className="block text-sm font-medium text-foreground">
|
|
Notes (optional)
|
|
</label>
|
|
<textarea
|
|
id="session-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Add any notes for this step..."
|
|
rows={2}
|
|
className={cn(
|
|
'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Back Button */}
|
|
{pathTaken.length > 1 && (
|
|
<button
|
|
onClick={handleGoBack}
|
|
className="mt-4 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
← Go back <span className="text-xs text-muted-foreground">[Esc]</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Keyboard Shortcuts Hint */}
|
|
{currentNode && (
|
|
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
|
|
<span className="font-medium">Keyboard:</span>{' '}
|
|
{currentNode.type === 'decision' && currentOptions.length > 0 && (
|
|
<span>1-{Math.min(currentOptions.length, 9)} select option</span>
|
|
)}
|
|
{pathTaken.length > 1 && <span>, Esc go back</span>}
|
|
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
|
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
|
)}
|
|
<span>, Tab notes</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Custom Step Modal */}
|
|
<CustomStepModal
|
|
isOpen={customStepFlow.showCustomStepModal}
|
|
onClose={() => customStepFlow.setShowCustomStepModal(false)}
|
|
onInsertStep={customStepFlow.handleStepCreated}
|
|
/>
|
|
|
|
{/* Post Step Action Modal */}
|
|
{customStepFlow.pendingStep && (
|
|
<PostStepActionModal
|
|
isOpen={customStepFlow.showPostStepModal}
|
|
onClose={customStepFlow.resetPendingStep}
|
|
step={customStepFlow.pendingStep}
|
|
onSaveForLater={customStepFlow.handleSaveForLater}
|
|
onUseNow={customStepFlow.handleUseNow}
|
|
onBoth={customStepFlow.handleBoth}
|
|
isFromLibrary={customStepFlow.pendingStepIsFromLibrary}
|
|
isSaving={customStepFlow.isSavingStep}
|
|
/>
|
|
)}
|
|
|
|
{/* Continuation Modal */}
|
|
<ContinuationModal
|
|
isOpen={customStepFlow.showContinuationModal}
|
|
onClose={() => customStepFlow.setShowContinuationModal(false)}
|
|
descendantNodes={customStepFlow.branchOriginNodeId ? customStepFlow.getDescendantNodes(customStepFlow.branchOriginNodeId) : []}
|
|
onSelectNode={customStepFlow.handleSelectDescendant}
|
|
onBuildCustomBranch={customStepFlow.handleBuildCustomBranch}
|
|
/>
|
|
|
|
{/* Fork Tree Modal */}
|
|
<ForkTreeModal
|
|
isOpen={customStepFlow.showForkModal}
|
|
onClose={() => customStepFlow.setShowForkModal(false)}
|
|
originalTreeName={tree?.name || 'Tree'}
|
|
onFork={customStepFlow.handleForkTree}
|
|
onSkip={customStepFlow.handleSkipFork}
|
|
/>
|
|
|
|
<SessionOutcomeModal
|
|
isOpen={showOutcomeModal}
|
|
onClose={closeOutcomeModal}
|
|
onSubmit={handleSubmitOutcome}
|
|
isSubmitting={isCompleting}
|
|
/>
|
|
|
|
{session && (
|
|
<CSATModal
|
|
isOpen={showCsatModal}
|
|
onClose={handleCsatClose}
|
|
sessionId={session.id}
|
|
/>
|
|
)}
|
|
|
|
{/* 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-muted-foreground">Select option</span>
|
|
<span className="rounded bg-accent px-2 py-0.5 font-mono text-xs text-foreground">1-9</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Go back</span>
|
|
<span className="rounded bg-accent px-2 py-0.5 font-mono text-xs text-foreground">Esc</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Continue / Complete</span>
|
|
<span className="rounded bg-accent px-2 py-0.5 font-mono text-xs text-foreground">Enter</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Focus notes</span>
|
|
<span className="rounded bg-accent px-2 py-0.5 font-mono text-xs text-foreground">Tab</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Show shortcuts</span>
|
|
<span className="rounded bg-accent px-2 py-0.5 font-mono text-xs text-foreground">?</span>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Share Session Modal */}
|
|
{session && (
|
|
<ShareSessionModal
|
|
sessionId={session.id}
|
|
sessionLabel={ticketNumber || tree?.name || 'Session'}
|
|
isOpen={showShareModal}
|
|
onClose={() => setShowShareModal(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scratchpad Sidebar */}
|
|
{session && (
|
|
<ScratchpadSidebar
|
|
sessionId={session.id}
|
|
initialContent={session.scratchpad ?? ''}
|
|
onSave={handleScratchpadSave}
|
|
onOpenChange={setScratchpadOpen}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TreeNavigationPage
|