fix: UX deep dive — 28 fixes across authoring, navigation, consistency, and cleanup (#86)

* 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>
This commit was merged in pull request #86.
This commit is contained in:
chihlasm
2026-02-19 22:10:47 -05:00
committed by GitHub
parent 9462d8b15a
commit aef40078d0
47 changed files with 864 additions and 626 deletions

View File

@@ -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 (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}
@@ -155,7 +156,7 @@ export function AccountSettingsPage() {
<div className="mb-8">
<div className="flex items-center gap-3">
<Building2 className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">Account Settings</h1>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Account Settings</h1>
</div>
<p className="mt-2 text-muted-foreground">
Manage your account, subscription, and team
@@ -585,7 +586,7 @@ function UsageStat({
const isAtLimit = !isUnlimited && current >= max
return (
<div className="glass-stat rounded-md p-3">
<div className="rounded-md border border-border bg-card p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(

View File

@@ -1,242 +0,0 @@
import { useState, useEffect } from 'react'
import { Plus } from 'lucide-react'
import { DndContext, closestCenter } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { stepCategoriesApi } from '@/api/stepCategories'
import { stepsApi } from '@/api/steps'
import { CategoryRow } from '@/components/admin/CategoryRow'
import { CreateCategoryModal } from '@/components/admin/CreateCategoryModal'
import { EditCategoryModal } from '@/components/admin/EditCategoryModal'
import type { StepCategoryListItem } from '@/types'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
export function AdminCategoriesPage() {
const [categories, setCategories] = useState<StepCategoryListItem[]>([])
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<StepCategoryListItem | null>(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 (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
</div>
)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">
Step Categories
</h1>
<p className="mt-2 text-muted-foreground">
Manage categories for organizing step library
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
'hover:opacity-90'
)}
>
<Plus className="h-4 w-4" />
Create Category
</button>
</div>
{/* Filter Toggle */}
<div className="mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeArchived}
onChange={(e) => setIncludeArchived(e.target.checked)}
className="h-4 w-4 rounded border-border text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
/>
<span className="text-sm text-muted-foreground">Show archived categories</span>
</label>
</div>
{/* Categories List */}
{categories.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center">
<p className="text-muted-foreground">
No categories found. Create your first category to get started.
</p>
</div>
) : (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={categories.map(c => c.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{categories.map(category => (
<CategoryRow
key={category.id}
category={category}
stepCount={getStepCount(category.id)}
onEdit={openEditModal}
onArchive={handleArchive}
onRestore={handleRestore}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Create Modal */}
<CreateCategoryModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isSaving={isSaving}
/>
{/* Edit Modal */}
<EditCategoryModal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false)
setEditingCategory(null)
}}
onSubmit={handleEdit}
category={editingCategory}
isSaving={isSaving}
/>
</div>
)
}
export default AdminCategoriesPage

View File

@@ -135,7 +135,7 @@ export function FeedbackPage() {
<div className="mb-8">
<div className="flex items-center gap-3">
<MessageSquareText className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">Send Feedback</h1>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Send Feedback</h1>
</div>
<p className="mt-2 text-muted-foreground">
Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts.

View File

@@ -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 (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
<Spinner />
</div>
)
}
@@ -62,14 +63,14 @@ export default function MyAnalyticsPage() {
const outcomeBreakdown = summary.outcome_breakdown
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span title="My Analytics">
<BarChart3 size={24} className="text-foreground" />
</span>
<h1 className="text-2xl font-bold text-foreground">My Analytics</h1>
<h1 className="text-2xl font-bold font-heading text-foreground">My Analytics</h1>
</div>
<div className="flex items-center gap-3">
@@ -84,7 +85,7 @@ export default function MyAnalyticsPage() {
<select
value={period}
onChange={(e) => 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) => (
<option key={opt.value} value={opt.value}>

View File

@@ -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<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(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 (
<div className="flex items-center justify-center py-32">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}
@@ -139,18 +141,20 @@ export default function MySharesPage() {
{/* Empty state */}
{shares.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center">
<Link2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">No shared sessions</h2>
<p className="text-muted-foreground text-sm mb-6">
Share a session from the session detail page to create a link
</p>
<button
onClick={() => navigate('/sessions')}
className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
Go to Sessions
</button>
<div className="bg-card border border-border rounded-xl">
<EmptyState
icon={<Link2 className="h-12 w-12" />}
title="No shared sessions"
description="Share a session from the session detail page to create a link"
action={
<button
onClick={() => navigate('/sessions')}
className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
Go to Sessions
</button>
}
/>
</div>
) : (
<div className="space-y-4">
@@ -223,7 +227,7 @@ export default function MySharesPage() {
</Link>
<button
onClick={() => handleRevoke(share)}
onClick={() => setRevokeTarget(share)}
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -235,6 +239,15 @@ export default function MySharesPage() {
})}
</div>
)}
<ConfirmDialog
isOpen={!!revokeTarget}
onClose={() => 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"
/>
</div>
)
}

View File

@@ -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'
@@ -115,7 +116,7 @@ export function MyTreesPage() {
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div>
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">My Flows</h1>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">My Flows</h1>
<p className="mt-2 text-muted-foreground">
Your forked and custom flows
</p>
@@ -177,7 +178,7 @@ export function MyTreesPage() {
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
) : trees.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center">

View File

@@ -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'
@@ -83,13 +84,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')
}
}
@@ -143,7 +144,7 @@ export function ProceduralEditorPage() {
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}
@@ -154,7 +155,7 @@ export function ProceduralEditorPage() {
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/my-trees')}
onClick={() => navigate('/trees')}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="h-5 w-5" />

View File

@@ -9,6 +9,8 @@ import { StepChecklist } from '@/components/procedural/StepChecklist'
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'
@@ -35,6 +37,7 @@ export function ProceduralNavigationPage() {
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
const [isComplete, setIsComplete] = useState(false)
const [completedAt, setCompletedAt] = useState<string>('')
const [showExitConfirm, setShowExitConfirm] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [paramsOpen, setParamsOpen] = useState(false)
const [showCsatModal, setShowCsatModal] = useState(false)
@@ -117,7 +120,7 @@ export function ProceduralNavigationPage() {
}
} catch {
toast.error('Failed to load flow')
navigate('/my-trees')
navigate('/trees')
} finally {
setIsLoading(false)
}
@@ -177,7 +180,7 @@ export function ProceduralNavigationPage() {
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
} catch {
toast.error('Failed to resume session')
navigate('/my-trees')
navigate('/trees')
}
}
@@ -288,7 +291,7 @@ export function ProceduralNavigationPage() {
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}
@@ -301,7 +304,7 @@ export function ProceduralNavigationPage() {
fields={tree.intake_form || []}
treeName={tree.name}
onSubmit={handleIntakeSubmit}
onCancel={() => navigate('/my-trees')}
onCancel={() => navigate('/trees')}
/>
)
}
@@ -327,7 +330,7 @@ export function ProceduralNavigationPage() {
startedAt={session.started_at}
completedAt={completedAt}
onExport={() => navigate(`/sessions/${session.id}`)}
onClose={() => navigate('/my-trees')}
onClose={() => navigate('/trees')}
/>
</div>
)
@@ -354,6 +357,19 @@ export function ProceduralNavigationPage() {
<ListOrdered className="h-5 w-5 text-muted-foreground" />
<h1 className="text-sm font-semibold text-foreground sm:text-base">{tree.name}</h1>
</div>
<button
onClick={() => {
if (currentStepIndex > 0) {
setShowExitConfirm(true)
} else {
navigate('/trees')
}
}}
className="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"
>
<X className="mr-1 inline h-3.5 w-3.5" />
Exit
</button>
</div>
<div className="mt-2">
<ProgressBar
@@ -433,6 +449,15 @@ export function ProceduralNavigationPage() {
/>
)}
<ConfirmDialog
isOpen={showExitConfirm}
onClose={() => 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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center">

View File

@@ -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'
@@ -243,8 +244,7 @@ export function SessionDetailPage() {
rating: data.rating,
review_text: data.review || undefined,
was_helpful: data.helpful !== null ? data.helpful : undefined,
session_id: session.id,
is_verified_use: true
session_id: session.id
})
)
@@ -289,7 +289,7 @@ export function SessionDetailPage() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" />
<Spinner />
</div>
)
}

View File

@@ -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'
@@ -15,6 +17,7 @@ export function SessionHistoryPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [sessions, setSessions] = useState<Session[]>([])
const [hasMore, setHasMore] = useState(false)
const [trees, setTrees] = useState<TreeListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
@@ -109,8 +112,10 @@ export function SessionHistoryPage() {
}
}
const sessionsData = await sessionsApi.list(params)
setSessions(sessionsData)
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
const truncated = sessionsData.length > 50
setHasMore(truncated)
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
} catch (err) {
toast.error('Failed to load sessions')
console.error(err)
@@ -188,27 +193,22 @@ export function SessionHistoryPage() {
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" />
<Spinner />
</div>
) : sessions.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No sessions found.{' '}
{filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? (
<button
onClick={handleClearFilters}
className="text-foreground hover:underline"
>
Clear filters
</button>
) : (
<button
onClick={() => navigate('/trees')}
className="text-foreground hover:underline"
>
Start a new session
</button>
)}
</div>
<EmptyState
title="No sessions found"
description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
? "Try adjusting your filters"
: "Complete a flow to see it here"}
action={
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
Clear all filters
</button>
) : undefined
}
/>
) : (
<div className="space-y-4">
{sessions.map((session) => (
@@ -298,6 +298,15 @@ export function SessionHistoryPage() {
</div>
</div>
))}
{hasMore ? (
<p className="text-center text-sm text-muted-foreground py-4">
Showing the 50 most recent sessions
</p>
) : sessions.length > 0 ? (
<p className="text-center text-sm text-muted-foreground py-4">
Showing all {sessions.length} sessions
</p>
) : null}
</div>
)}
</div>

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner />
<p className="text-sm text-muted-foreground">Loading shared session...</p>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { Bookmark } from 'lucide-react'
export default function StepLibraryPage() {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
<div className="flex items-center gap-3">
<span title="Step Library"><Bookmark className="h-8 w-8 text-muted-foreground" /></span>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Step Library</h1>
</div>
<p className="mt-2 text-muted-foreground">Reusable steps for your flows coming soon.</p>
</div>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Bookmark className="h-8 w-8 text-primary" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">Coming Soon</h2>
<p className="max-w-md text-sm text-muted-foreground">
The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows.
</p>
</div>
</div>
)
}

View File

@@ -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, 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'
@@ -44,31 +45,14 @@ export default function TeamAnalyticsPage() {
.finally(() => setLoading(false))
}, [period, isAccountOwner, isSuperAdmin])
// Permission guard
if (!isAccountOwner && !isSuperAdmin) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-6">
<ShieldX size={48} className="text-muted-foreground" />
<h2 className="text-xl font-semibold text-foreground">Access Denied</h2>
<p className="text-muted-foreground text-center max-w-md">
Team Analytics is only available to account owners and administrators.
You can view your personal stats instead.
</p>
<Link
to="/analytics/me"
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
>
<TrendingUp size={16} />
View My Stats
</Link>
</div>
)
return <Navigate to="/analytics/me" replace />
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
<Spinner />
</div>
)
}
@@ -84,14 +68,14 @@ export default function TeamAnalyticsPage() {
const { summary, time_series, top_flows, top_engineers } = data
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span title="Team Analytics">
<BarChart3 size={24} className="text-foreground" />
</span>
<h1 className="text-2xl font-bold text-foreground">Team Analytics</h1>
<h1 className="text-2xl font-bold font-heading text-foreground">Team Analytics</h1>
</div>
<div className="flex items-center gap-3">
@@ -104,7 +88,7 @@ export default function TeamAnalyticsPage() {
<select
value={period}
onChange={(e) => 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) => (
<option key={opt.value} value={opt.value}>

View File

@@ -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'
@@ -141,6 +142,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 +157,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 +165,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 {
@@ -379,7 +383,7 @@ export function TreeEditorPage() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}

View File

@@ -33,8 +33,6 @@ export function TreeLibraryPage() {
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(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'>(
@@ -75,7 +73,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 }
})()
@@ -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 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Type filter tabs — includes Drafts as a first-class filter */}
{/* Type filter tabs */}
<div className="flex rounded-lg border border-border p-0.5">
{(['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) => (
<button
key={t}
onClick={() => {
if (t === 'drafts') {
setShowDrafts(true)
setTypeFilter('all')
} else {
setShowDrafts(false)
setTypeFilter(t)
}
}}
onClick={() => setTypeFilter(t)}
className={cn(
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
isActive
typeFilter === t
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : t === 'maintenance' ? 'Maintenance' : 'Drafts'}
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
</button>
)
})}
))}
</div>
{/* Right controls: sort + view toggle */}
@@ -450,7 +436,7 @@ export function TreeLibraryPage() {
{lastSessionData && (
<div className="mb-6">
<button
onClick={() => navigate(`/trees/${lastSessionData.tree_id}/navigate`, {
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
})}
className={cn(

View File

@@ -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 (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
<Spinner />
</div>
)
}

View File

@@ -83,7 +83,7 @@ export function TeamCategoriesPage() {
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Team Categories</h1>
<h1 className="text-2xl font-bold font-heading text-foreground">Team Categories</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>

View File

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