diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7e5432f0..07b6bd36 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -37,15 +37,16 @@ function handleGlobalError(error: AxiosError) { return } - // Rate limit + // Rate limit — always worth notifying if (status === 429) { toast.error(detail || 'Too many requests — please try again shortly') return } - // Client errors (4xx) — show backend detail if present + // Client errors (4xx) — don't toast globally. + // Pages handle their own 4xx errors (permission checks, validation, not-found) + // and many are caught silently. Global toasts here cause noisy duplicates. if (status >= 400 && status < 500) { - toast.error(detail || 'Invalid request') return } diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx index 39c52274..a6d76349 100644 --- a/frontend/src/components/tree-editor/NodeEditorPanel.tsx +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -40,14 +40,15 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) const panelRef = useRef(null) - // Initialize/reset draft when nodeId changes + // Initialize/reset draft when nodeId changes or when node type changes + // (e.g., answer stub → decision/action/solution via type picker) useEffect(() => { if (node) { setDraft(cloneWithoutChildren(node)) setIsDirty(false) setShowDeleteConfirm(false) } - }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps const handleDraftUpdate = useCallback((updates: Partial) => { setDraft(prev => prev ? { ...prev, ...updates } : prev) @@ -60,7 +61,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan updateNode(nodeId, draftWithoutChildren) // Auto-create answer stubs for new decision options without next_node_id - if (draft.options) { + if (draft.type === 'decision' && draft.options) { const options = draft.options.filter(o => o.label.trim()) const stubsCreated: Array<{ optId: string; stubId: string }> = [] @@ -81,6 +82,13 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan } } + // Auto-create answer stub for action node without next_node_id + if (draft.type === 'action' && !draft.next_node_id) { + const stubId = addNode(nodeId, 'answer') + updateNode(stubId, { title: 'Next Step' }) + updateNode(nodeId, { next_node_id: stubId }) + } + setIsDirty(false) }, [draft, node, nodeId, updateNode, addNode]) diff --git a/frontend/src/components/tree-editor/NodeFormAction.tsx b/frontend/src/components/tree-editor/NodeFormAction.tsx index 90fcd1cf..2cd5b837 100644 --- a/frontend/src/components/tree-editor/NodeFormAction.tsx +++ b/frontend/src/components/tree-editor/NodeFormAction.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { DynamicArrayField } from './DynamicArrayField' -import { NodePicker } from './NodePicker' import { useTreeEditorStore } from '@/store/treeEditorStore' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { InfoTip } from '@/components/common/InfoTip' @@ -20,9 +19,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) { e => e.nodeId === node.id && e.field === 'title' ) - const nextNodeError = validationErrors.find( - e => e.nodeId === node.id && e.field === 'next_node_id' - ) + const hasNextNode = !!node.next_node_id const handleAddCommand = () => { onUpdate({ @@ -161,16 +158,16 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) { /> - {/* Next Node */} - onUpdate({ next_node_id: nodeId })} - parentNodeId={node.id} - excludeNodeId={node.id} - label="Next Node (after action)" - placeholder="Select or create next node..." - error={nextNodeError?.message} - /> + {/* Next step hint */} + {hasNextNode ? ( +

+ Next step is linked — click it on the canvas to edit. +

+ ) : ( +

+ Save to create a placeholder for the next step. +

+ )} ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index e521169c..06bc9aee 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -198,59 +198,61 @@ } } -/* Sonner Toast Customization */ +/* Sonner Toast Customization — outside @layer for higher specificity */ +[data-sonner-toast] { + background-color: hsl(var(--card)) !important; + color: hsl(var(--card-foreground)) !important; + border: 1px solid hsl(var(--border)) !important; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3) !important; + border-radius: 0.75rem; + font-family: 'Inter', system-ui, sans-serif; +} + +[data-sonner-toast] [data-title] { + font-family: 'Inter', system-ui, sans-serif; + font-weight: 600; +} + +[data-sonner-toast][data-type="success"] { + border-color: rgba(52, 211, 153, 0.3) !important; +} +[data-sonner-toast][data-type="success"] [data-icon] { + color: #34d399; +} + +[data-sonner-toast][data-type="error"] { + border-color: rgba(248, 113, 113, 0.3) !important; +} +[data-sonner-toast][data-type="error"] [data-icon] { + color: #f87171; +} + +[data-sonner-toast][data-type="info"] { + border-color: hsl(var(--border)) !important; +} +[data-sonner-toast][data-type="info"] [data-icon] { + color: hsl(var(--muted-foreground)); +} + +[data-sonner-toast][data-type="warning"] { + border-color: rgba(251, 191, 36, 0.3) !important; +} +[data-sonner-toast][data-type="warning"] [data-icon] { + color: #fbbf24; +} + +[data-sonner-toast] [data-close-button] { + color: hsl(var(--muted-foreground)); + border-radius: 0.375rem; + transition: color 150ms, background-color 150ms; +} +[data-sonner-toast] [data-close-button]:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +/* React Day Picker Customization */ @layer components { - :where([data-sonner-toast]) { - @apply bg-card text-card-foreground; - @apply border border-border shadow-lg; - @apply rounded-xl; - font-family: 'Inter', system-ui, sans-serif; - backdrop-filter: blur(10px); - } - - :where([data-sonner-toast]) [data-title] { - font-family: 'Inter', system-ui, sans-serif; - font-weight: 600; - } - - :where([data-sonner-toast][data-type="success"]) { - border-color: rgba(52, 211, 153, 0.3); - } - :where([data-sonner-toast][data-type="success"]) [data-icon] { - color: #34d399; - } - - :where([data-sonner-toast][data-type="error"]) { - border-color: rgba(248, 113, 113, 0.3); - } - :where([data-sonner-toast][data-type="error"]) [data-icon] { - color: #f87171; - } - - :where([data-sonner-toast][data-type="info"]) { - @apply border-border; - } - :where([data-sonner-toast][data-type="info"]) [data-icon] { - @apply text-muted-foreground; - } - - :where([data-sonner-toast][data-type="warning"]) { - border-color: rgba(251, 191, 36, 0.3); - } - :where([data-sonner-toast][data-type="warning"]) [data-icon] { - color: #fbbf24; - } - - :where([data-sonner-toast]) [data-close-button] { - @apply text-muted-foreground hover:bg-accent hover:text-accent-foreground; - @apply rounded-md transition-colors; - } - - :where([data-sonner-toast]) [data-icon][data-loading] { - @apply text-white; - } - - /* React Day Picker Customization */ .rdp-custom { @apply text-foreground; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e8bd2b5b..bdb2d697 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,6 +13,10 @@ createRoot(document.getElementById('root')!).render( closeButton visibleToasts={3} gap={8} + theme="dark" + toastOptions={{ + className: 'sonner-toast-custom', + }} /> ,