From 47051b3ed8e5a56f62b4621489d7934921a4cd55 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 14:19:51 -0500 Subject: [PATCH 01/11] 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 --- .../tree-editor/NodeEditorPanel.tsx | 6 +++--- .../tree-editor/NodeFormDecision.tsx | 20 +++++-------------- .../tree-editor/NodeFormResolution.tsx | 7 +++---- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx index ba085d28..b5487db7 100644 --- a/frontend/src/components/tree-editor/NodeEditorPanel.tsx +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -162,7 +162,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const isRoot = treeStructure?.id === nodeId return ( -
+
{/* Header */}
@@ -175,14 +175,14 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
{/* Body — scrollable form area */} -
+
{draft.type === 'decision' && } {draft.type === 'action' && } {draft.type === 'solution' && }
{/* Footer */} -
+
) } -- 2.49.1 From 78b4242a76cbb2a09f1e46e87f8cd79bb2d5cd36 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 14:22:58 -0500 Subject: [PATCH 02/11] 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 --- frontend/src/api/accounts.ts | 2 +- frontend/src/api/client.ts | 14 ++++++------ frontend/src/components/layout/Sidebar.tsx | 2 +- frontend/src/pages/StepLibraryPage.tsx | 25 ++++++++++++++++++++++ frontend/src/pages/TreeLibraryPage.tsx | 4 ++-- frontend/src/router.tsx | 9 ++++++++ frontend/src/store/authStore.ts | 6 ++++-- 7 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/StepLibraryPage.tsx diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 943d4481..3e1e3b04 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -25,7 +25,7 @@ export const accountsApi = { async updateMemberRole(userId: string, role: string): Promise { const response = await apiClient.patch( `/accounts/me/members/${userId}/role`, - { role } + { account_role: role } ) return response.data }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cf57d557..540d69dc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -32,13 +32,15 @@ function handleGlobalError(error: AxiosError) { return } - // Client errors (4xx) + // Rate limit + if (status === 429) { + toast.error(data?.detail || 'Too many requests — please try again shortly') + return + } + + // Client errors (4xx) — show backend detail if present if (status >= 400 && status < 500) { - const message = data?.detail || 'Invalid request' - // Only show generic messages - pages handle specific errors - if (!data?.detail) { - toast.error(message) - } + toast.error(data?.detail || 'Invalid request') return } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index da359cb8..394d5e58 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -172,7 +172,7 @@ export function Sidebar() { - +
diff --git a/frontend/src/pages/StepLibraryPage.tsx b/frontend/src/pages/StepLibraryPage.tsx new file mode 100644 index 00000000..5592c7d8 --- /dev/null +++ b/frontend/src/pages/StepLibraryPage.tsx @@ -0,0 +1,25 @@ +import { Bookmark } from 'lucide-react' + +export default function StepLibraryPage() { + return ( +
+
+
+ +

Step Library

+
+

Reusable steps for your flows — coming soon.

+
+ +
+
+ +
+

Coming Soon

+

+ The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows. +

+
+
+ ) +} diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 3a2c1610..28f0635b 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -75,7 +75,7 @@ export function TreeLibraryPage() { const lastSessionData = (() => { const raw = safeGetItem('last-session') if (!raw) return null - try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string } } + try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } } catch { return null } })() @@ -450,7 +450,7 @@ export function TreeLibraryPage() { {lastSessionData && (
- setUserMenuOpen(false)} - className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" - > - - Account - setUserMenuOpen(false)} className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" > - Settings + Account {isSuperAdmin && ( ([]) - const [allSteps, setAllSteps] = useState<{ category_id?: string }[]>([]) - const [isLoading, setIsLoading] = useState(true) - const [showCreateModal, setShowCreateModal] = useState(false) - const [showEditModal, setShowEditModal] = useState(false) - const [editingCategory, setEditingCategory] = useState(null) - const [isSaving, setIsSaving] = useState(false) - const [includeArchived, setIncludeArchived] = useState(false) - - useEffect(() => { - loadData() - }, [includeArchived]) - - const loadData = async () => { - setIsLoading(true) - try { - const [categoriesData, stepsData] = await Promise.all([ - stepCategoriesApi.list({ include_inactive: includeArchived }), - stepsApi.list({}) - ]) - setCategories(categoriesData) - setAllSteps(stepsData) - } catch (err) { - console.error('Failed to load categories:', err) - toast.error('Failed to load categories') - } finally { - setIsLoading(false) - } - } - - const getStepCount = (categoryId: string) => { - return allSteps?.filter(s => s.category_id === categoryId).length || 0 - } - - const handleCreate = async (data: { name: string; description: string }) => { - setIsSaving(true) - try { - await stepCategoriesApi.create({ - name: data.name, - description: data.description || undefined - }) - toast.success('Category created successfully') - setShowCreateModal(false) - await loadData() - } catch (err) { - console.error('Failed to create category:', err) - toast.error('Failed to create category') - throw err - } finally { - setIsSaving(false) - } - } - - const handleEdit = async (data: { name: string; description: string }) => { - if (!editingCategory) return - setIsSaving(true) - try { - await stepCategoriesApi.update(editingCategory.id, { - name: data.name, - description: data.description || undefined - }) - toast.success('Category updated successfully') - setShowEditModal(false) - setEditingCategory(null) - await loadData() - } catch (err) { - console.error('Failed to update category:', err) - toast.error('Failed to update category') - throw err - } finally { - setIsSaving(false) - } - } - - const handleArchive = async (id: string) => { - try { - await stepCategoriesApi.archive(id) - toast.success('Category archived') - await loadData() - } catch (err) { - console.error('Failed to archive category:', err) - toast.error('Failed to archive category') - } - } - - const handleRestore = async (id: string) => { - try { - await stepCategoriesApi.restore(id) - toast.success('Category restored') - await loadData() - } catch (err) { - console.error('Failed to restore category:', err) - toast.error('Failed to restore category') - } - } - - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - const oldIndex = categories.findIndex(c => c.id === active.id) - const newIndex = categories.findIndex(c => c.id === over.id) - - const reordered = arrayMove(categories, oldIndex, newIndex) - - // Optimistic update - setCategories(reordered) - - try { - // Update display_order for all affected categories - const updates = reordered.map((cat, index) => ({ - id: cat.id, - display_order: index - })) - await stepCategoriesApi.updateOrder(updates) - toast.success('Categories reordered') - } catch (err) { - console.error('Failed to reorder categories:', err) - toast.error('Failed to save order') - // Revert on error - await loadData() - } - } - - const openEditModal = (category: StepCategoryListItem) => { - setEditingCategory(category) - setShowEditModal(true) - } - - if (isLoading) { - return ( -
-
-
- ) - } - - return ( -
- {/* Header */} -
-
-

- Step Categories -

-

- Manage categories for organizing step library -

-
- -
- - {/* Filter Toggle */} -
- -
- - {/* Categories List */} - {categories.length === 0 ? ( -
-

- No categories found. Create your first category to get started. -

-
- ) : ( - - c.id)} - strategy={verticalListSortingStrategy} - > -
- {categories.map(category => ( - - ))} -
-
-
- )} - - {/* Create Modal */} - setShowCreateModal(false)} - onSubmit={handleCreate} - isSaving={isSaving} - /> - - {/* Edit Modal */} - { - setShowEditModal(false) - setEditingCategory(null) - }} - onSubmit={handleEdit} - category={editingCategory} - isSaving={isSaving} - /> -
- ) -} - -export default AdminCategoriesPage diff --git a/frontend/src/pages/ProceduralEditorPage.tsx b/frontend/src/pages/ProceduralEditorPage.tsx index 2bf6c9d2..67fbbec7 100644 --- a/frontend/src/pages/ProceduralEditorPage.tsx +++ b/frontend/src/pages/ProceduralEditorPage.tsx @@ -83,13 +83,13 @@ export function ProceduralEditorPage() { const tree = await treesApi.get(treeId) if (tree.tree_type !== 'procedural' && tree.tree_type !== 'maintenance') { toast.error('This flow is not a procedural or maintenance flow') - navigate('/my-trees') + navigate('/trees') return } loadTree(tree) } catch { toast.error('Failed to load flow') - navigate('/my-trees') + navigate('/trees') } } @@ -154,7 +154,7 @@ export function ProceduralEditorPage() {
) @@ -354,6 +356,19 @@ export function ProceduralNavigationPage() {

{tree.name}

+
)} + setShowExitConfirm(false)} + onConfirm={() => navigate('/trees')} + title="Exit Session" + message="You have progress in this session. Are you sure you want to exit? Your progress will not be saved." + confirmLabel="Exit" + /> + {/* Parameters popover */} {paramsOpen && (
diff --git a/frontend/src/pages/TeamAnalyticsPage.tsx b/frontend/src/pages/TeamAnalyticsPage.tsx index 3df0ff73..2df599d5 100644 --- a/frontend/src/pages/TeamAnalyticsPage.tsx +++ b/frontend/src/pages/TeamAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { BarChart3, Loader2, Users, Target, Clock, TrendingUp, ShieldX } from 'lucide-react' +import { Link, Navigate } from 'react-router-dom' +import { BarChart3, Loader2, Users, Target, Clock, TrendingUp } from 'lucide-react' import { AreaChart, Area, @@ -44,25 +44,8 @@ export default function TeamAnalyticsPage() { .finally(() => setLoading(false)) }, [period, isAccountOwner, isSuperAdmin]) - // Permission guard if (!isAccountOwner && !isSuperAdmin) { - return ( -
- -

Access Denied

-

- Team Analytics is only available to account owners and administrators. - You can view your personal stats instead. -

- - - View My Stats - -
- ) + return } if (loading) { diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index b0af4285..165b4306 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -141,6 +141,7 @@ export function TreeEditorPage() { // Permission guard: redirect viewers away from editor useEffect(() => { if (!canCreateTrees) { + toast.error("You don't have permission to edit flows") navigate('/trees') } }, [canCreateTrees, navigate]) @@ -155,6 +156,7 @@ export function TreeEditorPage() { try { const tree = await treesApi.get(id) if (!canEditTree({ author_id: tree.author_id, account_id: tree.account_id })) { + toast.error("You don't have permission to edit this flow") navigate('/trees') return } @@ -162,6 +164,7 @@ export function TreeEditorPage() { setTreeStatus(tree.status) // Load status from existing tree } catch (err) { console.error('Failed to load tree:', err) + toast.error('Failed to load flow') navigate('/trees') } } else { diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 59ac587a..fa4be554 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -7,4 +7,3 @@ export { default as TreeEditorPage } from './TreeEditorPage' export { default as SessionHistoryPage } from './SessionHistoryPage' export { default as SessionDetailPage } from './SessionDetailPage' export { default as AccountSettingsPage } from './AccountSettingsPage' -export { default as AdminCategoriesPage } from './AdminCategoriesPage' -- 2.49.1 From c309a0ba84b26ce2f29a1ac1fafceb0e04e3a7ff Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 14:32:01 -0500 Subject: [PATCH 04/11] 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 --- frontend/src/api/pinnedFlows.ts | 2 +- frontend/src/components/admin/EmptyState.tsx | 27 +-------- frontend/src/components/common/EmptyState.tsx | 25 ++++++++ frontend/src/components/common/PageLoader.tsx | 4 +- frontend/src/components/common/Spinner.tsx | 26 +++++++++ .../src/components/library/FolderSidebar.tsx | 23 ++++++-- .../components/sidebar/PinnedFlowsSection.tsx | 2 +- .../tree-editor/NodeEditorPanel.tsx | 17 +++++- frontend/src/pages/AccountSettingsPage.tsx | 3 +- frontend/src/pages/MyAnalyticsPage.tsx | 5 +- frontend/src/pages/MySharesPage.tsx | 57 ++++++++++++------- frontend/src/pages/MyTreesPage.tsx | 3 +- frontend/src/pages/ProceduralEditorPage.tsx | 3 +- .../src/pages/ProceduralNavigationPage.tsx | 3 +- frontend/src/pages/SessionDetailPage.tsx | 3 +- frontend/src/pages/SessionHistoryPage.tsx | 35 ++++++------ frontend/src/pages/SharedSessionPage.tsx | 5 +- frontend/src/pages/TeamAnalyticsPage.tsx | 5 +- frontend/src/pages/TreeEditorPage.tsx | 3 +- frontend/src/pages/TreeNavigationPage.tsx | 3 +- 20 files changed, 167 insertions(+), 87 deletions(-) create mode 100644 frontend/src/components/common/EmptyState.tsx create mode 100644 frontend/src/components/common/Spinner.tsx diff --git a/frontend/src/api/pinnedFlows.ts b/frontend/src/api/pinnedFlows.ts index 41b3754a..6ac05d8d 100644 --- a/frontend/src/api/pinnedFlows.ts +++ b/frontend/src/api/pinnedFlows.ts @@ -4,7 +4,7 @@ export interface PinnedFlow { id: string tree_id: string tree_name: string - tree_type: 'troubleshooting' | 'procedural' + tree_type: 'troubleshooting' | 'procedural' | 'maintenance' category_emoji?: string category_name?: string pinned_at: string diff --git a/frontend/src/components/admin/EmptyState.tsx b/frontend/src/components/admin/EmptyState.tsx index 22e4a266..4e58f7a8 100644 --- a/frontend/src/components/admin/EmptyState.tsx +++ b/frontend/src/components/admin/EmptyState.tsx @@ -1,25 +1,2 @@ -import type { ReactNode } from 'react' -import { cn } from '@/lib/utils' - -interface EmptyStateProps { - icon?: ReactNode - title: string - description?: string - action?: ReactNode - className?: string -} - -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { - return ( -
- {icon &&
{icon}
} -

{title}

- {description && ( -

{description}

- )} - {action &&
{action}
} -
- ) -} - -export default EmptyState +export { EmptyState } from '@/components/common/EmptyState' +export { default } from '@/components/common/EmptyState' diff --git a/frontend/src/components/common/EmptyState.tsx b/frontend/src/components/common/EmptyState.tsx new file mode 100644 index 00000000..22e4a266 --- /dev/null +++ b/frontend/src/components/common/EmptyState.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description?: string + action?: ReactNode + className?: string +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ) +} + +export default EmptyState diff --git a/frontend/src/components/common/PageLoader.tsx b/frontend/src/components/common/PageLoader.tsx index 90621223..0db230bd 100644 --- a/frontend/src/components/common/PageLoader.tsx +++ b/frontend/src/components/common/PageLoader.tsx @@ -1,8 +1,10 @@ +import { Spinner } from '@/components/common/Spinner' + export function PageLoader() { return (
-
+

Loading...

diff --git a/frontend/src/components/common/Spinner.tsx b/frontend/src/components/common/Spinner.tsx new file mode 100644 index 00000000..4222704d --- /dev/null +++ b/frontend/src/components/common/Spinner.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' + +const SIZES = { + sm: 'h-4 w-4 border-2', + md: 'h-8 w-8 border-4', + lg: 'h-12 w-12 border-4', +} as const + +interface SpinnerProps { + size?: keyof typeof SIZES + className?: string +} + +export function Spinner({ size = 'md', className }: SpinnerProps) { + return ( +
+ ) +} + +export default Spinner diff --git a/frontend/src/components/library/FolderSidebar.tsx b/frontend/src/components/library/FolderSidebar.tsx index 75c2a4dd..186fde17 100644 --- a/frontend/src/components/library/FolderSidebar.tsx +++ b/frontend/src/components/library/FolderSidebar.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react' import { foldersApi } from '@/api/folders' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import type { FolderListItem, FolderTreeItem } from '@/types' import { cn } from '@/lib/utils' @@ -245,6 +246,7 @@ export function FolderSidebar({ const [menuOpenId, setMenuOpenId] = useState(null) const [expandedIds, setExpandedIds] = useState>(new Set()) const [contextMenu, setContextMenu] = useState(null) + const [pendingDelete, setPendingDelete] = useState<{ id: string; message: string } | null>(null) const loadFolders = useCallback(async () => { setIsLoading(true) @@ -277,15 +279,19 @@ export function FolderSidebar({ }) } - const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => { + const handleDeleteFolder = (folderId: string, folderHasChildren: boolean) => { const descendantCount = getDescendantIds(folders, folderId).length const message = folderHasChildren ? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.` : 'Are you sure you want to delete this folder? The trees in it will not be deleted.' - if (!confirm(message)) { - return - } + setPendingDelete({ id: folderId, message }) + } + + const confirmDeleteFolder = async () => { + if (!pendingDelete) return + const folderId = pendingDelete.id + setPendingDelete(null) try { await foldersApi.delete(folderId) // Remove folder and all descendants from local state @@ -494,6 +500,15 @@ export function FolderSidebar({
)} + + setPendingDelete(null)} + onConfirm={confirmDeleteFolder} + title="Delete Folder" + message={pendingDelete?.message || ''} + confirmLabel="Delete" + /> ) } diff --git a/frontend/src/components/sidebar/PinnedFlowsSection.tsx b/frontend/src/components/sidebar/PinnedFlowsSection.tsx index 0dbacbf4..0e1ae165 100644 --- a/frontend/src/components/sidebar/PinnedFlowsSection.tsx +++ b/frontend/src/components/sidebar/PinnedFlowsSection.tsx @@ -46,7 +46,7 @@ export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) title={`${flow.tree_name} (right-click to unpin)`} > - {flow.tree_type === 'procedural' ? '📋' : '🔧'} + {flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'} {flow.tree_name} diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx index b5487db7..39c52274 100644 --- a/frontend/src/components/tree-editor/NodeEditorPanel.tsx +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -4,6 +4,7 @@ import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' import { NodeFormDecision } from './NodeFormDecision' import { NodeFormAction } from './NodeFormAction' import { NodeFormResolution } from './NodeFormResolution' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { cn } from '@/lib/utils' import type { TreeStructure, NodeType } from '@/types' @@ -36,6 +37,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const [draft, setDraft] = useState(null) const [isDirty, setIsDirty] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) const panelRef = useRef(null) // Initialize/reset draft when nodeId changes @@ -84,7 +86,8 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan const handleClose = useCallback(() => { if (isDirty) { - if (!window.confirm('You have unsaved changes. Discard them?')) return + setShowDiscardConfirm(true) + return } onClose() }, [isDirty, onClose]) @@ -236,6 +239,18 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan )}
+ + setShowDiscardConfirm(false)} + onConfirm={() => { + setShowDiscardConfirm(false) + onClose() + }} + title="Discard Changes" + message="You have unsaved changes. Discard them?" + confirmLabel="Discard" + />
) } diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index a3e0084b..e6b3c58a 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' import { useSubscription } from '@/hooks/useSubscription' @@ -130,7 +131,7 @@ export function AccountSettingsPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index 3670d601..1ead480a 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { BarChart3, Loader2, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react' +import { BarChart3, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react' import { AreaChart, Area, @@ -10,6 +10,7 @@ import { Tooltip, ResponsiveContainer, } from 'recharts' +import { Spinner } from '@/components/common/Spinner' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types' @@ -45,7 +46,7 @@ export default function MyAnalyticsPage() { if (loading) { return (
- +
) } diff --git a/frontend/src/pages/MySharesPage.tsx b/frontend/src/pages/MySharesPage.tsx index 48062682..17ea9a4a 100644 --- a/frontend/src/pages/MySharesPage.tsx +++ b/frontend/src/pages/MySharesPage.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { sessionsApi } from '@/api/sessions' @@ -47,6 +50,7 @@ export default function MySharesPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [copiedId, setCopiedId] = useState(null) + const [revokeTarget, setRevokeTarget] = useState(null) const fetchShares = useCallback(async () => { try { @@ -77,18 +81,16 @@ export default function MySharesPage() { } } - const handleRevoke = async (share: SessionShare) => { - const confirmed = window.confirm( - 'Revoke this share link? Anyone with the link will no longer be able to access the session.' - ) - if (!confirmed) return - + const handleRevoke = async () => { + if (!revokeTarget) return try { - await sessionsApi.revokeShare(share.id) - setShares((prev) => prev.filter((s) => s.id !== share.id)) + await sessionsApi.revokeShare(revokeTarget.id) + setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id)) toast.success('Share link revoked') } catch { toast.error('Failed to revoke share link') + } finally { + setRevokeTarget(null) } } @@ -96,7 +98,7 @@ export default function MySharesPage() { if (loading) { return (
-
+
) } @@ -139,18 +141,20 @@ export default function MySharesPage() { {/* Empty state */} {shares.length === 0 ? ( -
- -

No shared sessions

-

- Share a session from the session detail page to create a link -

- +
+ } + title="No shared sessions" + description="Share a session from the session detail page to create a link" + action={ + + } + />
) : (
@@ -223,7 +227,7 @@ export default function MySharesPage() {
)} + + setRevokeTarget(null)} + onConfirm={handleRevoke} + title="Revoke Share Link" + message="Revoke this share link? Anyone with the link will no longer be able to access the session." + confirmLabel="Revoke" + />
) } diff --git a/frontend/src/pages/MyTreesPage.tsx b/frontend/src/pages/MyTreesPage.tsx index 04422db4..ce3446da 100644 --- a/frontend/src/pages/MyTreesPage.tsx +++ b/frontend/src/pages/MyTreesPage.tsx @@ -7,6 +7,7 @@ import type { TreeListItem } from '@/types' import { TagBadges } from '@/components/common/TagBadges' import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ShareTreeModal } from '@/components/library/ShareTreeModal' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' @@ -177,7 +178,7 @@ export function MyTreesPage() { {/* Loading State */} {isLoading ? (
-
+
) : trees.length === 0 ? (
diff --git a/frontend/src/pages/ProceduralEditorPage.tsx b/frontend/src/pages/ProceduralEditorPage.tsx index 67fbbec7..a9a74560 100644 --- a/frontend/src/pages/ProceduralEditorPage.tsx +++ b/frontend/src/pages/ProceduralEditorPage.tsx @@ -9,6 +9,7 @@ import { MaintenanceScheduleSection } from '@/components/procedural-editor/Maint import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils' import { StepList } from '@/components/procedural-editor/StepList' import { TagInput } from '@/components/common/TagInput' +import { Spinner } from '@/components/common/Spinner' import { toast } from '@/lib/toast' import type { TreeType, MaintenanceSchedule, TargetList } from '@/types' @@ -143,7 +144,7 @@ export function ProceduralEditorPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index c9f23905..4d313ace 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -10,6 +10,7 @@ import { StepDetail } from '@/components/procedural/StepDetail' import { ProgressBar } from '@/components/procedural/ProgressBar' import { CompletionSummary } from '@/components/procedural/CompletionSummary' import { ConfirmDialog } from '@/components/common/ConfirmDialog' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { StepFeedback } from '@/components/session/StepFeedback' @@ -290,7 +291,7 @@ export function ProceduralNavigationPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index d6bc7785..dd4d8d9a 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -13,6 +13,7 @@ import type { MenuAction } from '@/components/common/ActionMenu' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' +import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' @@ -289,7 +290,7 @@ export function SessionDetailPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 7fb500ee..50ed13ca 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -6,6 +6,8 @@ import type { Session, TreeListItem } from '@/types' import type { DateRange } from 'react-day-picker' import { SessionFilters } from '@/components/session/SessionFilters' import type { SessionFilterState } from '@/components/session/SessionFilters' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { getSessionResumePath } from '@/lib/routing' @@ -188,27 +190,22 @@ export function SessionHistoryPage() { {/* Loading State */} {isLoading ? (
-
+
) : sessions.length === 0 ? ( -
- No sessions found.{' '} - {filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? ( - - ) : ( - - )} -
+ + Clear all filters + + ) : undefined + } + /> ) : (
{sessions.map((session) => ( diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx index 10e518b1..576432bb 100644 --- a/frontend/src/pages/SharedSessionPage.tsx +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' -import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react' +import { Globe, Users, ShieldAlert, FileX, Clock } from 'lucide-react' import { isAxiosError } from 'axios' import { sessionsApi } from '@/api/sessions' +import { Spinner } from '@/components/common/Spinner' import { BrandLogo } from '@/components/common/BrandLogo' import { SessionTimeline } from '@/components/session/SessionTimeline' import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview' @@ -144,7 +145,7 @@ export function SharedSessionPage() { return (
- +

Loading shared session...

diff --git a/frontend/src/pages/TeamAnalyticsPage.tsx b/frontend/src/pages/TeamAnalyticsPage.tsx index 2df599d5..3676af03 100644 --- a/frontend/src/pages/TeamAnalyticsPage.tsx +++ b/frontend/src/pages/TeamAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link, Navigate } from 'react-router-dom' -import { BarChart3, Loader2, Users, Target, Clock, TrendingUp } from 'lucide-react' +import { BarChart3, Users, Target, Clock, TrendingUp } from 'lucide-react' import { AreaChart, Area, @@ -10,6 +10,7 @@ import { Tooltip, ResponsiveContainer, } from 'recharts' +import { Spinner } from '@/components/common/Spinner' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types' @@ -51,7 +52,7 @@ export default function TeamAnalyticsPage() { if (loading) { return (
- +
) } diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 165b4306..85e886d2 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -11,6 +11,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { usePermissions } from '@/hooks/usePermissions' +import { Spinner } from '@/components/common/Spinner' import { cn, safeGetItem } from '@/lib/utils' import { toast } from '@/lib/toast' import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel' @@ -382,7 +383,7 @@ export function TreeEditorPage() { if (isLoading) { return (
-
+
) } diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 43418c02..00f31bf8 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -11,6 +11,7 @@ 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' @@ -528,7 +529,7 @@ export function TreeNavigationPage() { if (isLoading) { return (
-
+
) } -- 2.49.1 From 6d8f361d7d53e721b55a480d6e43445107ec560e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 14:34:06 -0500 Subject: [PATCH 05/11] 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 --- frontend/src/components/admin/PageHeader.tsx | 2 +- frontend/src/components/common/TagBadges.tsx | 4 ++-- frontend/src/main.tsx | 3 ++- frontend/src/pages/AccountSettingsPage.tsx | 4 ++-- frontend/src/pages/FeedbackPage.tsx | 2 +- frontend/src/pages/MyAnalyticsPage.tsx | 6 +++--- frontend/src/pages/MyTreesPage.tsx | 2 +- frontend/src/pages/TeamAnalyticsPage.tsx | 6 +++--- frontend/src/pages/account/TeamCategoriesPage.tsx | 2 +- 9 files changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx index a388f00f..d282bcdc 100644 --- a/frontend/src/components/admin/PageHeader.tsx +++ b/frontend/src/components/admin/PageHeader.tsx @@ -12,7 +12,7 @@ export function PageHeader({ title, description, action, className }: PageHeader return (
-

{title}

+

{title}

{description && (

{description}

)} diff --git a/frontend/src/components/common/TagBadges.tsx b/frontend/src/components/common/TagBadges.tsx index bd93cbc6..95d850b2 100644 --- a/frontend/src/components/common/TagBadges.tsx +++ b/frontend/src/components/common/TagBadges.tsx @@ -34,7 +34,7 @@ export function TagBadges({ }} disabled={!onTagClick} className={cn( - 'rounded-full transition-colors', + 'rounded-full font-label transition-colors', size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm', variant === 'default' ? 'bg-accent text-muted-foreground hover:bg-accent' @@ -48,7 +48,7 @@ export function TagBadges({ {hiddenCount > 0 && ( , diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index e6b3c58a..6266f44c 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -156,7 +156,7 @@ export function AccountSettingsPage() {
-

Account Settings

+

Account Settings

Manage your account, subscription, and team @@ -586,7 +586,7 @@ function UsageStat({ const isAtLimit = !isUnlimited && current >= max return ( -

+

{label}

-

Send Feedback

+

Send Feedback

Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts. diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index 1ead480a..a7b7d88a 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -63,14 +63,14 @@ export default function MyAnalyticsPage() { const outcomeBreakdown = summary.outcome_breakdown return ( -

+
{/* Header */}
-

My Analytics

+

My Analytics

@@ -85,7 +85,7 @@ export default function MyAnalyticsPage() { setPeriod(e.target.value as AnalyticsPeriod)} - className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20" > {PERIOD_OPTIONS.map((opt) => (
)}
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 28f0635b..63d8e7d3 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -33,8 +33,6 @@ export function TreeLibraryPage() { const [selectedFolderId, setSelectedFolderId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [isLoading, setIsLoading] = useState(true) - const [showDrafts, setShowDrafts] = useState(false) - // Read type filter from URL query params (e.g. /trees?type=procedural) const urlType = searchParams.get('type') const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>( @@ -131,7 +129,7 @@ export function TreeLibraryPage() { // Load trees when filters change useEffect(() => { loadTrees() - }, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts, typeFilter]) + }, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter]) // Load folders on mount and listen for changes useEffect(() => { @@ -150,7 +148,6 @@ export function TreeLibraryPage() { tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined, folder_id: selectedFolderId || undefined, sort_by: treeLibrarySortBy, - include_drafts: showDrafts || undefined, }) setTrees(treesData) } catch (err) { @@ -326,33 +323,22 @@ export function TreeLibraryPage() { {/* View Controls */}
- {/* Type filter tabs — includes Drafts as a first-class filter */} + {/* Type filter tabs */}
- {(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => { - const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t - return ( + {(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => ( - ) - })} + ))}
{/* Right controls: sort + view toggle */} diff --git a/frontend/src/types/account.ts b/frontend/src/types/account.ts index 4841530e..78185db0 100644 --- a/frontend/src/types/account.ts +++ b/frontend/src/types/account.ts @@ -44,9 +44,7 @@ export interface AccountInvite { email: string role: 'engineer' | 'viewer' code: string - invited_by_id: string - accepted_by_id: string | null - expires_at: string + expires_at: string | null used_at: string | null created_at: string } diff --git a/frontend/src/types/step.ts b/frontend/src/types/step.ts index 7cd2559e..54d5df29 100644 --- a/frontend/src/types/step.ts +++ b/frontend/src/types/step.ts @@ -121,7 +121,6 @@ export interface RatingCreate { review_text?: string was_helpful?: boolean session_id?: string - is_verified_use?: boolean } export interface RatingUpdate { diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index c9674902..dc3887bb 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -215,7 +215,6 @@ export interface TreeFilters { is_active?: boolean author_id?: string is_public?: boolean - include_drafts?: boolean sort_by?: 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version' skip?: number limit?: number -- 2.49.1 From 65b0514b40243b093b28bdf40e8829aa6abe14b3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 15:48:27 -0500 Subject: [PATCH 07/11] 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 --- frontend/src/components/common/PageLoader.tsx | 2 +- frontend/src/components/common/RouteError.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/common/PageLoader.tsx b/frontend/src/components/common/PageLoader.tsx index 0db230bd..04c54daa 100644 --- a/frontend/src/components/common/PageLoader.tsx +++ b/frontend/src/components/common/PageLoader.tsx @@ -2,7 +2,7 @@ import { Spinner } from '@/components/common/Spinner' export function PageLoader() { return ( -
+

Loading...

diff --git a/frontend/src/components/common/RouteError.tsx b/frontend/src/components/common/RouteError.tsx index 0fa4e7e9..e06c3082 100644 --- a/frontend/src/components/common/RouteError.tsx +++ b/frontend/src/components/common/RouteError.tsx @@ -17,7 +17,7 @@ export function RouteError() { } return ( -
+

Oops!

{errorMessage}

-- 2.49.1 From 3dc635659fb3727488a73340cca5934c1f6fce5e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 16:25:41 -0500 Subject: [PATCH 08/11] 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 --- frontend/src/api/client.ts | 11 ++++++++--- frontend/src/store/authStore.ts | 10 ++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 540d69dc..7e5432f0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -25,7 +25,12 @@ function handleGlobalError(error: AxiosError) { } const status = error.response.status - const data = error.response.data as { detail?: string } + const data = error.response.data as { detail?: string | unknown[] } + + // Extract a displayable error message from the response. + // FastAPI returns `detail` as a string for most errors, but 422 validation + // errors return an array of objects — we must not pass those to toast. + const detail = typeof data?.detail === 'string' ? data.detail : undefined // Don't show toast for 401 (handled by refresh interceptor) if (status === 401) { @@ -34,13 +39,13 @@ function handleGlobalError(error: AxiosError) { // Rate limit if (status === 429) { - toast.error(data?.detail || 'Too many requests — please try again shortly') + toast.error(detail || 'Too many requests — please try again shortly') return } // Client errors (4xx) — show backend detail if present if (status >= 400 && status < 500) { - toast.error(data?.detail || 'Invalid request') + toast.error(detail || 'Invalid request') return } diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 5053fcbe..e06fbc32 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -48,8 +48,9 @@ export const useAuthStore = create()( // Fetch user info await get().fetchUser() } catch (error: unknown) { - const axiosErr = error as { response?: { data?: { detail?: string } } } - const message = axiosErr.response?.data?.detail || (error instanceof Error ? error.message : 'Login failed') + const axiosErr = error as { response?: { data?: { detail?: unknown } } } + const rawDetail = axiosErr.response?.data?.detail + const message = (typeof rawDetail === 'string' ? rawDetail : null) || (error instanceof Error ? error.message : 'Login failed') set({ error: message, isLoading: false }) throw error } @@ -62,8 +63,9 @@ export const useAuthStore = create()( // After registration, log the user in await get().login({ email: data.email, password: data.password }) } catch (error: unknown) { - const axiosErr = error as { response?: { data?: { detail?: string } } } - const message = axiosErr.response?.data?.detail || (error instanceof Error ? error.message : 'Registration failed') + const axiosErr = error as { response?: { data?: { detail?: unknown } } } + const rawDetail = axiosErr.response?.data?.detail + const message = (typeof rawDetail === 'string' ? rawDetail : null) || (error instanceof Error ? error.message : 'Registration failed') set({ error: message, isLoading: false }) throw error } -- 2.49.1 From 372d412fec037ba99e15ff466f06d9e58eb2a4a5 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 17:36:19 -0500 Subject: [PATCH 09/11] 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 --- frontend/src/api/client.ts | 7 +- .../tree-editor/NodeEditorPanel.tsx | 14 ++- .../components/tree-editor/NodeFormAction.tsx | 25 ++--- frontend/src/index.css | 106 +++++++++--------- frontend/src/main.tsx | 4 + 5 files changed, 84 insertions(+), 72 deletions(-) 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', + }} /> , -- 2.49.1 From efb3ec13b4669cb4c1d82cc7de01b0f19e6d1190 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 20:29:22 -0500 Subject: [PATCH 10/11] 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 --- backend/app/main.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 91b34c91..01ccb65f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,8 +27,20 @@ def _configure_seed_module(mod: object, api_url: str, email: str, password: str) mod.ADMIN_PASSWORD = password # type: ignore[attr-defined] +async def _seed_users_directly() -> None: + """Seed test users directly via DB if they don't exist yet.""" + try: + from scripts.seed_test_users import main as seed_users + logger.info("[seed] Seeding test users directly via DB...") + await seed_users() + logger.info("[seed] Test users seeded!") + except Exception as e: + logger.warning(f"[seed] User seeding failed: {e}") + raise + + async def _seed_trees_background() -> None: - """Background task: seed all flows via HTTP API after server is ready.""" + """Background task: seed test users + all flows after server is ready.""" await asyncio.sleep(5) # Wait for server to be fully ready port = os.environ.get("PORT", "8000") api_url = f"http://127.0.0.1:{port}/api/v1" @@ -37,12 +49,17 @@ async def _seed_trees_background() -> None: try: import httpx - # Login to verify admin user exists + # Try to login — if it fails, seed users first async with httpx.AsyncClient(base_url=api_url, timeout=30) as client: login_resp = await client.post("/auth/login/json", json={"email": email, "password": password}) if login_resp.status_code != 200: - logger.warning("[seed] Could not login as admin — skipping flow seeding") - return + logger.warning("[seed] Admin login failed — seeding users first") + await _seed_users_directly() + # Retry login after seeding users + login_resp = await client.post("/auth/login/json", json={"email": email, "password": password}) + if login_resp.status_code != 200: + logger.error(f"[seed] Admin login still failing after user seed (status={login_resp.status_code}) — aborting") + return token = login_resp.json()["access_token"] # Check if trees already exist -- 2.49.1 From 96db6ed651d081a2bd917ecb8b733bec977e7064 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 21:28:08 -0500 Subject: [PATCH 11/11] 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 --- frontend/src/components/layout/Sidebar.tsx | 81 +--------------------- 1 file changed, 3 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 51490e8d..b1093390 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,32 +1,18 @@ import { useEffect, useState } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react' import { cn } from '@/lib/utils' import { useUserPreferencesStore } from '@/store/userPreferencesStore' -import { CategoryList } from '@/components/sidebar/CategoryList' -import { TagCloud } from '@/components/sidebar/TagCloud' import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection' import { NavItem } from './NavItem' -import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api' +import { sessionsApi, treesApi } from '@/api' import { pinnedFlowsApi } from '@/api/pinnedFlows' import type { PinnedFlow } from '@/api/pinnedFlows' import { toast } from '@/lib/toast' -interface CategoryItem { - id: string - name: string - color: string - count: number -} - export function Sidebar() { const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed) const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar) - const [categories, setCategories] = useState([]) - const [tags, setTags] = useState([]) - const [activeCategoryId, setActiveCategoryId] = useState(null) - const [activeTags, setActiveTags] = useState([]) const [activeSessionCount, setActiveSessionCount] = useState(0) const [pinnedFlows, setPinnedFlows] = useState([]) const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 }) @@ -35,20 +21,11 @@ export function Sidebar() { useEffect(() => { const fetchData = async () => { try { - const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([ - categoriesApi.list(), - tagsApi.list().catch(() => []), + const [activeSessions, allTrees, pinnedData] = await Promise.all([ sessionsApi.list({ completed: false, size: 50 }).catch(() => []), treesApi.list({ sort_by: 'name' }).catch(() => []), pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })), ]) - setCategories(cats.map(c => ({ - id: c.id, - name: c.name, - color: c.color || '#3b82f6', - count: c.tree_count || 0, - }))) - setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15)) setActiveSessionCount(activeSessions.length) setPinnedFlows(pinnedData.items) @@ -64,44 +41,6 @@ export function Sidebar() { fetchData() }, []) - const navigate = useNavigate() - const location = useLocation() - - // Sync active filters from URL when on /trees page - useEffect(() => { - if (location.pathname === '/trees') { - const params = new URLSearchParams(location.search) - setActiveCategoryId(params.get('category') || null) - const tagsParam = params.get('tags') - setActiveTags(tagsParam ? tagsParam.split(',') : []) - } - }, [location.pathname, location.search]) - - const handleCategorySelect = (id: string | null) => { - setActiveCategoryId(id) - const params = new URLSearchParams(location.search) - if (id) { - params.set('category', id) - } else { - params.delete('category') - } - navigate(`/trees?${params.toString()}`) - } - - const handleTagClick = (tag: string) => { - const next = activeTags.includes(tag) - ? activeTags.filter(t => t !== tag) - : [...activeTags, tag] - setActiveTags(next) - const params = new URLSearchParams(location.search) - if (next.length > 0) { - params.set('tags', next.join(',')) - } else { - params.delete('tags') - } - navigate(`/trees?${params.toString()}`) - } - const handleUnpin = async (treeId: string) => { try { await pinnedFlowsApi.unpin(treeId) @@ -175,24 +114,10 @@ export function Sidebar() {
- -
- - {/* Categories */} - - -
- - {/* Tags */} - )} - {/* Spacer */} + {/* Spacer — pushes footer to bottom */}
{/* Footer */} -- 2.49.1